import { useState } from "react"; import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { redirectSignedIn } from "~/lib/route-guards"; import { returnSearch, safeReturnPath } from "~/lib/return-to"; import { getAuthOptions, signUp } from "~/fns/auth"; import { getSignupGate } from "~/fns/waitlist"; import { WaitlistForm } from "~/components/WaitlistForm"; import { useStripSearchParam } from "~/hooks/use-strip-search-param"; import { useAction } from "~/hooks/use-action"; import { useTurnstile } from "~/hooks/use-turnstile"; import { useBootstrapToken } from "~/hooks/use-bootstrap-token"; import { AuthCard } from "~/components/AuthCard"; import { SignupConsent } from "~/components/SignupConsent"; import { Button, buttonClass } from "~/ui/Button"; import { TextField } from "~/ui/Field"; import { Callout, ErrorBanner } from "~/ui/Callout"; import { TextLink } from "~/ui/TextLink"; export const Route = createFileRoute("/signup")({ // `bootstrap` is read straight from the address bar by useBootstrapToken, so it must survive search validation. validateSearch: (search: Record) => ({ ...returnSearch(search), ...(typeof search.bootstrap === "string" ? { bootstrap: search.bootstrap } : {}), ...(typeof search.invite === "string" ? { invite: search.invite } : {}), }), beforeLoad: redirectSignedIn, loaderDeps: ({ search }) => ({ invite: search.invite }), loader: async ({ deps }) => { const [options, gate] = await Promise.all([getAuthOptions(), getSignupGate({ data: { invite: deps.invite } })]); return { ...options, gate, inviteToken: gate.invitedEmail ? (deps.invite ?? null) : null }; }, component: SignupPage, }); function SignupPage() { const options = Route.useLoaderData(); // Validated again here: router search state can still carry the raw value during SSR. const next = safeReturnPath(Route.useSearch().next) ?? undefined; const navigate = useNavigate(); const action = useAction(signUp); const turnstile = useTurnstile(options.turnstileSiteKey, "signup"); const bootstrapToken = useBootstrapToken(); // Kept in loader data; the address bar copy goes so the token doesn't linger in history or analytics. useStripSearchParam("invite"); // Captured once: stripping the parameter re-runs the loader without it, which must not drop the invite. const [invite] = useState(() => ({ token: options.inviteToken, email: options.gate.invitedEmail })); const { gate } = options; const inviteToken = invite.token; if (gate.full && !inviteToken) { return ( {gate.inviteExpired && ( That invite has expired and the spot went to the next person. Join again to get back in line. )} ); } async function submit(event: React.SubmitEvent) { event.preventDefault(); const form = new FormData(event.currentTarget); const result = await action.run({ data: { username: String(form.get("username") ?? ""), email: String(form.get("email") ?? ""), password: String(form.get("password") ?? ""), turnstileToken: turnstile.token, bootstrapToken: bootstrapToken.current, inviteToken: inviteToken ?? undefined, acceptTerms: true, }, }); if (result) await navigate({ href: next ?? "/" }); else turnstile.reset(); } return (
{invite.email && You're off the waitlist. Welcome in.} {turnstile.widget} {turnstile.failed && (

The verification check failed to load. Reload the page to try again.

)} {options.githubEnabled && ( Sign up with GitHub )}

Already have an account? Sign in

); }