"use client"; import { useEffect, useRef, useState } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { Check, X, Loader2 } from "lucide-react"; import { STARTING_BALANCE, USERNAME_RE, formatSC } from "@spinza/shared"; import { api, ApiClientError } from "@/lib/api"; import { useSession } from "@/lib/store"; import { Button, Input } from "@/components/ui"; import { RecoveryCodePanel } from "@/components/shell/recovery-code"; import { cn } from "@/lib/utils"; type Availability = { state: "idle" | "checking" | "ok" | "bad"; message?: string }; const REASONS: Record = { length: "Use 3 to 24 characters.", charset: "Lowercase letters, numbers, _ and - only.", reserved: "This username is reserved.", profanity: "This username is not allowed.", taken: "That username is already taken.", }; interface RegisterResponse { user: { id: string; username: string; isNew: boolean }; balance: number; recoveryCode: string; notice: string; } export default function RegisterPage() { const router = useRouter(); const refresh = useSession((s) => s.refresh); const status = useSession((s) => s.status); const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); const [confirm, setConfirm] = useState(""); const [age, setAge] = useState(false); /** Result of the last server-side availability check, tagged with the username it was for. */ const [checked, setChecked] = useState<{ for: string; available: boolean; reason?: string; failed?: boolean } | null>(null); const [submitting, setSubmitting] = useState(false); const [formError, setFormError] = useState(null); const [result, setResult] = useState(null); const [continuing, setContinuing] = useState(false); const seq = useRef(0); // Already signed in → lobby (unless we are mid-flow showing the recovery code). useEffect(() => { if (status === "authenticated" && !result) router.replace("/"); }, [status, result, router]); // Availability is derived: local rules synchronously, server check debounced. const normalized = username.trim().toLowerCase(); const localError = !normalized ? null : normalized.length < 3 || normalized.length > 24 ? REASONS.length : !USERNAME_RE.test(normalized) ? REASONS.charset : null; const serverResult = checked && checked.for === normalized ? checked : null; const avail: Availability = !normalized ? { state: "idle" } : localError ? { state: "bad", message: localError } : !serverResult || serverResult.failed ? { state: serverResult?.failed ? "idle" : "checking" } : serverResult.available ? { state: "ok", message: "Available" } : { state: "bad", message: REASONS[serverResult.reason ?? ""] ?? "Not available." }; useEffect(() => { if (!normalized || localError) return; const my = ++seq.current; const t = setTimeout(async () => { try { const res = await api<{ available: boolean; reason?: string }>("/api/auth/check-username", { json: { username: normalized } }); if (my !== seq.current) return; setChecked({ for: normalized, available: res.available, reason: res.reason }); } catch { if (my !== seq.current) return; setChecked({ for: normalized, available: false, failed: true }); } }, 350); return () => clearTimeout(t); }, [normalized, localError]); const pwError = password && password.length < 8 ? "At least 8 characters." : null; const confirmError = confirm && confirm !== password ? "Passwords do not match." : null; const canSubmit = avail.state === "ok" && password.length >= 8 && confirm === password && age && !submitting; const submit = async (e: React.FormEvent) => { e.preventDefault(); if (!canSubmit) return; setSubmitting(true); setFormError(null); try { const res = await api("/api/auth/register", { json: { username: username.trim().toLowerCase(), password, confirmPassword: confirm, ageConfirmed: true } }); setResult(res); } catch (err) { if (err instanceof ApiClientError) { if (err.code === "USERNAME_TAKEN") setChecked({ for: normalized, available: false, reason: "taken" }); setFormError(err.message); } else setFormError("Something went wrong. Please try again."); } finally { setSubmitting(false); } }; const finish = async () => { setContinuing(true); await refresh(); router.push("/?welcome=1"); router.refresh(); }; if (result) { return (
Account created — @{result.user.username} starts with {formatSC(result.balance)}.
); } return (
Create account

Pick a username. That's it.

No email, no phone number, no card. You receive {formatSC(STARTING_BALANCE)} — fictional credits with no cash value.

setUsername(e.target.value.toLowerCase())} autoComplete="username" autoCapitalize="none" autoCorrect="off" spellCheck={false} maxLength={24} placeholder="e.g. neon_spinner" error={avail.state === "bad" ? avail.message : null} hint={avail.state === "ok" ? undefined : "3–24 lowercase letters, numbers, _ or -"} aria-describedby="username-status" /> {avail.state === "checking" ? : avail.state === "ok" ? ( <> Available ) : avail.state === "bad" ? ( ) : null}
setPassword(e.target.value)} autoComplete="new-password" placeholder="At least 8 characters" error={pwError} /> setConfirm(e.target.value)} autoComplete="new-password" placeholder="Repeat your password" error={confirmError} /> {formError ? (

{formError}

) : null}

By continuing you accept the{" "} Terms {" "} and{" "} Privacy Policy . Spinza Credits are fictional and cannot be purchased or withdrawn.

Already have an account?{" "} Sign in

); }