TypeScript 97.6%
SQL 1.4%
JavaScript 0.5%
1"use client";23import { useEffect, useRef, useState } from "react";4import Link from "next/link";5import { useRouter } from "next/navigation";6import { Check, X, Loader2 } from "lucide-react";7import { STARTING_BALANCE, USERNAME_RE, formatSC } from "@spinza/shared";8import { api, ApiClientError } from "@/lib/api";9import { useSession } from "@/lib/store";10import { Button, Input } from "@/components/ui";11import { RecoveryCodePanel } from "@/components/shell/recovery-code";12import { cn } from "@/lib/utils";1314type Availability = { state: "idle" | "checking" | "ok" | "bad"; message?: string };1516const REASONS: Record<string, string> = {17 length: "Use 3 to 24 characters.",18 charset: "Lowercase letters, numbers, _ and - only.",19 reserved: "This username is reserved.",20 profanity: "This username is not allowed.",21 taken: "That username is already taken.",22};2324interface RegisterResponse {25 user: { id: string; username: string; isNew: boolean };26 balance: number;27 recoveryCode: string;28 notice: string;29}3031export default function RegisterPage() {32 const router = useRouter();33 const refresh = useSession((s) => s.refresh);34 const status = useSession((s) => s.status);35 const [username, setUsername] = useState("");36 const [password, setPassword] = useState("");37 const [confirm, setConfirm] = useState("");38 const [age, setAge] = useState(false);39 /** Result of the last server-side availability check, tagged with the username it was for. */40 const [checked, setChecked] = useState<{ for: string; available: boolean; reason?: string; failed?: boolean } | null>(null);41 const [submitting, setSubmitting] = useState(false);42 const [formError, setFormError] = useState<string | null>(null);43 const [result, setResult] = useState<RegisterResponse | null>(null);44 const [continuing, setContinuing] = useState(false);45 const seq = useRef(0);4647 // Already signed in → lobby (unless we are mid-flow showing the recovery code).48 useEffect(() => {49 if (status === "authenticated" && !result) router.replace("/");50 }, [status, result, router]);5152 // Availability is derived: local rules synchronously, server check debounced.53 const normalized = username.trim().toLowerCase();54 const localError = !normalized ? null : normalized.length < 3 || normalized.length > 24 ? REASONS.length : !USERNAME_RE.test(normalized) ? REASONS.charset : null;55 const serverResult = checked && checked.for === normalized ? checked : null;56 const avail: Availability = !normalized57 ? { state: "idle" }58 : localError59 ? { state: "bad", message: localError }60 : !serverResult || serverResult.failed61 ? { state: serverResult?.failed ? "idle" : "checking" }62 : serverResult.available63 ? { state: "ok", message: "Available" }64 : { state: "bad", message: REASONS[serverResult.reason ?? ""] ?? "Not available." };6566 useEffect(() => {67 if (!normalized || localError) return;68 const my = ++seq.current;69 const t = setTimeout(async () => {70 try {71 const res = await api<{ available: boolean; reason?: string }>("/api/auth/check-username", { json: { username: normalized } });72 if (my !== seq.current) return;73 setChecked({ for: normalized, available: res.available, reason: res.reason });74 } catch {75 if (my !== seq.current) return;76 setChecked({ for: normalized, available: false, failed: true });77 }78 }, 350);79 return () => clearTimeout(t);80 }, [normalized, localError]);8182 const pwError = password && password.length < 8 ? "At least 8 characters." : null;83 const confirmError = confirm && confirm !== password ? "Passwords do not match." : null;84 const canSubmit = avail.state === "ok" && password.length >= 8 && confirm === password && age && !submitting;8586 const submit = async (e: React.FormEvent) => {87 e.preventDefault();88 if (!canSubmit) return;89 setSubmitting(true);90 setFormError(null);91 try {92 const res = await api<RegisterResponse>("/api/auth/register", { json: { username: username.trim().toLowerCase(), password, confirmPassword: confirm, ageConfirmed: true } });93 setResult(res);94 } catch (err) {95 if (err instanceof ApiClientError) {96 if (err.code === "USERNAME_TAKEN") setChecked({ for: normalized, available: false, reason: "taken" });97 setFormError(err.message);98 } else setFormError("Something went wrong. Please try again.");99 } finally {100 setSubmitting(false);101 }102 };103104 const finish = async () => {105 setContinuing(true);106 await refresh();107 router.push("/?welcome=1");108 router.refresh();109 };110111 if (result) {112 return (113 <div className="surface rounded-xl p-6 sm:p-8">114 <div className="mb-5 rounded-md border border-success/30 bg-success/10 px-4 py-3 text-sm text-success">115 Account created — <span className="font-semibold">@{result.user.username}</span> starts with {formatSC(result.balance)}.116 </div>117 <RecoveryCodePanel code={result.recoveryCode} notice={result.notice} onContinue={finish} loading={continuing} continueLabel="Continue to Spinza" />118 </div>119 );120 }121122 return (123 <div className="surface rounded-xl p-6 sm:p-8">124 <div className="mb-6">125 <div className="eyebrow mb-2">Create account</div>126 <h1 className="text-2xl font-semibold tracking-tight">Pick a username. That's it.</h1>127 <p className="mt-1.5 text-sm text-fg-3">No email, no phone number, no card. You receive {formatSC(STARTING_BALANCE)} — fictional credits with no cash value.</p>128 </div>129130 <form onSubmit={submit} className="space-y-4" noValidate>131 <div className="relative">132 <Input label="Username" value={username} onChange={(e) => 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" />133 <span id="username-status" className={cn("pointer-events-none absolute right-4 top-[38px] flex items-center gap-1 text-[12px] font-medium", avail.state === "ok" && "text-success", avail.state === "bad" && "text-danger", avail.state === "checking" && "text-fg-3")} aria-live="polite">134 {avail.state === "checking" ? <Loader2 className="h-4 w-4 animate-spin" /> : avail.state === "ok" ? (135 <>136 <Check className="h-4 w-4" /> Available137 </>138 ) : avail.state === "bad" ? (139 <X className="h-4 w-4" />140 ) : null}141 </span>142 </div>143 <Input label="Password" type="password" value={password} onChange={(e) => setPassword(e.target.value)} autoComplete="new-password" placeholder="At least 8 characters" error={pwError} />144 <Input label="Confirm password" type="password" value={confirm} onChange={(e) => setConfirm(e.target.value)} autoComplete="new-password" placeholder="Repeat your password" error={confirmError} />145146 <label className="flex cursor-pointer items-start gap-3 rounded-md border border-line p-3.5 tap hover:bg-surface">147 <input type="checkbox" checked={age} onChange={(e) => setAge(e.target.checked)} className="mt-0.5 h-5 w-5 shrink-0 accent-[#c9a961]" />148 <span className="text-[14px] leading-snug">I confirm that I am 18 years of age or older.</span>149 </label>150151 {formError ? (152 <p className="rounded-md border border-danger/30 bg-danger/10 px-4 py-3 text-sm text-danger" role="alert">153 {formError}154 </p>155 ) : null}156157 <Button type="submit" size="lg" variant="accent" className="w-full" disabled={!canSubmit} loading={submitting}>158 Create account159 </Button>160161 <p className="text-center text-[12px] leading-relaxed text-fg-4">162 By continuing you accept the{" "}163 <Link href="/legal/terms" className="text-fg-3 underline-offset-4 hover:underline">164 Terms165 </Link>{" "}166 and{" "}167 <Link href="/legal/privacy" className="text-fg-3 underline-offset-4 hover:underline">168 Privacy Policy169 </Link>170 . Spinza Credits are fictional and cannot be purchased or withdrawn.171 </p>172 </form>173174 <p className="mt-6 text-center text-sm text-fg-3">175 Already have an account?{" "}176 <Link href="/login" className="font-medium text-accent-2 underline-offset-4 hover:underline">177 Sign in178 </Link>179 </p>180 </div>181 );182}183