350 lines
11 KiB
TypeScript
350 lines
11 KiB
TypeScript
"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 don’t 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 || "Couldn’t 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 || "Couldn’t 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 || "Couldn’t 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 || "Couldn’t 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 don’t 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"
|
||
>
|
||
I’ll finish later
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
} |