Fazekit

Code

"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&apos;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>
  );
}