"use client"; import { Check, Send } from "lucide-react"; import { motion, type Transition, useReducedMotion } from "motion/react"; import { type ElementType, type FormEvent, type ReactNode, useEffect, useId, useRef, useState, } from "react"; import { Button, ButtonLink, type ButtonState, StatefulButton, } from "@/components/motion/button"; import { TextReveal } from "@/components/motion/text-reveal"; import { EASE_OUT } from "@/lib/ease"; import { cn } from "@/lib/utils"; export interface CtaAction { label: ReactNode; href?: string; onClick?: () => void; /** Trailing icon. */ icon?: ReactNode; } export interface CtaEmailCapture { /** Resolve to show success; reject to show the retry state. */ onSubscribe: (email: string) => Promise | void; placeholder?: string; buttonLabel?: ReactNode; successMessage?: ReactNode; /** Small print under the field, e.g. "Unsubscribe any time." */ note?: ReactNode; } export interface CtaSectionProps { /** "centered" stacks everything in one card; "split" puts copy left and a visual or email capture right. */ variant?: "centered" | "split"; eyebrow?: ReactNode; /** Revealed word by word when the section scrolls into view. */ headline: string | string[]; description?: ReactNode; primaryAction?: CtaAction; secondaryAction?: CtaAction; /** Short reassurance list ("No credit card", "Cancel any time"). */ checklist?: ReactNode[]; /** Email field with a stateful submit button. */ emailCapture?: CtaEmailCapture; /** Right column for `variant="split"` (mock UI, illustration). When omitted, the email capture takes that slot. */ visual?: ReactNode; /** Card decoration: rotating gradient beam on the border, a dotted field, or nothing. Default "beam". */ background?: "beam" | "dots" | "none"; /** Heading element for the headline. Default "h2". */ headingAs?: ElementType; className?: string; cardClassName?: string; } const WORD_STAGGER = 0.05; const ENTER: Transition = { duration: 0.5, ease: EASE_OUT }; // Beam sweep: decorative and slow so the border never pulls focus from the // copy; linear so the light travels at a constant speed around the card. const BEAM_TRANSITION: Transition = { duration: 8, ease: "linear", repeat: Number.POSITIVE_INFINITY, }; function isEmail(value: string) { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim()); } function wordCount(text: string | string[]) { return (Array.isArray(text) ? text.join(" ") : text) .split(/\s+/) .filter(Boolean).length; } function ActionButton({ action, variant, }: { action: CtaAction; variant: "primary" | "secondary"; }) { const content = ( <> {action.label} {action.icon ?? null} ); return action.href ? ( {content} ) : ( ); } function EmailCapture({ capture, align, }: { capture: CtaEmailCapture; align: "center" | "left"; }) { const id = useId(); const [email, setEmail] = useState(""); const [state, setState] = useState("idle"); const [error, setError] = useState(null); const timer = useRef(undefined); useEffect( () => () => { if (timer.current) window.clearTimeout(timer.current); }, [], ); const submit = async (event: FormEvent) => { event.preventDefault(); if (state === "loading" || state === "success") return; if (!isEmail(email)) { setError("Enter a valid email address."); return; } setError(null); setState("loading"); try { await capture.onSubscribe(email.trim()); setState("success"); } catch { setState("error"); setError("Something went wrong. Try again."); timer.current = window.setTimeout(() => setState("idle"), 2400); } }; const done = state === "success"; return (
{ setEmail(event.target.value); if (error && isEmail(event.target.value)) setError(null); }} placeholder={capture.placeholder ?? "you@company.com"} className={cn( "h-10 min-w-0 flex-1 rounded-full border bg-background px-4 text-sm text-foreground outline-none transition-colors placeholder:text-muted-foreground focus-visible:ring-2 disabled:opacity-60", error ? "border-destructive focus-visible:ring-destructive/30" : "border-border focus-visible:border-border-strong focus-visible:ring-ring/40", )} /> } className="shrink-0" > {capture.buttonLabel ?? "Get started"}

{error ?? (done ? (capture.successMessage ?? "Thanks! Check your inbox.") : (capture.note ?? ""))}

); } function CardBackground({ background, reduce, }: { background: "beam" | "dots" | "none"; reduce: boolean | null; }) { if (background === "none") return null; if (background === "dots") { return (
); } // Beam: an oversized conic gradient spins behind the card; the card body // sits 1px inside it, so only a moving sliver shows along the border. return ( ); } /** * Closing call-to-action band. `centered` puts a large headline, supporting * copy, two CTAs and an optional reassurance checklist inside one card; * `split` sets the copy beside a visual or an email capture. The headline * reveals word by word on view and the rest follows. The card can carry a * rotating gradient beam along its border or a soft dotted field. Layout * reads the section's own container width. */ export function CtaSection({ variant = "centered", eyebrow, headline, description, primaryAction, secondaryAction, checklist, emailCapture, visual, background = "beam", headingAs = "h2", className, cardClassName, }: CtaSectionProps) { const reduce = useReducedMotion(); const centered = variant === "centered"; const align = centered ? "center" : "left"; const after = Math.min(1, 0.15 + wordCount(headline) * WORD_STAGGER); const enter = (delay: number) => ({ initial: reduce ? { opacity: 0 } : { opacity: 0, y: 12 }, whileInView: reduce ? { opacity: 1 } : { opacity: 1, y: 0 }, viewport: { once: true, amount: 0.4 }, transition: { ...ENTER, delay }, }); // In split mode the email capture fills the right column unless a visual // claims it; then it moves under the copy. const captureInAside = !centered && !visual && Boolean(emailCapture); const copy = (
{eyebrow ? ( {eyebrow} ) : null} {description ? ( {description} ) : null} {primaryAction || secondaryAction ? ( {primaryAction ? ( ) : null} {secondaryAction ? ( ) : null} ) : null} {emailCapture && !captureInAside ? ( ) : null} {checklist?.length ? ( {checklist.map((item, index) => ( {item} ))} ) : null}
); return (
{/* Soft top glow so the card reads as lit from the beam side. */}
{copy} {!centered && (visual || captureInAside) ? ( {visual ?? (emailCapture ? (
) : null)}
) : null}
); }