"use client"; import { Clock, type LucideIcon, Mail, MapPin, MessagesSquare, User, } from "lucide-react"; import { AnimatePresence, animate, motion, useReducedMotion, } from "motion/react"; import { type FormEvent, type ReactNode, useId, useMemo, useRef, useState } from "react"; import { Button, StatefulButton } from "@/components/motion/button"; import { Input } from "@/components/motion/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/motion/select"; import { Textarea } from "@/components/motion/textarea"; import { EASE_OUT, SPRING_LAYOUT, SPRING_SWAP } from "@/lib/ease"; import { cn } from "@/lib/utils"; export type ContactValues = { name: string; email: string; topic: string; message: string; }; export type ContactErrors = Partial>; export type ContactTopic = { value: string; label: string }; export type ContactMethod = { id: string; icon: LucideIcon; label: string; value: ReactNode; /** Makes the row a link (mailto:, tel:, https:). */ href?: string; }; export interface ContactFormProps { defaultValues?: Partial; /** Called with valid values. Reject with a message to show it and shake the form. */ onSubmit?: (values: ContactValues) => void | Promise; validate?: (values: ContactValues) => ContactErrors; topics?: ContactTopic[]; /** Side panel rows. Pass `[]` to hide the panel. */ methods?: ContactMethod[]; /** Character limit for the message (counter shows). Default 500. */ maxLength?: number; /** Minimum message length. Default 10. */ minLength?: number; title?: ReactNode; description?: ReactNode; className?: string; } const DEFAULT_TOPICS: ContactTopic[] = [ { value: "sales", label: "Sales" }, { value: "support", label: "Support" }, { value: "partnership", label: "Partnership" }, { value: "other", label: "Something else" }, ]; const DEFAULT_METHODS: ContactMethod[] = [ { id: "email", icon: Mail, label: "Email", value: "hello@example.com", href: "mailto:hello@example.com", }, { id: "chat", icon: MessagesSquare, label: "Live chat", value: "Mon–Fri, 9am–6pm" }, { id: "office", icon: MapPin, label: "Office", value: "100 Example St, Springfield" }, { id: "reply", icon: Clock, label: "Response time", value: "Within one business day" }, ]; const EMPTY: ContactValues = { name: "", email: "", topic: "", message: "" }; const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const SHAKE = { x: [0, -8, 8, -5, 5, -2, 0] }; function messageOf(reason: unknown) { if (reason instanceof Error && reason.message) return reason.message; if (typeof reason === "string" && reason) return reason; return "Something went wrong. Try again."; } /** * Contact section: name / email / topic `Select` / message `Textarea` with a * live counter, "reward early, punish late" validation (as in `SignUpForm`), * an async submit through `StatefulButton` that shakes on failure, and a * success panel the card morphs into. A side panel lists contact methods. */ export function ContactForm({ defaultValues, onSubmit, validate, topics = DEFAULT_TOPICS, methods = DEFAULT_METHODS, maxLength = 500, minLength = 10, title = "Get in touch", description = "Tell us what you need and the right person will reply.", className, }: ContactFormProps) { const reduce = useReducedMotion(); const baseId = useId(); const [values, setValues] = useState({ ...EMPTY, ...defaultValues }); const [touched, setTouched] = useState>>({}); const [status, setStatus] = useState<"idle" | "loading" | "success" | "error">("idle"); const [formError, setFormError] = useState(); const [sent, setSent] = useState(null); const shakeRef = useRef(null); const errors = useMemo(() => { if (validate) return validate(values); const next: ContactErrors = {}; if (!values.name.trim()) next.name = "Enter your name."; if (!values.email.trim()) next.email = "Enter your email."; else if (!EMAIL_PATTERN.test(values.email)) next.email = "That doesn't look like an email address."; if (!values.topic) next.topic = "Pick a topic."; if (values.message.trim().length < minLength) next.message = `Write at least ${minLength} characters.`; return next; }, [values, validate, minLength]); const shown = (key: keyof ContactValues) => (touched[key] ? errors[key] : undefined); const ok = (key: keyof ContactValues) => Boolean(touched[key]) && !errors[key] && Boolean(values[key]); const set = (key: keyof ContactValues, next: string) => { setValues((prev) => ({ ...prev, [key]: next })); if (status === "error") setStatus("idle"); }; const touch = (key: keyof ContactValues) => setTouched((prev) => (prev[key] ? prev : { ...prev, [key]: true })); const shake = () => { if (!reduce && shakeRef.current) { animate(shakeRef.current, SHAKE, { duration: 0.42, ease: EASE_OUT }); } }; const handleSubmit = async (event: FormEvent) => { event.preventDefault(); setTouched({ name: true, email: true, topic: true, message: true }); if (Object.keys(errors).length > 0) { shake(); return; } setFormError(undefined); setStatus("loading"); try { await onSubmit?.(values); setStatus("success"); setSent(values); } catch (reason) { setStatus("error"); setFormError(messageOf(reason)); shake(); } }; const restart = () => { setValues({ ...EMPTY, name: values.name, email: values.email }); setTouched({}); setStatus("idle"); setSent(null); }; const busy = status === "loading"; const topicErrorId = `${baseId}-topic-error`; const topicLabelId = `${baseId}-topic-label`; const offset = reduce ? 0 : 16; return (
0 && "@3xl:grid-cols-[minmax(0,1fr)_14rem]", )} > {/* The card resizes between the form and the thank-you panel; `layout` is a layout job here by design, with content counter-corrected. */} {sent ? (

Message sent

Thanks, {sent.name.split(" ")[0]}. We'll reply to{" "} {sent.email} soon.

) : (
{title || description ? (
{title ? (

{title}

) : null} {description ? (

{description}

) : null}
) : null}
} disabled={busy} value={values.name} onChange={(next) => set("name", next)} onBlur={() => touch("name")} error={shown("name")} success={ok("name")} /> } disabled={busy} value={values.email} onChange={(next) => set("email", next)} onBlur={() => touch("email")} error={shown("email")} success={ok("email")} />
Topic
{shown("topic") ? ( {shown("topic")} ) : null}