"use client"; import { ArrowRightIcon, CheckIcon, CopyIcon, LinkIcon, PartyPopperIcon, PlusIcon } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { type FormEvent, useEffect, useId, useLayoutEffect, useRef, useState } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { cn } from "@/lib/utils"; export interface WaitlistReferral { id: string; name: string; tone?: string; } export interface WaitlistTicket { position: number; total: number; link: string; } export interface WaitlistWave { label: string; upTo: number; } export interface WaitlistProps { product?: string; eyebrow?: string; title?: string; subtitle?: string; waiting?: number; spotsPerReferral?: number; goal?: number; waves?: WaitlistWave[]; referrals?: WaitlistReferral[]; defaultEmail?: string; onJoin?: (email: string) => Promise; className?: string; } const ease = [0.23, 1, 0.32, 1] as const; const press = "transition-[box-shadow,scale,background-color] duration-150 active:scale-[0.97]"; const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/; const FALLBACK_TONES = [ "bg-sky-500/15 text-sky-700 dark:text-sky-300", "bg-rose-500/15 text-rose-700 dark:text-rose-300", "bg-amber-500/15 text-amber-800 dark:text-amber-300", "bg-emerald-500/15 text-emerald-700 dark:text-emerald-300", ]; function initials(name: string) { return name .split(" ") .map((w) => w[0]) .join("") .slice(0, 2) .toUpperCase(); } function useMeasuredHeight() { const ref = useRef(null); const [height, setHeight] = useState("auto"); useLayoutEffect(() => { const el = ref.current; if (!el) return; const ro = new ResizeObserver(() => setHeight(el.offsetHeight)); ro.observe(el); return () => ro.disconnect(); }, []); return [ref, height] as const; } function Digit({ value }: { value: number }) { const reduce = useReducedMotion(); return ( {Array.from({ length: 10 }, (_, n) => ( {n} ))} ); } function Rolling({ value, className }: { value: number; className?: string }) { const text = value.toLocaleString("en-US"); const chars = text.split(""); return ( {text} {chars.map((ch, i) => { const key = chars.length - i; return /\d/.test(ch) ? ( ) : ( {ch} ); })} ); } export function Waitlist({ product = "Parcel", eyebrow = "Early access", title = "Notes that file themselves.", subtitle = "Parcel reads what you write and sorts it into the right project, so your inbox of ideas stays at zero.", waiting = 6212, spotsPerReferral = 250, goal = 3, waves = [ { label: "Wave 1", upTo: 500 }, { label: "Wave 2", upTo: 2000 }, { label: "Wave 3", upTo: Number.POSITIVE_INFINITY }, ], referrals = [], defaultEmail = "", onJoin = async () => ({ position: waiting + 1, total: waiting + 1, link: "parcel.so/r/you" }), className, }: WaitlistProps) { const reduce = useReducedMotion(); const uid = useId(); const [email, setEmail] = useState(defaultEmail); const [error, setError] = useState(); const [busy, setBusy] = useState(false); const [ticket, setTicket] = useState(null); const [copied, setCopied] = useState(false); const [shown, setShown] = useState(null); const [cardRef, cardHeight] = useMeasuredHeight(); const target = ticket ? Math.max(1, ticket.position - referrals.length * spotsPerReferral) : 0; const waveIndex = waves.findIndex((w) => target <= w.upTo); const wave = waves[waveIndex]; const nextWave = waveIndex > 0 ? waves[waveIndex - 1] : undefined; useEffect(() => { if (!ticket) return; if (shown === null) { setShown(ticket.total); const t = setTimeout(() => setShown(target), reduce ? 0 : 120); return () => clearTimeout(t); } setShown(target); }, [ticket, target]); useEffect(() => { if (!copied) return; const t = setTimeout(() => setCopied(false), 1600); return () => clearTimeout(t); }, [copied]); const submit = async (event: FormEvent) => { event.preventDefault(); if (!EMAIL.test(email.trim())) { setError("Enter an email address so we can save your spot."); return; } setError(undefined); setBusy(true); const t = await onJoin(email.trim()); setBusy(false); setTicket(t); }; const copyLink = async () => { if (!ticket) return; try { await navigator.clipboard.writeText(`https://${ticket.link}`); } catch { // Blocked clipboard (e.g. sandboxed iframe): the link is still selectable in the field. } setCopied(true); }; const latest = referrals[referrals.length - 1]; const prevWave = waves.findIndex((w) => target + spotsPerReferral <= w.upTo); const promoted = latest && prevWave !== waveIndex; const progressTo = ticket && nextWave ? Math.min(1, Math.max(0.03, (ticket.total - target) / Math.max(1, ticket.total - nextWave.upTo))) : 1; return (
{eyebrow}

{title}

{subtitle}

{["Ines Vale", "Kofi Mensah", "Lena Park", "Ravi Shah"].map((n, i) => ( {initials(n)} ))}

{(ticket?.total ?? waiting).toLocaleString("en-US")} people waiting

{!ticket ? (

Save your spot in line

One email when it’s your turn. Nothing else.

{ setEmail(e.target.value); if (error) setError(undefined); }} placeholder="you@studio.com" type="email" value={email} />
{error && ( {error} )}
) : (

You’re on the list

{email.trim()}

{wave?.label}
# of {ticket.total.toLocaleString("en-US")} {latest && ( ↑ {spotsPerReferral} spots )}
{nextWave ? (
{Math.max(0, target - nextWave.upTo).toLocaleString("en-US")} spots to {nextWave.label} #{nextWave.upTo.toLocaleString("en-US")}
) : (

You’re in the first wave. Watch your inbox.

)}

Skip ahead

Every friend who joins with your link moves you up {spotsPerReferral} spots.

{ticket.link}
    {Array.from({ length: goal }, (_, i) => { const r = referrals[i]; return (
  • {r && ( {initials(r.name)} )}
  • ); })}

{Math.min(referrals.length, goal)} of {goal} friends joined

{latest ? promoted ? `${latest.name.split(" ")[0]} joined, and you moved up to ${wave?.label}` : `${latest.name.split(" ")[0]} just joined with your link` : "Share your link to fill these in"}
)}
); }