"use client";
import * as React from "react";
import { AnimatePresence, animate, motion, useReducedMotion } from "motion/react";
import { AlertCircle, ArrowRight, Loader2, Mail } from "lucide-react";
import { cn } from "@/lib/utils";
export interface CtaNewsletterProps {
eyebrow?: string;
headline?: string;
description?: string;
placeholder?: string;
buttonLabel?: string;
successTitle?: string;
successMessage?: string;
/** Initials shown in the social-proof avatar stack. */
avatars?: string[];
proof?: string;
/** Receives the email. Throw / reject to show an error message. */
onSubscribe?: (email: string) => void | Promise<void>;
className?: string;
}
const EASE = [0.22, 1, 0.36, 1] as const;
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
const BLOBS = [
{ c: "bg-violet-400/40 dark:bg-violet-600/30", s: "size-72 sm:size-96", pos: "left-[-6%] top-[-10%]", x: [0, 60, -20, 0], y: [0, 40, 80, 0], d: 16 },
{ c: "bg-sky-300/40 dark:bg-sky-500/25", s: "size-64 sm:size-80", pos: "right-[-4%] top-[10%]", x: [0, -70, 20, 0], y: [0, 60, -20, 0], d: 19 },
{ c: "bg-pink-300/40 dark:bg-pink-500/25", s: "size-60 sm:size-72", pos: "left-[30%] bottom-[-20%]", x: [0, 50, -60, 0], y: [0, -50, -10, 0], d: 22 },
{ c: "bg-amber-200/50 dark:bg-amber-500/15", s: "size-48 sm:size-64", pos: "right-[25%] bottom-[-10%]", x: [0, -40, 30, 0], y: [0, -30, 40, 0], d: 18 },
] as const;
const AVATAR_COLORS = ["bg-violet-500", "bg-sky-500", "bg-pink-500", "bg-amber-500", "bg-emerald-500"];
export function CtaNewsletter({
eyebrow = "The Lumen Letter",
headline = "Ideas worth shipping, every other Tuesday",
description = "One short email with product teardowns, growth experiments and the tools we're using. No fluff, ever.",
placeholder = "Enter your email",
buttonLabel = "Subscribe",
successTitle = "You're subscribed!",
successMessage = "Check your inbox to confirm — your first issue lands next Tuesday.",
avatars = ["JL", "MK", "AS", "RD", "TN"],
proof = "Join 12,400+ founders and designers",
onSubscribe,
className,
}: CtaNewsletterProps) {
const reduce = useReducedMotion();
const id = React.useId();
const inputRef = React.useRef<HTMLInputElement>(null);
const [email, setEmail] = React.useState("");
const [status, setStatus] = React.useState<"idle" | "loading" | "success">("idle");
const [error, setError] = React.useState<string | null>(null);
const rowRef = React.useRef<HTMLDivElement>(null);
const fail = (msg: string) => {
setError(msg);
if (rowRef.current && !reduce) animate(rowRef.current, { x: [0, -8, 8, -5, 5, 0] }, { duration: 0.4 });
inputRef.current?.focus();
};
const submit = async (e: React.FormEvent) => {
e.preventDefault();
const value = email.trim();
if (!value) return fail("Please enter your email address.");
if (!EMAIL.test(value)) return fail("That doesn't look like a valid email.");
setError(null);
setStatus("loading");
try {
await (onSubscribe ? onSubscribe(value) : new Promise((r) => setTimeout(r, 1000)));
setStatus("success");
} catch (err) {
setStatus("idle");
fail(err instanceof Error ? err.message : "Something went wrong. Please try again.");
}
};
return (
<section className={cn("relative isolate w-full overflow-hidden bg-background px-5 py-20 text-foreground sm:px-8 sm:py-28", className)}>
<div aria-hidden className="pointer-events-none absolute inset-0 -z-10 [mask-image:linear-gradient(to_bottom,transparent,#000_18%,#000_82%,transparent)]">
{BLOBS.map((b, i) => (
<motion.div
key={i}
className={cn("absolute rounded-full blur-3xl", b.c, b.s, b.pos)}
animate={reduce ? undefined : { x: [...b.x], y: [...b.y] }}
transition={{ duration: b.d, repeat: Infinity, ease: "easeInOut" }}
/>
))}
</div>
<div className="mx-auto max-w-2xl rounded-3xl border border-foreground/10 bg-background/60 p-7 text-center shadow-xl shadow-black/5 backdrop-blur-2xl sm:p-12 dark:bg-background/50 dark:shadow-black/30">
<AnimatePresence mode="wait" initial={false}>
{status === "success" ? (
<motion.div
key="success"
initial={{ opacity: 0, scale: 0.96 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.35, ease: EASE }}
role="status"
className="flex flex-col items-center py-4"
>
<SuccessCheck reduce={!!reduce} />
<h2 className="mt-6 text-2xl font-semibold tracking-tight sm:text-3xl">{successTitle}</h2>
<p className="mt-3 max-w-sm text-muted-foreground">{successMessage}</p>
<p className="mt-2 text-sm font-medium">{email}</p>
<button
type="button"
onClick={() => (setStatus("idle"), setEmail(""))}
className="mt-6 rounded-full px-3 py-1.5 text-sm font-medium text-muted-foreground underline-offset-4 transition hover:text-foreground hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
Use a different email
</button>
</motion.div>
) : (
<motion.div key="form" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0, scale: 0.98 }} transition={{ duration: 0.25 }}>
<span className="mx-auto grid size-12 place-items-center rounded-2xl border bg-card shadow-sm">
<Mail className="size-5 text-primary" aria-hidden />
</span>
<p className="mt-5 text-sm font-medium text-primary">{eyebrow}</p>
<h2 className="mt-2 text-balance text-3xl font-semibold tracking-tight sm:text-4xl">{headline}</h2>
<p className="mx-auto mt-4 max-w-md text-pretty text-muted-foreground">{description}</p>
<form onSubmit={submit} noValidate className="mx-auto mt-8 max-w-md text-left">
<label htmlFor={id} className="sr-only">
Email address
</label>
<div ref={rowRef} className="flex flex-col gap-2 sm:flex-row">
<input
ref={inputRef}
id={id}
type="email"
inputMode="email"
autoComplete="email"
value={email}
onChange={(e) => (setEmail(e.target.value), error && setError(null))}
placeholder={placeholder}
aria-invalid={!!error}
aria-describedby={`${id}-err`}
disabled={status === "loading"}
className={cn(
"h-12 w-full min-w-0 rounded-xl sm:w-auto sm:flex-1 border bg-card px-4 text-sm shadow-sm outline-none transition placeholder:text-muted-foreground focus:border-primary/60 focus:ring-4 focus:ring-primary/15",
error && "border-destructive/70 focus:border-destructive focus:ring-destructive/15",
)}
/>
<button
type="submit"
disabled={status === "loading"}
className="group inline-flex h-12 items-center justify-center gap-2 rounded-xl bg-foreground px-6 text-sm font-semibold text-background shadow-sm transition hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:opacity-70"
>
{status === "loading" ? (
<Loader2 className="size-4 animate-spin" aria-hidden />
) : (
<>
{buttonLabel}
<ArrowRight className="size-4 transition-transform group-hover:translate-x-0.5" aria-hidden />
</>
)}
{status === "loading" && <span className="sr-only">Subscribing…</span>}
</button>
</div>
<div id={`${id}-err`} aria-live="polite" className="min-h-6 pt-2">
<AnimatePresence>
{error && (
<motion.p
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
className="flex items-center gap-1.5 text-sm text-destructive"
>
<AlertCircle className="size-4 shrink-0" aria-hidden />
{error}
</motion.p>
)}
</AnimatePresence>
</div>
</form>
{avatars.length > 0 && (
<div className="mt-4 flex flex-col items-center justify-center gap-3 sm:flex-row">
<div className="flex -space-x-2" aria-hidden>
{avatars.slice(0, 5).map((a, i) => (
<span key={a + i} className={cn("grid size-8 place-items-center rounded-full text-[11px] font-semibold text-white ring-2 ring-background", AVATAR_COLORS[i % AVATAR_COLORS.length])}>
{a}
</span>
))}
</div>
<p className="text-sm text-muted-foreground">{proof}</p>
</div>
)}
</motion.div>
)}
</AnimatePresence>
</div>
</section>
);
}
function SuccessCheck({ reduce }: { reduce: boolean }) {
const t = (delay: number, duration: number) => (reduce ? { duration: 0 } : { delay, duration, ease: EASE });
return (
<div className="relative grid size-20 place-items-center">
<motion.span
aria-hidden
className="absolute inset-0 rounded-full bg-emerald-500/15"
initial={{ scale: 0.4, opacity: 0 }}
animate={{ scale: [0.4, 1.25, 1], opacity: 1 }}
transition={t(0.05, 0.6)}
/>
<svg viewBox="0 0 52 52" className="relative size-16 text-emerald-600 dark:text-emerald-400" aria-hidden>
<motion.circle
cx="26"
cy="26"
r="23"
fill="none"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
initial={{ pathLength: 0, rotate: -90 }}
animate={{ pathLength: 1, rotate: -90 }}
transition={t(0.1, 0.6)}
style={{ originX: "50%", originY: "50%" }}
/>
<motion.path
d="M15 27 l7.5 7.5 L37.5 19"
fill="none"
stroke="currentColor"
strokeWidth="3.5"
strokeLinecap="round"
strokeLinejoin="round"
initial={{ pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={t(0.6, 0.45)}
/>
</svg>
</div>
);
}