Files
shadow-gunbuilder-ai-proto/app/(account)/account/onboarding/page.tsx
T

350 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/context/AuthContext";
export default function OnboardingPage() {
const router = useRouter();
const { user, loading, token, logout, refreshMeAndSession } = useAuth();
// Step: 1=password, 2=username, 3=optional display name
const [step, setStep] = useState<1 | 2 | 3>(1);
// Password
const [pw1, setPw1] = useState("");
const [pw2, setPw2] = useState("");
const [pwSaving, setPwSaving] = useState(false);
const [pwMsg, setPwMsg] = useState<string | null>(null);
// Username
const [username, setUsername] = useState("");
const [checking, setChecking] = useState(false);
const [available, setAvailable] = useState<boolean | null>(null);
const [userMsg, setUserMsg] = useState<string | null>(null);
const [savingUsername, setSavingUsername] = useState(false);
// Display name (optional)
const [displayName, setDisplayName] = useState("");
const [savingName, setSavingName] = useState(false);
const [nameMsg, setNameMsg] = useState<string | null>(null);
const hasUsername = useMemo(() => {
const current = String((user as any)?.username ?? "").trim();
return current.length > 0;
}, [user]);
const hasPassword = useMemo(() => {
return !!(user as any)?.passwordSetAt;
}, [user]);
const pwMismatch = pw1.length > 0 && pw2.length > 0 && pw1 !== pw2;
const pwTooShort = pw1.length > 0 && pw1.length < 8;
const canSavePassword = !pwSaving && pw1.length >= 8 && pw1 === pw2;
// Hydrate local inputs when user becomes available
useEffect(() => {
if (!user) return;
setUsername(String((user as any)?.username ?? ""));
setDisplayName(String(user.displayName ?? ""));
}, [user]);
// Route guards + step sync (NO redirects during render)
useEffect(() => {
if (loading) return;
if (!user) return;
// If complete, bounce them out
if (hasUsername && hasPassword) {
router.replace("/builder");
return;
}
// Choose the right step based on what's missing
if (!hasPassword) setStep(1);
else if (!hasUsername) setStep(2);
else setStep(3);
}, [loading, user, hasUsername, hasPassword, router]);
if (loading) return <div className="text-sm opacity-70">Loading</div>;
if (!user) return <div className="text-sm opacity-70">Not logged in.</div>;
async function savePassword() {
setPwMsg(null);
if (pw1.length < 8) return setPwMsg("Password must be at least 8 characters.");
if (pw1 !== pw2) return setPwMsg("Passwords dont match.");
setPwSaving(true);
try {
const res = await fetch(`/api/users/me/password`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({ password: pw1 }),
});
if (!res.ok) {
throw new Error((await res.text().catch(() => "")) || "Failed to set password");
}
await refreshMeAndSession();
setPwMsg("Password saved ✅");
setPw1("");
setPw2("");
// step will auto-sync via useEffect once user updates, but this makes UI feel instant:
setStep(2);
} catch (e: any) {
setPwMsg(e?.message || "Couldnt set password.");
} finally {
setPwSaving(false);
}
}
async function checkUsername() {
setChecking(true);
setUserMsg(null);
setAvailable(null);
try {
const u = username.trim().toLowerCase();
const res = await fetch(
`/api/users/me/username-available?username=${encodeURIComponent(u)}`,
{
headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}) },
cache: "no-store",
}
);
if (!res.ok) {
throw new Error((await res.text().catch(() => "")) || "Failed to check username");
}
const data = await res.json();
setAvailable(!!data?.available);
} catch (e: any) {
setUserMsg(e?.message || "Couldnt check username.");
} finally {
setChecking(false);
}
}
async function saveUsername() {
setSavingUsername(true);
setUserMsg(null);
try {
const u = username.trim().toLowerCase();
const res = await fetch(`/api/users/me`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({ username: u }),
});
if (!res.ok) {
throw new Error((await res.text().catch(() => "")) || "Failed to save username");
}
await refreshMeAndSession();
setAvailable(true);
setUserMsg("Username saved ✅");
setStep(3);
} catch (e: any) {
setUserMsg(e?.message || "Couldnt save username.");
} finally {
setSavingUsername(false);
}
}
async function saveDisplayName() {
setSavingName(true);
setNameMsg(null);
try {
const next = displayName.trim();
const res = await fetch(`/api/users/me`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({ displayName: next }),
});
if (!res.ok) {
throw new Error((await res.text().catch(() => "")) || "Failed to save display name");
}
await refreshMeAndSession();
setNameMsg("Saved ✅");
} catch (e: any) {
setNameMsg(e?.message || "Couldnt save display name.");
} finally {
setSavingName(false);
}
}
return (
<div className="rounded-2xl border border-amber-500/20 bg-amber-500/5 p-6">
<h2 className="text-lg font-semibold">Welcome to the beta 👋</h2>
<p className="mt-1 text-sm text-zinc-300/80">
Step {step} of 3. Password + username required.
</p>
{step === 1 && (
<div className="mt-6 space-y-3">
<div className="text-sm font-medium">Set password *</div>
<input
type="password"
value={pw1}
onChange={(e) => setPw1(e.target.value)}
placeholder="Password (min 8 chars)"
className="w-full rounded-md border border-white/10 bg-black/40 px-3 py-2"
/>
<input
type="password"
value={pw2}
onChange={(e) => setPw2(e.target.value)}
placeholder="Confirm password"
className="w-full rounded-md border border-white/10 bg-black/40 px-3 py-2"
/>
{(pwTooShort || pwMismatch) && (
<div className="text-xs text-red-300">
{pwTooShort ? "Password must be at least 8 characters." : "Passwords dont match."}
</div>
)}
<button
type="button"
onClick={savePassword}
disabled={!canSavePassword}
className="rounded-md bg-amber-400 px-4 py-2 text-sm font-medium text-black disabled:opacity-50"
>
{pwSaving ? "Saving…" : "Save password"}
</button>
{pwMsg && <div className="text-xs text-zinc-200/80">{pwMsg}</div>}
<div className="pt-4 border-t border-white/10 flex items-center justify-between">
<button
type="button"
onClick={logout}
className="text-xs underline opacity-70 hover:opacity-100"
>
Log out
</button>
</div>
</div>
)}
{step === 2 && (
<div className="mt-6 space-y-3">
<div className="text-sm font-medium">Choose username *</div>
<input
value={username}
onChange={(e) => {
setUsername(e.target.value);
setAvailable(null);
setUserMsg(null);
}}
placeholder="e.g. s2tactical"
className="w-full rounded-md border border-white/10 bg-black/40 px-3 py-2"
/>
<div className="flex items-center gap-2">
<button
type="button"
onClick={checkUsername}
disabled={checking || savingUsername || username.trim().length < 3}
className="rounded-md border border-white/10 px-3 py-2 text-xs hover:bg-white/5 disabled:opacity-50"
>
{checking ? "Checking…" : "Check availability"}
</button>
<button
type="button"
onClick={saveUsername}
disabled={savingUsername || available !== true}
className="rounded-md bg-amber-400 px-3 py-2 text-xs font-medium text-black disabled:opacity-50"
>
{savingUsername ? "Saving…" : "Save username"}
</button>
{available !== null && (
<span className={`text-xs ${available ? "text-emerald-300" : "text-red-300"}`}>
{available ? "Available" : "Taken / invalid"}
</span>
)}
</div>
{userMsg && <div className="text-xs text-zinc-200/80">{userMsg}</div>}
<div className="pt-4 border-t border-white/10 flex items-center justify-between">
<button
type="button"
onClick={logout}
className="text-xs underline opacity-70 hover:opacity-100"
>
Log out
</button>
</div>
</div>
)}
{step === 3 && (
<div className="mt-6 space-y-3">
<div className="text-sm font-medium">
Display name <span className="text-zinc-400">(optional)</span>
</div>
<input
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="e.g. Sean / S2Tactical / DirtNinja69"
className="w-full rounded-md border border-white/10 bg-black/40 px-3 py-2"
/>
<button
type="button"
onClick={saveDisplayName}
disabled={savingName || displayName.trim().length === 0}
className="rounded-md border border-white/10 px-3 py-2 text-xs hover:bg-white/5 disabled:opacity-50"
>
{savingName ? "Saving…" : "Save display name"}
</button>
{nameMsg && <div className="text-xs text-zinc-200/80">{nameMsg}</div>}
<div className="mt-6 flex items-center justify-between border-t border-white/10 pt-4">
<button
type="button"
onClick={() => router.replace("/builder")}
disabled={!(hasUsername && hasPassword)}
className="rounded-md bg-amber-400 px-4 py-2 text-sm font-medium text-black disabled:opacity-50"
>
Continue to Builder
</button>
<button
type="button"
onClick={() => router.replace("/account/profile")}
className="text-xs underline opacity-70 hover:opacity-100"
>
Ill finish later
</button>
</div>
</div>
)}
</div>
);
}