"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { ArrowRight, ChevronDown, Clock, LoaderCircle, Mail, MapPin, Phone } from "lucide-react";
import { cn } from "@/lib/utils";
export type ContactInfo = { icon: "email" | "phone" | "office" | "hours"; label: string; value: string; href?: string; note?: string };
export type ContactValues = { name: string; email: string; topic: string; message: string };
export interface ContactSplitProps {
eyebrow?: string;
title?: string;
description?: string;
info?: ContactInfo[];
topics?: string[];
/** Called with the form values after validation passes. Return a promise to keep the loading state until it resolves. */
onSubmit?: (values: ContactValues) => void | Promise<void>;
successTitle?: string;
successMessage?: string;
className?: string;
}
const ICONS = { email: Mail, phone: Phone, office: MapPin, hours: Clock } as const;
const DEFAULT_INFO: ContactInfo[] = [
{ icon: "email", label: "Email", value: "[email protected]", href: "mailto:[email protected]", note: "We reply within one business day" },
{ icon: "phone", label: "Phone", value: "+1 (555) 014-2290", href: "tel:+15550142290", note: "Mon–Fri, 9am–6pm" },
{ icon: "office", label: "Office", value: "221 Harbor Street, Suite 400", note: "Portland, OR 97204" },
{ icon: "hours", label: "Support hours", value: "24/7 for Business plans", note: "Live chat inside the app" },
];
const DEFAULT_TOPICS = ["Sales & pricing", "Technical support", "Partnerships", "Press", "Something else"];
type Errors = Partial<Record<keyof ContactValues, string>>;
function validate(v: ContactValues): Errors {
const e: Errors = {};
if (v.name.trim().length < 2) e.name = "Please enter your name.";
if (!/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(v.email.trim())) e.email = "Enter a valid email address.";
if (!v.topic) e.topic = "Choose a topic so we can route your message.";
if (v.message.trim().length < 10) e.message = "Tell us a little more (at least 10 characters).";
return e;
}
const MAX_MESSAGE = 600;
function Field({
id,
label,
error,
focused,
children,
hint,
}: {
id: string;
label: string;
error?: string;
focused: boolean;
children: React.ReactNode;
hint?: React.ReactNode;
}) {
return (
<div>
<div className="flex items-baseline justify-between">
<label htmlFor={id} className={cn("text-sm font-medium transition-colors", error ? "text-destructive" : focused ? "text-primary" : "text-foreground")}>
{label}
</label>
{hint}
</div>
<div className="relative mt-2">
{children}
{/* animated focus glow */}
<motion.span
aria-hidden
className={cn("pointer-events-none absolute -inset-px rounded-xl ring-2", error ? "ring-destructive/60" : "ring-primary/70")}
initial={false}
animate={{ opacity: focused || error ? 1 : 0, scale: focused ? 1 : 0.985 }}
transition={{ duration: 0.2 }}
/>
<motion.span
aria-hidden
className="pointer-events-none absolute inset-x-4 -bottom-px h-px origin-left bg-gradient-to-r from-violet-500 via-fuchsia-500 to-amber-400"
initial={false}
animate={{ scaleX: focused && !error ? 1 : 0 }}
transition={{ type: "spring", stiffness: 300, damping: 30 }}
/>
</div>
<AnimatePresence initial={false}>
{error && (
<motion.p
id={`${id}-error`}
initial={{ opacity: 0, height: 0, y: -4 }}
animate={{ opacity: 1, height: "auto", y: 0 }}
exit={{ opacity: 0, height: 0, y: -4 }}
className="overflow-hidden text-xs font-medium text-destructive"
>
<span className="block pt-1.5">{error}</span>
</motion.p>
)}
</AnimatePresence>
</div>
);
}
const BURST = Array.from({ length: 12 }, (_, i) => {
const angle = (i / 12) * Math.PI * 2;
const dist = 58 + (i % 3) * 14;
return { x: Math.cos(angle) * dist, y: Math.sin(angle) * dist, c: ["bg-violet-500", "bg-fuchsia-500", "bg-amber-400", "bg-sky-500"][i % 4] };
});
export function ContactSplit({
eyebrow = "Contact",
title = "Let's build something great together",
description = "Questions about pricing, a tricky integration, or just want to say hi? Drop us a line and a real human will get back to you.",
info = DEFAULT_INFO,
topics = DEFAULT_TOPICS,
onSubmit,
successTitle = "Message sent!",
successMessage = "Thanks for reaching out. We've got your note and will reply to your inbox within one business day.",
className,
}: ContactSplitProps) {
const reduce = useReducedMotion();
const baseId = React.useId();
const empty: ContactValues = { name: "", email: "", topic: "", message: "" };
const [values, setValues] = React.useState<ContactValues>(empty);
const [touched, setTouched] = React.useState<Partial<Record<keyof ContactValues, boolean>>>({});
const [focus, setFocus] = React.useState<keyof ContactValues | null>(null);
const [status, setStatus] = React.useState<"idle" | "sending" | "sent">("idle");
const [attempted, setAttempted] = React.useState(false);
const formRef = React.useRef<HTMLFormElement>(null);
const errors = validate(values);
const visibleError = (k: keyof ContactValues) => ((touched[k] || attempted) && errors[k]) || undefined;
const set = (k: keyof ContactValues) => (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) =>
setValues((v) => ({ ...v, [k]: k === "message" ? e.target.value.slice(0, MAX_MESSAGE) : e.target.value }));
const bind = (k: keyof ContactValues) => ({
id: `${baseId}-${k}`,
name: k,
value: values[k],
onChange: set(k),
onFocus: () => setFocus(k),
onBlur: () => {
setFocus(null);
setTouched((t) => ({ ...t, [k]: true }));
},
"aria-invalid": !!visibleError(k) || undefined,
"aria-describedby": visibleError(k) ? `${baseId}-${k}-error` : undefined,
});
const submit = async (e: React.FormEvent) => {
e.preventDefault();
setAttempted(true);
if (Object.keys(errors).length) {
const first = (Object.keys(errors) as (keyof ContactValues)[])[0];
formRef.current?.querySelector<HTMLElement>(`[name="${first}"]`)?.focus();
return;
}
setStatus("sending");
// No network call: simulate a short round-trip unless a handler is provided.
await (onSubmit ? onSubmit(values) : new Promise((r) => setTimeout(r, 1100)));
setStatus("sent");
};
const reset = () => {
setValues(empty);
setTouched({});
setAttempted(false);
setStatus("idle");
};
const inputCls = (k: keyof ContactValues) =>
cn(
"w-full rounded-xl border bg-background px-4 text-[15px] text-foreground outline-none transition-colors placeholder:text-muted-foreground/70",
visibleError(k) ? "border-destructive/60" : "hover:border-foreground/20",
);
return (
<section className={cn("relative w-full overflow-hidden bg-background px-4 py-16 sm:px-6 sm:py-20", className)}>
<div aria-hidden className="pointer-events-none absolute -left-32 bottom-0 size-[480px] rounded-full bg-[radial-gradient(closest-side,color-mix(in_oklch,var(--primary)_14%,transparent),transparent)]" />
<div className="relative mx-auto grid max-w-6xl gap-12 lg:grid-cols-[minmax(0,5fr)_minmax(0,7fr)] lg:gap-16">
<div>
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-primary">{eyebrow}</p>
<h2 className="mt-3 text-balance text-3xl font-semibold tracking-tight text-foreground sm:text-5xl">{title}</h2>
<p className="mt-5 max-w-md text-pretty text-base leading-relaxed text-muted-foreground">{description}</p>
<ul className="mt-10 grid gap-3 sm:grid-cols-2 lg:grid-cols-1">
{info.map((item, i) => {
const Icon = ICONS[item.icon];
const inner = (
<>
<span className="grid size-11 shrink-0 place-items-center rounded-xl border bg-card text-primary shadow-sm transition group-hover:scale-105 group-hover:border-primary/40">
<Icon aria-hidden className="size-5" />
</span>
<span className="min-w-0">
<span className="block text-xs font-medium uppercase tracking-wider text-muted-foreground">{item.label}</span>
<span className="mt-0.5 block truncate font-medium text-foreground">{item.value}</span>
{item.note && <span className="block text-sm text-muted-foreground">{item.note}</span>}
</span>
</>
);
return (
<motion.li
key={item.label}
initial={reduce ? false : { opacity: 0, x: -16 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{ delay: i * 0.07, duration: 0.45, ease: [0.22, 1, 0.36, 1] }}
>
{item.href ? (
<a href={item.href} className="group flex items-start gap-4 rounded-2xl p-2 transition hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
{inner}
</a>
) : (
<div className="group flex items-start gap-4 p-2">{inner}</div>
)}
</motion.li>
);
})}
</ul>
</div>
<div className="relative min-h-[520px] rounded-3xl border bg-card p-6 shadow-xl shadow-black/[0.03] sm:p-8 dark:shadow-black/30">
<AnimatePresence mode="wait" initial={false}>
{status !== "sent" ? (
<motion.form
key="form"
ref={formRef}
noValidate
onSubmit={submit}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.97, filter: reduce ? "blur(0px)" : "blur(4px)" }}
transition={{ duration: 0.3 }}
className="space-y-5"
aria-label="Contact form"
>
<div className="grid gap-5 sm:grid-cols-2">
<Field id={`${baseId}-name`} label="Full name" error={visibleError("name")} focused={focus === "name"}>
<input {...bind("name")} autoComplete="name" placeholder="Jordan Avery" className={cn(inputCls("name"), "h-12")} />
</Field>
<Field id={`${baseId}-email`} label="Work email" error={visibleError("email")} focused={focus === "email"}>
<input {...bind("email")} type="email" autoComplete="email" placeholder="[email protected]" className={cn(inputCls("email"), "h-12")} />
</Field>
</div>
<Field id={`${baseId}-topic`} label="Topic" error={visibleError("topic")} focused={focus === "topic"}>
<select {...bind("topic")} className={cn(inputCls("topic"), "h-12 cursor-pointer appearance-none pr-10", !values.topic && "text-muted-foreground/80")}>
<option value="" disabled>
What can we help with?
</option>
{topics.map((t) => (
<option key={t} value={t} className="text-foreground">
{t}
</option>
))}
</select>
<ChevronDown aria-hidden className="pointer-events-none absolute right-4 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
</Field>
<Field
id={`${baseId}-message`}
label="Message"
error={visibleError("message")}
focused={focus === "message"}
hint={
<span className={cn("text-xs tabular-nums", values.message.length > MAX_MESSAGE * 0.9 ? "text-amber-600 dark:text-amber-400" : "text-muted-foreground")}>
{values.message.length}/{MAX_MESSAGE}
</span>
}
>
<textarea {...bind("message")} rows={6} placeholder="Tell us about your project, timeline and team size…" className={cn(inputCls("message"), "block resize-none py-3 leading-relaxed")} />
</Field>
<div className="flex flex-col-reverse gap-4 pt-1 sm:flex-row sm:items-center sm:justify-between">
<p className="text-xs text-muted-foreground">We'll never share your details. No spam, ever.</p>
<motion.button
type="submit"
disabled={status === "sending"}
whileTap={reduce ? undefined : { scale: 0.97 }}
className="group inline-flex h-12 items-center justify-center gap-2 rounded-xl bg-primary px-6 text-sm font-semibold text-primary-foreground shadow-lg shadow-primary/25 transition hover:brightness-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-wait disabled:opacity-80"
>
{status === "sending" ? (
<>
<LoaderCircle aria-hidden className="size-4 animate-spin" /> Sending…
</>
) : (
<>
Send message <ArrowRight aria-hidden className="size-4 transition-transform group-hover:translate-x-0.5" />
</>
)}
</motion.button>
</div>
</motion.form>
) : (
<motion.div
key="success"
role="status"
initial={{ opacity: 0, scale: 0.96 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0 }}
transition={{ type: "spring", stiffness: 260, damping: 24 }}
className="absolute inset-0 flex flex-col items-center justify-center p-8 text-center"
>
<div className="relative grid size-24 place-items-center">
{!reduce &&
BURST.map((b, i) => (
<motion.span
key={i}
aria-hidden
className={cn("absolute size-2 rounded-full", b.c)}
initial={{ x: 0, y: 0, opacity: 0, scale: 0.4 }}
animate={{ x: b.x, y: b.y, opacity: [0, 1, 0], scale: [0.4, 1, 0.6] }}
transition={{ duration: 0.9, delay: 0.25, ease: "easeOut" }}
/>
))}
<motion.span
aria-hidden
className="absolute inset-0 rounded-full bg-gradient-to-br from-violet-500 to-fuchsia-500 shadow-xl shadow-fuchsia-500/30"
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: "spring", stiffness: 260, damping: 16, delay: 0.05 }}
/>
<svg viewBox="0 0 48 48" aria-hidden className="relative size-12 text-white">
<motion.path
d="M13 25l7 7 15-16"
fill="none"
stroke="currentColor"
strokeWidth={4}
strokeLinecap="round"
strokeLinejoin="round"
initial={{ pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: 0.45, delay: 0.3, ease: "easeOut" }}
/>
</svg>
</div>
<motion.h3 initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.45 }} className="mt-8 text-2xl font-semibold tracking-tight text-foreground">
{successTitle}
</motion.h3>
<motion.p initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.55 }} className="mt-2 max-w-sm text-pretty text-muted-foreground">
{successMessage}
</motion.p>
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ delay: 0.7 }} className="mt-6 rounded-2xl border bg-muted/50 px-4 py-3 text-left text-sm">
<p className="text-muted-foreground">
Reply goes to <span className="font-medium text-foreground">{values.email}</span>
</p>
<p className="text-muted-foreground">
Topic: <span className="font-medium text-foreground">{values.topic}</span>
</p>
</motion.div>
<motion.button
type="button"
onClick={reset}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.8 }}
className="mt-6 rounded-full border px-5 py-2 text-sm font-medium text-foreground transition hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
Send another message
</motion.button>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
</section>
);
}