"use client"; import { Mail, Send } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { type FormEvent, type ReactNode, useEffect, useId, useRef, useState, } from "react"; import { AvatarGroup, type AvatarGroupItem } from "@/components/motion/avatar-group"; import { type ButtonState, StatefulButton } from "@/components/motion/button"; import { Input } from "@/components/motion/input"; import { EASE_OUT, SPRING_LAYOUT, SPRING_SWAP } from "@/lib/ease"; import { cn } from "@/lib/utils"; export interface NewsletterSocialProof { /** Avatars shown in an overlapping stack. */ avatars: AvatarGroupItem[]; /** Copy beside the stack, e.g. "Join 4,200 readers". */ label: ReactNode; /** Avatars before a "+N" chip. Default 4. */ max?: number; } export interface NewsletterSectionProps { /** "card" is a centered bordered panel; "inline" puts copy and form side by side. */ variant?: "card" | "inline"; eyebrow?: ReactNode; heading?: ReactNode; description?: ReactNode; placeholder?: string; buttonLabel?: string; /** Resolve to confirm; reject (or throw) to show the error state. */ onSubscribe?: (email: string) => Promise | void; /** Small print under the form. Pass null to hide. */ privacyNote?: ReactNode; socialProof?: NewsletterSocialProof; successTitle?: ReactNode; /** Receives the subscribed address. */ successMessage?: ReactNode | ((email: string) => ReactNode); invalidEmailMessage?: string; errorMessage?: string; className?: string; } const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/; /** Light check used before `onSubscribe`; the server stays the source of truth. */ export function isValidNewsletterEmail(value: string) { return EMAIL_PATTERN.test(value.trim()); } const SWAP_ENTER = { duration: 0.32, ease: EASE_OUT } as const; const SWAP_EXIT = { duration: 0.16, ease: EASE_OUT } as const; const ERROR_RESET_MS = 2400; function SuccessCheck({ reduce }: { reduce: boolean }) { return ( ); } /** * Newsletter signup section: heading, an email field with idle/loading/ * success/error states driven by the `onSubscribe` promise (with a light * client-side email check), a privacy note and optional social-proof avatars. * On success the form morphs into a confirmation with a draw-in check. Two * layouts: a centered "card" and a side-by-side "inline" band that stacks in * narrow containers. */ export function NewsletterSection({ variant = "card", eyebrow, heading = "Get the weekly digest", description = "One short email every Friday with new components, motion notes and the occasional deep dive.", placeholder = "you@example.com", buttonLabel = "Subscribe", onSubscribe, privacyNote = "No spam. Unsubscribe with one click.", socialProof, successTitle = "You're on the list", successMessage = (email: string) => ( <> We sent a confirmation to {email}. ), invalidEmailMessage = "Enter a valid email address.", errorMessage = "Something went wrong. Please try again.", className, }: NewsletterSectionProps) { const reduce = useReducedMotion() ?? false; const id = useId(); const [email, setEmail] = useState(""); const [state, setState] = useState("idle"); const [error, setError] = useState(null); const [subscribed, setSubscribed] = useState(""); const timer = useRef(undefined); const successRef = useRef(null); useEffect( () => () => { if (timer.current) window.clearTimeout(timer.current); }, [], ); const done = state === "success"; // Move focus to the confirmation so keyboard and screen reader users land // on the result instead of a removed form. useEffect(() => { if (done) successRef.current?.focus({ preventScroll: true }); }, [done]); const submit = async (event: FormEvent) => { event.preventDefault(); if (state === "loading" || done) return; if (!isValidNewsletterEmail(email)) { setError(invalidEmailMessage); return; } const value = email.trim(); setError(null); setState("loading"); try { await onSubscribe?.(value); setSubscribed(value); setState("success"); } catch { setState("error"); setError(errorMessage); if (timer.current) window.clearTimeout(timer.current); timer.current = window.setTimeout(() => setState("idle"), ERROR_RESET_MS); } }; const inline = variant === "inline"; const swapInitial = reduce ? { opacity: 0 } : { opacity: 0, y: 10, filter: "blur(4px)" }; const swapExit = reduce ? { opacity: 0, transition: SWAP_EXIT } : { opacity: 0, y: -8, filter: "blur(4px)", transition: SWAP_EXIT }; const form = (
{ setEmail(next); if (error && isValidNewsletterEmail(next)) setError(null); }} error={error ?? undefined} leftIcon={} className="min-w-0 flex-1" classNames={{ field: "bg-background", errorMessage: "text-left" }} /> } className="h-11 shrink-0" > {buttonLabel}
{privacyNote ? (

{privacyNote}

) : null}
); const confirmation = (

{successTitle}

{typeof successMessage === "function" ? successMessage(subscribed) : successMessage}

); const copy = (
{eyebrow ? (

{eyebrow}

) : null} {heading ? (

{heading}

) : null} {description ? (

{description}

) : null}
); const proof = socialProof ? (

{socialProof.label}

) : null; // The form/confirmation swap changes height, so the slot morphs its layout // on purpose (the section must grow or shrink with the content). const slot = ( {done ? confirmation : form} ); return (
{inline ? (
{copy} {proof}
{slot}
) : (
{/* Decorative dot grid fading out from the top. */}
{copy}
{slot}
{proof}
)}
); }