SPB Git forge

spb/spinza

Public
8commits 1branches 0releases
1.6 MBsize
maindefault branch
16 days agolast push
TypeScript 97.6% SQL 1.4% JavaScript 0.5%
8.5 KB · 183 lines tsx
Raw Blame History
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&apos;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