import { createContext, useContext, useEffect, useRef, useState, type ReactNode } from "react"; import { createPortal } from "react-dom"; import { Link, useNavigate } from "react-router-dom"; import { ChevIcon } from "./icons"; import { COUNTRY_OPTIONS } from "../data/countries"; import { STATE_OPTIONS } from "../data/states"; import { slugForAbbr } from "../lib/stateSlug"; import { saveZip, submitSignup } from "../lib/signup"; // The signature flow, app-wide. SignupProvider owns the state machine and the // modal (mounted once at App level), so any button on any page — hero CTA, // endbeat CTA, the nav's Take Action — opens the same modal in place via // useSignup().launch(). Steps: email, then place (a US/world toggle swaps the // picker between state and country), then an optional zip for US signers, // then the bridge to the call. After a signup the fresh total is broadcast as // a "rti:total" window event so live counters update instantly. type Step = "email" | "place" | "zip" | "done"; type SignupCtx = { launch: () => void; step: Step; stateKey: string }; const Ctx = createContext(null); export function useSignup(): SignupCtx { const ctx = useContext(Ctx); if (!ctx) throw new Error("useSignup must be used inside SignupProvider"); return ctx; } const broadcastTotal = (total: number) => window.dispatchEvent(new CustomEvent("rti:total", { detail: total })); export function SignupProvider({ children }: { children: ReactNode }) { const [open, setOpen] = useState(false); const [step, setStep] = useState("email"); const [email, setEmail] = useState(""); const [stateKey, setStateKey] = useState(""); const [countryKey, setCountryKey] = useState(""); const [intl, setIntl] = useState(false); const [zip, setZip] = useState(""); const [website, setWebsite] = useState(""); // honeypot const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const emailRef = useRef(null); const overlayRef = useRef(null); const stepRef = useRef(step); stepRef.current = step; useEffect(() => { if (open && step === "email") emailRef.current?.focus(); }, [open, step]); // lock page scroll + close on Esc while the modal is up. iOS Safari ignores // overflow:hidden on body, so freeze the body in place with position:fixed // and restore the scroll position on close. useEffect(() => { if (!open) return; const y = window.scrollY; const page = window.location.pathname; const b = document.body.style; const prev = { position: b.position, top: b.top, left: b.left, right: b.right, overflow: b.overflow }; b.position = "fixed"; b.top = `-${y}px`; b.left = "0"; b.right = "0"; b.overflow = "hidden"; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape" && stepRef.current !== "done") setOpen(false); }; window.addEventListener("keydown", onKey); return () => { b.position = prev.position; b.top = prev.top; b.left = prev.left; b.right = prev.right; b.overflow = prev.overflow; // If the modal closed because a link navigated, the new page owns the // scroll position — restoring the old page's spot would drag it there. if (window.location.pathname === page) window.scrollTo(0, y); window.removeEventListener("keydown", onKey); }; }, [open]); // iOS: the keyboard shrinks the *visual* viewport but position:fixed tracks // the layout viewport. The dim must stay full-screen, so never resize the // overlay — instead pad it to match the visible area so the centered card // rides above the keyboard while everything behind stays dimmed. useEffect(() => { if (!open) return; const vv = window.visualViewport; if (!vv) return; const sync = () => { const el = overlayRef.current; if (!el) return; const below = Math.max(0, window.innerHeight - vv.height - vv.offsetTop); el.style.paddingTop = `${Math.max(14, vv.offsetTop + 14)}px`; el.style.paddingBottom = `${below + 14}px`; }; sync(); vv.addEventListener("resize", sync); vv.addEventListener("scroll", sync); return () => { vv.removeEventListener("resize", sync); vv.removeEventListener("scroll", sync); }; }, [open]); const launch = () => { setError(null); if (step !== "done") setStep("email"); setOpen(true); }; const nextFromEmail = (e: React.FormEvent) => { e.preventDefault(); setError(null); if (!/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(email.trim())) { setError("That email doesn't look right — double check it?"); return; } setStep("place"); }; const finish = async (e: React.FormEvent) => { e.preventDefault(); if (busy) return; setError(null); if (zip.trim() !== "" && !/^\d{5}(-\d{4})?$/.test(zip.trim())) { setError("Zip looks off — 5 digits, or just leave it blank."); return; } setBusy(true); try { const result = await submitSignup({ email: email.trim(), stateKey, zip: zip.trim() || undefined, website, }); if (zip.trim()) saveZip(zip.trim()); broadcastTotal(result.total); setStep("done"); } catch (err) { setError( err instanceof Error && err.message === "verification_failed" ? "Couldn't verify you're human — give it another try." : "Something broke on our end. Give it a minute and try again.", ); } finally { setBusy(false); } }; // International path: country picked, signature lands immediately — no zip // (zips are a US concept) and no call step (the scripts target US offices). const finishIntl = async (e: React.FormEvent) => { e.preventDefault(); if (busy) return; setError(null); if (!countryKey) { setError("Pick your country — your signature counts globally."); return; } setBusy(true); try { const result = await submitSignup({ email: email.trim(), country: countryKey, website, }); broadcastTotal(result.total); setStateKey(""); setStep("done"); } catch (err) { setError( err instanceof Error && err.message === "verification_failed" ? "Couldn't verify you're human — give it another try." : "Something broke on our end. Give it a minute and try again.", ); } finally { setBusy(false); } }; const modal = open && createPortal(
{ if (e.target === e.currentTarget && step !== "done") setOpen(false); }} >
{step !== "done" && ( )} {step === "email" && (

01 / your email

Add your name to the count.

setEmail(e.target.value)} required />
{error &&

{error}

}

No spam, ever. Just a heads-up when your state needs you. Never sold, never shared —{" "} how we handle your info →

)} {step === "place" && (
{ if (intl) { void finishIntl(e); return; } e.preventDefault(); setError(null); if (!stateKey) { setError("Pick your state — it's where your signature counts."); return; } setStep("zip"); }} >

02 / where are you?

Where does your signature count?

{!intl ? (
) : (
)}
{/* honeypot — visually hidden, tab-skipped; bots fill it, humans never see it */} setWebsite(e.target.value)} name="website" /> {error &&

{error}

}

{intl ? "This fight is global — your signature joins the worldwide count." : "Your state is where your signature counts."}

)} {step === "zip" && (

03 / your zip — optional

Want us to find your exact offices?

setZip(e.target.value)} />
{/* honeypot — visually hidden, tab-skipped; bots fill it, humans never see it */} setWebsite(e.target.value)} name="website" /> {error &&

{error}

}

Zip lets us point you at your exact state offices if you choose to call. Stays on your device + your signature — never shared.

)} {step === "done" && stateKey && (

You're in. ✓

Your signature just joined the count. Now the move that actually flips votes:{" "} a two-minute call to your state office. We already wrote the script — you just read it.

setOpen(false)} > Make the call →

)} {step === "done" && !stateKey && (

You're in. ✓

Your signature just joined the worldwide count. The strongest thing you can do next: send this to someone in the US — their call to a state office is where the votes flip.

setOpen(false)}> Watch the count grow →

)}
, document.body, ); return ( {children} {modal} ); } // The signature CTA block — drop it anywhere inside SignupProvider. Same // button, same note, same shared state everywhere it appears. "Just want to // call?" reveals a state picker in place and routes straight to that state's // action page — no signature required to call. export default function SignupForm() { const { launch, step, stateKey } = useSignup(); const navigate = useNavigate(); const [callPick, setCallPick] = useState(false); return (

Ten seconds. Email + your state, nothing else.{" "} {callPick ? ( ) : ( )}

); }