"use client"; import { useEffect, useId, useRef, useState, type CSSProperties, type ElementType, type FormEvent } from "react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; export type ContactFlowData = { topic: string; name: string; email: string; message: string }; const LABELS = { topic: "What is this about?", name: "Your name", email: "Email", message: "How can we help?", next: "Next", back: "Back", send: "Send message", sending: "Sending…", step: "Step", of: "of", pickTopic: "Pick one to go on.", needName: "Please add your name.", needEmail: "Please add a valid email.", needMessage: "Please write a short message.", failed: "That did not send. Please try again.", doneTitle: "Message sent", doneText: "Thanks. We will write back soon.", again: "Send another", }; type ContactFlowProps = { /** The choices on the first step. */ topics: string[]; /** Sends the message. Throw or reject to show the error line. */ onSubmit: (data: ContactFlowData) => void | Promise; title?: string; /** Small line above the title. */ eyebrow?: string; subtitle?: string; /** Other ways to reach you, beside the form. */ channels?: { label: string; value: string; href?: string }[]; /** Every word in the form, for other languages. Give only the ones to change. */ labels?: Partial; /** Colour of the bar, the choice and the main button. */ color?: string; /** Text colour on that colour. */ ink?: string; /** Card colour. */ tile?: string; /** h2 by default. */ headingAs?: ElementType; /** Set padding and colours here. */ className?: string; }; const EMPTY: ContactFlowData = { topic: "", name: "", email: "", message: "" }; const STEPS = 3; const FIELD = "mt-1.5 w-full rounded-lg border border-current/15 bg-transparent px-3 py-2 text-sm font-normal outline-none transition-colors focus:border-[var(--cf-color)] aria-invalid:border-red-400"; /* ContactFlow: a contact form taken one step at a time. The card reshapes to fit each step, focus moves with you, and the sent state draws a tick. It only collects: you send the data in onSubmit. */ export function ContactFlow({ topics, onSubmit, title, eyebrow, subtitle, channels, labels, color = "#a78bfa", ink = "#0b0a14", tile = "#15122a", headingAs: Heading = "h2", className = "bg-[#0b0a14] px-5 py-16 text-white", }: ContactFlowProps) { const reduce = useReducedMotion(); const id = useId(); const words = { ...LABELS, ...labels }; const inner = useRef(null); const card = useRef(null); const moved = useRef(false); const [height, setHeight] = useState(); const [step, setStep] = useState(0); const [way, setWay] = useState(1); const [data, setData] = useState(EMPTY); const [error, setError] = useState(""); const [state, setState] = useState<"open" | "sending" | "sent">("open"); const sent = state === "sent"; useEffect(() => { const el = inner.current; if (!el) return; const watcher = new ResizeObserver(([entry]) => setHeight(entry.target.getBoundingClientRect().height)); watcher.observe(el); return () => watcher.disconnect(); }, []); useEffect(() => { if (!moved.current) return; card.current?.querySelector(`[data-step="${sent ? "done" : step}"] :is(input, textarea, button)`)?.focus({ preventScroll: true }); }, [sent, step]); const set = (key: keyof ContactFlowData, value: string) => { setData((now) => ({ ...now, [key]: value })); setError(""); }; const check = () => { if (step === 0 && !data.topic) return words.pickTopic; if (step === 1 && !data.name.trim()) return words.needName; if (step === 1 && !/^\S+@\S+\.\S+$/.test(data.email.trim())) return words.needEmail; if (step === 2 && !data.message.trim()) return words.needMessage; return ""; }; const go = (to: number) => { moved.current = true; setWay(to > step ? 1 : -1); setError(""); setStep(to); }; const submit = async (e: FormEvent) => { e.preventDefault(); if (state === "sending") return; const problem = check(); if (problem) return setError(problem); if (step < STEPS - 1) return go(step + 1); setState("sending"); setError(""); try { await onSubmit(data); moved.current = true; setState("sent"); } catch { setState("open"); setError(words.failed); } }; const again = () => { setData(EMPTY); setStep(0); setState("open"); }; const errorFor = (bad: boolean) => (bad && error ? { "aria-invalid": true, "aria-describedby": `${id}-error` } : {}); const slide = { initial: { opacity: 0, x: reduce ? 0 : way * 36 }, animate: { opacity: 1, x: 0 }, exit: { opacity: 0, x: reduce ? 0 : way * -36 }, transition: { duration: reduce ? 0 : 0.3, ease: [0.16, 1, 0.3, 1] as const }, }; return (
{title && (
{eyebrow &&

{eyebrow}

} {title} {subtitle &&

{subtitle}

}
)} {channels && channels.length > 0 && (
{channels.map((item) => (
{item.label}
{item.href ? ( {item.value} ) : ( item.value )}
))}
)}
{sent ? (

{words.doneTitle}

{words.doneText}

) : (

{words.step} {step + 1} {words.of} {STEPS}

{step === 0 && (
{words.topic}
{topics.map((topic) => ( ))}
)} {step === 1 && (
)} {step === 2 && (