"use client"; import { useEffect, useId, useRef, useState, type FormEvent } from "react"; import { AnimatePresence, motion, useAnimationControls, useReducedMotion } from "motion/react"; type EnvelopeSignupProps = { /** Printed on the letter. */ title?: string; /** Small line on the letter. */ subtitle?: string; /** Hint inside the address line. */ placeholder?: string; /** Words on the button. */ buttonLabel?: string; /** Field name, for a form post. */ name?: string; /** Your signup call. A rejected promise sends the letter back. */ onSubmit?: (email: string) => void | Promise; /** Set the width here. Everything scales with it. */ className?: string; }; type Phase = "open" | "sealing" | "stamped" | "posting" | "posted"; const wait = (ms: number) => new Promise((done) => setTimeout(done, ms)); /* EnvelopeSignup: a newsletter signup where the address line of an open envelope is the email field. On send the letter drops in, the flap folds shut, a postmark lands with the date, and the envelope goes through the slot of a red post box. A bad address comes back stamped. */ export function EnvelopeSignup({ title = "One good letter a month", subtitle = "New pieces and the story behind them. No spam, leave any time.", placeholder = "you@example.com", buttonLabel = "Send", name = "email", onSubmit, className = "w-full max-w-xl" }: EnvelopeSignupProps) { const reduce = useReducedMotion(); const id = useId().replace(/[^a-zA-Z0-9]/g, ""); const input = useRef(null); const again = useRef(null); const shake = useAnimationControls(); const alive = useRef(true); const [phase, setPhase] = useState("open"); const [email, setEmail] = useState(""); const [sent, setSent] = useState(""); const [date, setDate] = useState(""); const [error, setError] = useState(""); const [returned, setReturned] = useState(0); const [letters, setLetters] = useState(0); useEffect(() => { alive.current = true; return () => { alive.current = false; }; }, []); useEffect(() => { if (phase === "posted") again.current?.focus(); }, [phase]); const refuse = (message: string) => { setPhase("open"); setError(message); setReturned((r) => r + 1); if (!reduce) void shake.start({ x: [0, -9, 8, -6, 4, 0], transition: { duration: 0.42 } }); input.current?.focus(); }; const send = async (e: FormEvent) => { e.preventDefault(); if (phase !== "open") return; const field = input.current; const value = email.trim(); if (!field || !value || !field.validity.valid) { refuse(value ? "That address looks wrong." : "Write your address first."); return; } const step = reduce ? 0 : 1; setError(""); setReturned(0); setSent(value); setDate(new Date().toLocaleDateString("en-GB", { day: "2-digit", month: "short", year: "2-digit" }).toUpperCase()); setPhase("sealing"); try { await Promise.all([onSubmit?.(value), wait(980 * step)]); } catch { if (alive.current) refuse("It came back. Please try again."); return; } if (!alive.current) return; setPhase("stamped"); await wait(560 * step); if (!alive.current) return; setPhase("posting"); await wait(1020 * step); if (!alive.current) return; setLetters((n) => n + 1); setPhase("posted"); }; const reset = () => { setEmail(""); setPhase("open"); requestAnimationFrame(() => input.current?.focus()); }; const closed = phase !== "open"; const posting = phase === "posting"; const stamped = phase === "stamped" || posting; return (
{/* Soft wall light. */}
{/* Post box. */}

LETTERS

NEXT
COLLECT
5 PM
{letters}
{phase !== "posted" && ( {/* Back of the envelope, and its flap while open. */}
{/* The letter. Clipped at the foot so all of it can go in. */}

{title}

{subtitle}

{[92, 74].map((w) => ( ))}
{/* Front pocket, with the address on it. */}
{/* The flap once it has folded down. */} {/* Stamp. */}
₹5
{stamped && ( POSTED {date} )} {/* Address line, the real field. */}
{/* Sent back. */} {returned > 0 && !closed && error && ( RETURN TO SENDER )}
)} {phase === "posted" && (

RECEIPT OF POSTING

Posted. Watch your inbox.

{sent}

)}
); }