account settings and account creation
This commit is contained in:
@@ -0,0 +1,350 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user