account settings and account creation
This commit is contained in:
@@ -0,0 +1,8 @@
|
|||||||
|
export default function FavoritesPage() {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-white/10 bg-white/5 p-6">
|
||||||
|
<h2 className="text-lg font-semibold">Favorite Parts</h2>
|
||||||
|
<p className="mt-2 text-sm text-zinc-400">Coming soon.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,439 +1,5 @@
|
|||||||
// app/(account)/account/page.tsx
|
import { redirect } from "next/navigation";
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useMemo, useState } from "react";
|
export default function AccountIndex() {
|
||||||
import { useSearchParams, useRouter } from "next/navigation";
|
redirect("/account/profile");
|
||||||
import { useAuth } from "@/context/AuthContext";
|
|
||||||
|
|
||||||
const API_BASE_URL =
|
|
||||||
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
|
|
||||||
|
|
||||||
export default function AccountPage() {
|
|
||||||
const { user, loading, token, setSession, logout } = useAuth();
|
|
||||||
const searchParams = useSearchParams();
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const welcome = searchParams.get("welcome") === "1";
|
|
||||||
|
|
||||||
const needsDisplayName = useMemo(() => {
|
|
||||||
return !user?.displayName || user.displayName.trim().length === 0;
|
|
||||||
}, [user?.displayName]);
|
|
||||||
|
|
||||||
// Only show welcome panel if they actually need setup
|
|
||||||
const showWelcomeCard = welcome && needsDisplayName;
|
|
||||||
|
|
||||||
// ----------------------------
|
|
||||||
// Display Name state (shared)
|
|
||||||
// ----------------------------
|
|
||||||
const [displayName, setDisplayName] = useState(user?.displayName ?? "");
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const [msg, setMsg] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// Inline profile editor
|
|
||||||
const [editingName, setEditingName] = useState(false);
|
|
||||||
|
|
||||||
// ----------------------------
|
|
||||||
// Password state
|
|
||||||
// ----------------------------
|
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
|
||||||
const [pw1, setPw1] = useState("");
|
|
||||||
const [pw2, setPw2] = useState("");
|
|
||||||
const [pwSaving, setPwSaving] = useState(false);
|
|
||||||
const [pwMsg, setPwMsg] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const canSaveName = useMemo(() => {
|
|
||||||
return displayName.trim().length > 0 && !saving;
|
|
||||||
}, [displayName, saving]);
|
|
||||||
|
|
||||||
const pwMismatch = useMemo(
|
|
||||||
() => pw1.length > 0 && pw2.length > 0 && pw1 !== pw2,
|
|
||||||
[pw1, pw2]
|
|
||||||
);
|
|
||||||
const pwTooShort = useMemo(() => pw1.length > 0 && pw1.length < 8, [pw1]);
|
|
||||||
const canSavePassword = useMemo(() => {
|
|
||||||
return !pwSaving && pw1.length >= 8 && pw1 === pw2;
|
|
||||||
}, [pwSaving, pw1, pw2]);
|
|
||||||
|
|
||||||
if (loading) return <div className="text-sm opacity-70">Loading…</div>;
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return (
|
|
||||||
<div className="text-sm opacity-70">
|
|
||||||
You’re not logged in. Please log in to view your account.
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const authedUser = user; // user is non-null from here down
|
|
||||||
|
|
||||||
async function saveDisplayName() {
|
|
||||||
setSaving(true);
|
|
||||||
setMsg(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const nextName = displayName.trim();
|
|
||||||
|
|
||||||
const res = await fetch(`${API_BASE_URL}/api/users/me`, {
|
|
||||||
method: "PATCH",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ displayName: nextName }),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
const text = await res.text().catch(() => "");
|
|
||||||
throw new Error(text || "Failed to update profile");
|
|
||||||
}
|
|
||||||
|
|
||||||
const updated = await res.json().catch(() => null);
|
|
||||||
|
|
||||||
const nextUser = {
|
|
||||||
email: authedUser.email,
|
|
||||||
displayName: updated?.displayName ?? nextName,
|
|
||||||
role: authedUser.role,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (token) setSession(token, nextUser);
|
|
||||||
|
|
||||||
setMsg("Saved ✅");
|
|
||||||
setEditingName(false);
|
|
||||||
|
|
||||||
// If we came from the welcome flow, clear the param
|
|
||||||
if (welcome) router.replace("/account");
|
|
||||||
} catch (e: any) {
|
|
||||||
setMsg(e?.message || "Couldn’t save. Try again.");
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function savePassword() {
|
|
||||||
setPwMsg(null);
|
|
||||||
|
|
||||||
if (pw1.length < 8) {
|
|
||||||
setPwMsg("Password must be at least 8 characters.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (pw1 !== pw2) {
|
|
||||||
setPwMsg("Passwords don’t match.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setPwSaving(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${API_BASE_URL}/api/users/me/password`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ password: pw1 }),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
const text = await res.text().catch(() => "");
|
|
||||||
throw new Error(text || "Failed to set password");
|
|
||||||
}
|
|
||||||
|
|
||||||
setPwMsg("Password set ✅ You can now use email + password login.");
|
|
||||||
setPw1("");
|
|
||||||
setPw2("");
|
|
||||||
setShowPassword(false);
|
|
||||||
|
|
||||||
if (welcome) router.replace("/account");
|
|
||||||
} catch (e: any) {
|
|
||||||
setPwMsg(e?.message || "Couldn’t set password. Try again.");
|
|
||||||
} finally {
|
|
||||||
setPwSaving(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Welcome / Setup card (only if needed) */}
|
|
||||||
{showWelcomeCard && (
|
|
||||||
<div className="rounded-xl border border-amber-500/30 bg-amber-500/10 p-4 space-y-4">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-base font-semibold">Welcome to the beta 👋</h2>
|
|
||||||
<p className="text-sm text-zinc-300/80">
|
|
||||||
Quick setup and you’re ready to cook.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Display name */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-xs font-medium text-zinc-200">
|
|
||||||
Display name
|
|
||||||
</label>
|
|
||||||
<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 text-sm outline-none focus:ring-2 focus:ring-amber-500/40"
|
|
||||||
/>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<button
|
|
||||||
onClick={saveDisplayName}
|
|
||||||
disabled={!canSaveName}
|
|
||||||
className="rounded-md bg-amber-400 px-3 py-2 text-sm font-medium text-black disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{saving ? "Saving…" : "Save"}
|
|
||||||
</button>
|
|
||||||
{msg && (
|
|
||||||
<span
|
|
||||||
className={`text-xs ${
|
|
||||||
msg.toLowerCase().includes("couldn")
|
|
||||||
? "text-red-300"
|
|
||||||
: "text-zinc-200/80"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{msg}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Password */}
|
|
||||||
<div className="pt-2 border-t border-white/10">
|
|
||||||
<p className="text-xs text-zinc-300/80">
|
|
||||||
Want password login later? Optional (but recommended).
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{!showPassword ? (
|
|
||||||
<button
|
|
||||||
onClick={() => setShowPassword(true)}
|
|
||||||
className="mt-2 text-xs underline opacity-80 hover:opacity-100"
|
|
||||||
>
|
|
||||||
Set a password
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<div className="mt-3 space-y-2">
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={pw1}
|
|
||||||
onChange={(e) => setPw1(e.target.value)}
|
|
||||||
placeholder="New password (min 8 chars)"
|
|
||||||
className="w-full rounded-md border border-white/10 bg-black/40 px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-amber-500/40"
|
|
||||||
/>
|
|
||||||
<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 text-sm outline-none focus:ring-2 focus:ring-amber-500/40"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{(pwTooShort || pwMismatch) && (
|
|
||||||
<div className="text-xs text-red-300">
|
|
||||||
{pwTooShort
|
|
||||||
? "Password must be at least 8 characters."
|
|
||||||
: "Passwords don’t match."}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<button
|
|
||||||
onClick={savePassword}
|
|
||||||
disabled={!canSavePassword}
|
|
||||||
className="rounded-md bg-amber-400 px-3 py-2 text-sm font-medium text-black disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{pwSaving ? "Saving…" : "Save password"}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setShowPassword(false);
|
|
||||||
setPw1("");
|
|
||||||
setPw2("");
|
|
||||||
setPwMsg(null);
|
|
||||||
}}
|
|
||||||
className="text-xs opacity-80 hover:opacity-100"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{pwMsg && (
|
|
||||||
<div
|
|
||||||
className={`text-xs ${
|
|
||||||
pwMsg.toLowerCase().includes("couldn")
|
|
||||||
? "text-red-300"
|
|
||||||
: "text-zinc-200/80"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{pwMsg}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Profile header */}
|
|
||||||
<div>
|
|
||||||
<h2 className="text-lg font-semibold">Profile</h2>
|
|
||||||
<p className="text-sm opacity-70">Basic account info.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Profile card */}
|
|
||||||
<div className="rounded-xl border border-white/10 bg-white/5 p-4 space-y-3">
|
|
||||||
<div className="text-sm">
|
|
||||||
<span className="opacity-70">Email:</span>{" "}
|
|
||||||
<span className="font-medium">{user.email}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Display name (inline editable) */}
|
|
||||||
<div className="text-sm">
|
|
||||||
<span className="opacity-70">Display name:</span>{" "}
|
|
||||||
{!editingName ? (
|
|
||||||
<>
|
|
||||||
<span className="font-medium">{user.displayName || "—"}</span>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setEditingName(true);
|
|
||||||
setMsg(null);
|
|
||||||
setDisplayName(user.displayName ?? "");
|
|
||||||
}}
|
|
||||||
className="ml-3 text-xs underline opacity-70 hover:opacity-100"
|
|
||||||
>
|
|
||||||
Edit
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="mt-2 space-y-2">
|
|
||||||
<input
|
|
||||||
value={displayName}
|
|
||||||
onChange={(e) => setDisplayName(e.target.value)}
|
|
||||||
placeholder="Your display name"
|
|
||||||
className="w-full rounded-md border border-white/10 bg-black/40 px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-amber-500/40"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<button
|
|
||||||
onClick={saveDisplayName}
|
|
||||||
disabled={!canSaveName}
|
|
||||||
className="rounded-md bg-amber-400 px-3 py-2 text-xs font-medium text-black disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{saving ? "Saving…" : "Save"}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setEditingName(false);
|
|
||||||
setDisplayName(user.displayName ?? "");
|
|
||||||
setMsg(null);
|
|
||||||
}}
|
|
||||||
className="text-xs opacity-80 hover:opacity-100"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
{msg && (
|
|
||||||
<span
|
|
||||||
className={`text-xs ${
|
|
||||||
msg.toLowerCase().includes("couldn")
|
|
||||||
? "text-red-300"
|
|
||||||
: "text-zinc-200/80"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{msg}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-sm">
|
|
||||||
<span className="opacity-70">Role:</span>{" "}
|
|
||||||
<span className="font-medium">{user.role}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="pt-3 border-t border-white/10 flex items-center justify-between">
|
|
||||||
<button
|
|
||||||
onClick={logout}
|
|
||||||
className="rounded-md border border-white/10 px-3 py-2 text-xs opacity-80 hover:opacity-100"
|
|
||||||
>
|
|
||||||
Log out
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Optional: quick link to set password later */}
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setShowPassword(true);
|
|
||||||
// Scroll user’s attention to welcome panel area if it exists,
|
|
||||||
// otherwise just open the password UI in their mind.
|
|
||||||
// (Kept simple: you can add a dedicated card later if you want)
|
|
||||||
setPwMsg(null);
|
|
||||||
}}
|
|
||||||
className="text-xs underline opacity-70 hover:opacity-100"
|
|
||||||
>
|
|
||||||
Set / change password
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* If user clicks "Set/change password" outside welcome flow, show it here */}
|
|
||||||
{!showWelcomeCard && showPassword && (
|
|
||||||
<div className="pt-4 border-t border-white/10 space-y-2">
|
|
||||||
<div className="text-sm font-medium">Set password</div>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={pw1}
|
|
||||||
onChange={(e) => setPw1(e.target.value)}
|
|
||||||
placeholder="New password (min 8 chars)"
|
|
||||||
className="w-full rounded-md border border-white/10 bg-black/40 px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-amber-500/40"
|
|
||||||
/>
|
|
||||||
<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 text-sm outline-none focus:ring-2 focus:ring-amber-500/40"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{(pwTooShort || pwMismatch) && (
|
|
||||||
<div className="text-xs text-red-300">
|
|
||||||
{pwTooShort
|
|
||||||
? "Password must be at least 8 characters."
|
|
||||||
: "Passwords don’t match."}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<button
|
|
||||||
onClick={savePassword}
|
|
||||||
disabled={!canSavePassword}
|
|
||||||
className="rounded-md bg-amber-400 px-3 py-2 text-sm font-medium text-black disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{pwSaving ? "Saving…" : "Save password"}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setShowPassword(false);
|
|
||||||
setPw1("");
|
|
||||||
setPw2("");
|
|
||||||
setPwMsg(null);
|
|
||||||
}}
|
|
||||||
className="text-xs opacity-80 hover:opacity-100"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{pwMsg && (
|
|
||||||
<div
|
|
||||||
className={`text-xs ${
|
|
||||||
pwMsg.toLowerCase().includes("couldn")
|
|
||||||
? "text-red-300"
|
|
||||||
: "text-zinc-200/80"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{pwMsg}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useAuth } from "@/context/AuthContext";
|
||||||
|
|
||||||
|
export default function ProfilePage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { user, loading, token, logout, refreshMeAndSession } = useAuth();
|
||||||
|
|
||||||
|
const [displayName, setDisplayName] = useState("");
|
||||||
|
const [savingName, setSavingName] = useState(false);
|
||||||
|
const [nameMsg, setNameMsg] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [pw1, setPw1] = useState("");
|
||||||
|
const [pw2, setPw2] = useState("");
|
||||||
|
const [pwSaving, setPwSaving] = useState(false);
|
||||||
|
const [pwMsg, setPwMsg] = 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 needsOnboarding = !!user && (!hasUsername || !hasPassword);
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user) return;
|
||||||
|
setDisplayName(String(user.displayName ?? ""));
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
// If missing required setup, bounce to onboarding
|
||||||
|
useEffect(() => {
|
||||||
|
if (loading) return;
|
||||||
|
if (!user) return;
|
||||||
|
|
||||||
|
if (needsOnboarding) {
|
||||||
|
router.replace("/account/onboarding");
|
||||||
|
}
|
||||||
|
}, [loading, user, needsOnboarding, 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>;
|
||||||
|
|
||||||
|
// While redirecting, don't flash profile UI
|
||||||
|
if (needsOnboarding) return null;
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 updated ✅");
|
||||||
|
setPw1("");
|
||||||
|
setPw2("");
|
||||||
|
setShowPassword(false);
|
||||||
|
} catch (e: any) {
|
||||||
|
setPwMsg(e?.message || "Couldn’t set password. Try again.");
|
||||||
|
} finally {
|
||||||
|
setPwSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold">Profile</h2>
|
||||||
|
<p className="text-sm opacity-70">Basic account info.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-xl border border-white/10 bg-white/5 p-4 space-y-4">
|
||||||
|
<div className="text-sm">
|
||||||
|
<span className="opacity-70">Email:</span>{" "}
|
||||||
|
<span className="font-medium">{user.email}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-sm">
|
||||||
|
<span className="opacity-70">Username:</span>{" "}
|
||||||
|
<span className="font-medium">{String((user as any)?.username ?? "—")}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-sm font-medium">Display name</div>
|
||||||
|
<input
|
||||||
|
value={displayName}
|
||||||
|
onChange={(e) => {
|
||||||
|
setDisplayName(e.target.value);
|
||||||
|
setNameMsg(null);
|
||||||
|
}}
|
||||||
|
placeholder="e.g. Sean / S2Tactical"
|
||||||
|
className="w-full rounded-md border border-white/10 bg-black/40 px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-amber-500/40"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={saveDisplayName}
|
||||||
|
disabled={savingName || displayName.trim().length === 0}
|
||||||
|
className="rounded-md border border-white/10 px-3 py-2 text-xs opacity-80 hover:opacity-100 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{savingName ? "Saving…" : "Save display name"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{nameMsg && (
|
||||||
|
<span
|
||||||
|
className={`text-xs ${
|
||||||
|
nameMsg.toLowerCase().includes("couldn") || nameMsg.toLowerCase().includes("failed")
|
||||||
|
? "text-red-300"
|
||||||
|
: "text-zinc-200/80"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{nameMsg}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-sm">
|
||||||
|
<span className="opacity-70">Role:</span>{" "}
|
||||||
|
<span className="font-medium">{user.role}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-3 border-t border-white/10 flex items-center justify-between">
|
||||||
|
<button
|
||||||
|
onClick={logout}
|
||||||
|
className="rounded-md border border-white/10 px-3 py-2 text-xs opacity-80 hover:opacity-100"
|
||||||
|
>
|
||||||
|
Log out
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowPassword((v) => !v);
|
||||||
|
setPwMsg(null);
|
||||||
|
}}
|
||||||
|
className="text-xs underline opacity-70 hover:opacity-100"
|
||||||
|
>
|
||||||
|
{showPassword ? "Hide password" : "Set / change password"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showPassword && (
|
||||||
|
<div className="pt-4 border-t border-white/10 space-y-2">
|
||||||
|
<div className="text-sm font-medium">Set password</div>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={pw1}
|
||||||
|
onChange={(e) => setPw1(e.target.value)}
|
||||||
|
placeholder="New password (min 8 chars)"
|
||||||
|
className="w-full rounded-md border border-white/10 bg-black/40 px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-amber-500/40"
|
||||||
|
/>
|
||||||
|
<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 text-sm outline-none focus:ring-2 focus:ring-amber-500/40"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{(pwTooShort || pwMismatch) && (
|
||||||
|
<div className="text-xs text-red-300">
|
||||||
|
{pwTooShort ? "Password must be at least 8 characters." : "Passwords don’t match."}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={savePassword}
|
||||||
|
disabled={!canSavePassword}
|
||||||
|
className="rounded-md bg-amber-400 px-3 py-2 text-sm font-medium text-black disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{pwSaving ? "Saving…" : "Save password"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowPassword(false);
|
||||||
|
setPw1("");
|
||||||
|
setPw2("");
|
||||||
|
setPwMsg(null);
|
||||||
|
}}
|
||||||
|
className="text-xs opacity-80 hover:opacity-100"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{pwMsg && (
|
||||||
|
<div
|
||||||
|
className={`text-xs ${
|
||||||
|
pwMsg.toLowerCase().includes("couldn") || pwMsg.toLowerCase().includes("failed")
|
||||||
|
? "text-red-300"
|
||||||
|
: "text-zinc-200/80"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{pwMsg}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="pt-3 border-t border-white/10 flex items-center justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => router.replace("/builder")}
|
||||||
|
className="rounded-md bg-amber-400 px-3 py-2 text-sm font-medium text-black"
|
||||||
|
>
|
||||||
|
Back to Builder
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export default function SecurityPage() {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-white/10 bg-white/5 p-6">
|
||||||
|
<h2 className="text-lg font-semibold">Security</h2>
|
||||||
|
<p className="mt-2 text-sm text-zinc-400">Coming soon.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export default function VaultPage() {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-white/10 bg-white/5 p-6">
|
||||||
|
<h2 className="text-lg font-semibold">My Vault</h2>
|
||||||
|
<p className="mt-2 text-sm text-zinc-400">Coming soon.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+24
-26
@@ -1,59 +1,57 @@
|
|||||||
// app/(account)/layout.tsx
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import AccountChrome from "./AccountChrome";
|
|
||||||
|
|
||||||
const nav = [
|
const nav = [
|
||||||
{ href: "/account", label: "My Account" },
|
{ name: "Profile", href: "/account/profile" },
|
||||||
{ href: "/account/settings", label: "Settings" },
|
{ name: "Security", href: "/account/security" },
|
||||||
{ href: "/vault", label: "My Vault" },
|
{ name: "My Vault", href: "/account/vault" },
|
||||||
|
{ name: "Favorite Parts", href: "/account/favorites" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function AccountLayout({ children }: { children: React.ReactNode }) {
|
export default function AccountLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen">
|
<div className="min-h-screen bg-black text-zinc-50">
|
||||||
<AccountChrome />
|
<div className="mx-auto max-w-6xl px-4 py-10">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
<div className="mx-auto w-full max-w-6xl px-4 py-6">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="mb-6 flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[11px] uppercase tracking-[0.22em] text-white/50">
|
<div className="text-xs tracking-[0.25em] uppercase text-zinc-400">
|
||||||
Account
|
Account
|
||||||
</div>
|
</div>
|
||||||
<h1 className="mt-1 text-2xl font-semibold text-white/90">
|
<h1 className="mt-2 text-3xl font-semibold">Settings & Profile</h1>
|
||||||
Settings & Profile
|
<p className="mt-2 text-sm text-zinc-400">
|
||||||
</h1>
|
|
||||||
<p className="mt-1 text-sm text-white/60">
|
|
||||||
Profile, password, and account settings.
|
Profile, password, and account settings.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
href="/builder"
|
href="/builder"
|
||||||
className="inline-flex items-center justify-center rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm font-semibold text-white/80 hover:bg-white/10"
|
className="rounded-lg border border-white/10 bg-white/5 px-4 py-2 text-sm hover:bg-white/10"
|
||||||
>
|
>
|
||||||
← Back to Builder
|
← Back to Builder
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-6 lg:grid-cols-12">
|
<div className="mt-10 grid grid-cols-1 gap-8 lg:grid-cols-12">
|
||||||
<aside className="lg:col-span-3 rounded-2xl border border-white/10 bg-white/5 p-4">
|
{/* Sidebar */}
|
||||||
<nav className="flex flex-col gap-1">
|
<aside className="lg:col-span-3">
|
||||||
|
<nav className="rounded-xl border border-white/10 bg-white/5 p-2">
|
||||||
{nav.map((item) => (
|
{nav.map((item) => (
|
||||||
<Link
|
<Link
|
||||||
key={item.href}
|
key={item.href}
|
||||||
href={item.href}
|
href={item.href}
|
||||||
className="rounded-lg px-3 py-2 text-sm text-white/70 hover:bg-white/10 hover:text-white"
|
className="block rounded-lg px-3 py-2 text-sm text-zinc-200 hover:bg-white/5"
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.name}
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main className="lg:col-span-9 rounded-2xl border border-white/10 bg-white/5 p-5">
|
{/* Content */}
|
||||||
{children}
|
<main className="lg:col-span-9">{children}</main>
|
||||||
</main>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,102 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
export const metadata = {
|
|
||||||
title: "Terms of Service | BattlBuilder",
|
|
||||||
description: "Terms of Service and legal disclaimers for rifle assembly and part compatibility.",
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function TermsOfService() {
|
|
||||||
const lastUpdated = "December 26, 2024";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mx-auto max-w-4xl px-4 py-12">
|
|
||||||
<div className="mb-10">
|
|
||||||
<h1 className="text-4xl font-bold tracking-tight">Terms of Service</h1>
|
|
||||||
<p className="mt-2 text-sm opacity-60">Last Updated: {lastUpdated}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-10 text-sm leading-relaxed text-white/90">
|
|
||||||
{/* 1. Acceptance */}
|
|
||||||
<section>
|
|
||||||
<h2 className="mb-4 text-xl font-semibold text-white">1. Acceptance of Terms</h2>
|
|
||||||
<p>
|
|
||||||
By accessing or using Shadow GunBuilder AI (the "Site"), you agree to be bound by these Terms of Service.
|
|
||||||
This Site is intended for informational and entertainment purposes only, providing a platform to
|
|
||||||
visualize and plan AR-platform rifle builds using third-party components.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* 2. Legal Compliance */}
|
|
||||||
<section className="rounded-xl border border-orange-500/30 bg-orange-500/5 p-6">
|
|
||||||
<h2 className="mb-4 text-xl font-semibold text-orange-200">2. Legal Compliance & Firearms Safety</h2>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<p className="font-medium text-orange-100">
|
|
||||||
IMPORTANT: It is your sole responsibility to ensure that any firearm configuration you assemble
|
|
||||||
complies with all applicable local, state, federal, and international laws.
|
|
||||||
</p>
|
|
||||||
<ul className="list-disc space-y-2 pl-5">
|
|
||||||
<li>Laws regarding "assault weapons," magazine capacity, barrel length (NFA regulations), and specific features vary significantly by jurisdiction.</li>
|
|
||||||
<li>The Site does not provide legal advice regarding the legality of any specific build in your area.</li>
|
|
||||||
<li>Always consult with a qualified gunsmith or legal professional before commencing a physical build.</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* 3. No Guarantee of Compatibility */}
|
|
||||||
<section>
|
|
||||||
<h2 className="mb-4 text-xl font-semibold text-white">3. Compatibility Disclaimer</h2>
|
|
||||||
<p>
|
|
||||||
While we strive for accuracy, the Site is a digital simulation. Compatibility data between parts
|
|
||||||
(e.g., gas block diameters, handguard clearances, or buffer weights) is based on manufacturer
|
|
||||||
specifications which are subject to change.
|
|
||||||
</p>
|
|
||||||
<p className="mt-4">
|
|
||||||
Shadow GunBuilder AI does <strong>not</strong> guarantee that parts selected in the digital builder
|
|
||||||
will fit together physically or function safely. Mechanical tolerances and manufacturer variations
|
|
||||||
may exist.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* 4. User Generated Content */}
|
|
||||||
<section>
|
|
||||||
<h2 className="mb-4 text-xl font-semibold text-white">4. User Builds and the "Vault"</h2>
|
|
||||||
<p>
|
|
||||||
When you save a build to your Vault or publish it to the community, you grant us a non-exclusive
|
|
||||||
license to display and store that data. You are responsible for the content of your descriptions
|
|
||||||
and ensuring they do not violate any laws or third-party rights.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* 5. Affiliate and Pricing Disclosure */}
|
|
||||||
<section>
|
|
||||||
<h2 className="mb-4 text-xl font-semibold text-white">5. Affiliate & Pricing Data</h2>
|
|
||||||
<p>
|
|
||||||
Pricing and availability data are pulled from third-party retailers. We are not responsible
|
|
||||||
for pricing errors or out-of-stock items. We may receive commissions for purchases made
|
|
||||||
through links on the Site.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* 6. Limitation of Liability */}
|
|
||||||
<section className="border-t border-white/10 pt-10">
|
|
||||||
<h2 className="mb-4 text-xl font-semibold text-white">6. Limitation of Liability</h2>
|
|
||||||
<p className="uppercase text-xs opacity-70">
|
|
||||||
SHADOW GUNBUILDER AI AND ITS OPERATORS SHALL NOT BE LIABLE FOR ANY DAMAGES, INJURIES, OR LEGAL
|
|
||||||
CONSEQUENCES ARISING FROM THE USE OF THIS SITE OR THE PHYSICAL ASSEMBLY OF FIREARMS BASED ON
|
|
||||||
PLANS CREATED HEREIN. USE AT YOUR OWN RISK.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div className="pt-10">
|
|
||||||
<Link
|
|
||||||
href="/public"
|
|
||||||
className="text-sm text-blue-400 hover:text-blue-300 transition-colors"
|
|
||||||
>
|
|
||||||
← Return to Home
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -3,6 +3,8 @@ import type { ReactNode } from "react";
|
|||||||
|
|
||||||
import { BuilderNav } from "@/components/BuilderNav";
|
import { BuilderNav } from "@/components/BuilderNav";
|
||||||
import { TopNav } from "@/components/TopNav";
|
import { TopNav } from "@/components/TopNav";
|
||||||
|
import { Import } from "lucide-react";
|
||||||
|
import { Footer } from "@/components/Footer";
|
||||||
|
|
||||||
export default function BuilderLayout({ children }: { children: ReactNode }) {
|
export default function BuilderLayout({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
@@ -10,6 +12,7 @@ export default function BuilderLayout({ children }: { children: ReactNode }) {
|
|||||||
<TopNav />
|
<TopNav />
|
||||||
<BuilderNav />
|
<BuilderNav />
|
||||||
<main className="min-h-screen">{children}</main>
|
<main className="min-h-screen">{children}</main>
|
||||||
|
<Footer />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
import React from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: "Terms of Service | Battl Builder",
|
||||||
|
description:
|
||||||
|
"Terms of Service and legal disclaimers for Battl Builder, including firearm safety, compatibility, and community content.",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function TermsOfService() {
|
||||||
|
const lastUpdated = "December 27, 2025";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-4xl px-4 py-12">
|
||||||
|
<div className="mb-10">
|
||||||
|
<h1 className="text-4xl font-bold tracking-tight">Terms of Service</h1>
|
||||||
|
<p className="mt-2 text-sm opacity-60">Last Updated: {lastUpdated}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-12 text-sm leading-relaxed text-white/90">
|
||||||
|
{/* 1. Acceptance */}
|
||||||
|
<section>
|
||||||
|
<h2 className="mb-4 text-xl font-semibold">1. Acceptance of Terms</h2>
|
||||||
|
<p>
|
||||||
|
These Terms of Service (“Terms”) govern your access to and use of
|
||||||
|
Battl Builder (the “Service”), including all content, tools,
|
||||||
|
features, and community functionality provided through the website.
|
||||||
|
</p>
|
||||||
|
<p className="mt-4">
|
||||||
|
By accessing or using any part of the Service, you agree to be bound
|
||||||
|
by these Terms and our Privacy Policy. If you do not agree, you may
|
||||||
|
not access or use the Service.
|
||||||
|
</p>
|
||||||
|
<p className="mt-4">
|
||||||
|
The Service is offered only to individuals who are at least 18 years
|
||||||
|
of age.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 2. Nature of the Service */}
|
||||||
|
<section>
|
||||||
|
<h2 className="mb-4 text-xl font-semibold">
|
||||||
|
2. Nature of the Service
|
||||||
|
</h2>
|
||||||
|
<p>
|
||||||
|
Battl Builder is an informational and planning platform designed to
|
||||||
|
help users explore, visualize, and compare firearm-related
|
||||||
|
components and configurations. The Service may include pricing data,
|
||||||
|
compatibility indicators, build summaries, and community-generated
|
||||||
|
content.
|
||||||
|
</p>
|
||||||
|
<p className="mt-4 font-medium text-orange-200">
|
||||||
|
Battl Builder does NOT provide gunsmithing services, mechanical
|
||||||
|
instructions, professional advice, training, or certification of any
|
||||||
|
kind.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 3. Legal Compliance & Firearms Safety */}
|
||||||
|
<section className="rounded-xl border border-orange-500/30 bg-orange-500/5 p-6">
|
||||||
|
<h2 className="mb-4 text-xl font-semibold text-orange-200">
|
||||||
|
3. Legal Compliance, Firearms Safety & Assumption of Risk
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="font-medium text-orange-100">
|
||||||
|
You are solely responsible for ensuring that any firearm,
|
||||||
|
component, or configuration complies with all applicable local,
|
||||||
|
state, federal, and international laws.
|
||||||
|
</p>
|
||||||
|
<ul className="list-disc space-y-2 pl-5">
|
||||||
|
<li>
|
||||||
|
Firearm laws vary significantly by jurisdiction and change
|
||||||
|
frequently.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Battl Builder does not provide legal advice or determine the
|
||||||
|
legality of any build.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Compatibility indicators are informational only and may be
|
||||||
|
incomplete or inaccurate.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p className="font-semibold">
|
||||||
|
YOU ACKNOWLEDGE AND AGREE THAT THE PHYSICAL ASSEMBLY,
|
||||||
|
MODIFICATION, POSSESSION, OR USE OF FIREARMS INVOLVES INHERENT
|
||||||
|
RISKS, INCLUDING SERIOUS INJURY OR DEATH. YOU ASSUME ALL SUCH
|
||||||
|
RISKS.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Nothing on the Service constitutes legal, mechanical, firearms,
|
||||||
|
safety, or professional advice of any kind.
|
||||||
|
</p>
|
||||||
|
<p className="mt-4 font-semibold">
|
||||||
|
You are solely responsible for verifying all component
|
||||||
|
compatibility, legality, and safe operation before acquiring,
|
||||||
|
assembling, or using any firearm or related component.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 4. No Warranty / No Reliance */}
|
||||||
|
<section>
|
||||||
|
<h2 className="mb-4 text-xl font-semibold">
|
||||||
|
4. No Warranty; No Reliance
|
||||||
|
</h2>
|
||||||
|
<p>
|
||||||
|
The Service is provided for general informational purposes only. You
|
||||||
|
agree that you will not rely on the Service as a substitute for
|
||||||
|
professional advice, inspection, or training.
|
||||||
|
</p>
|
||||||
|
<p className="mt-4">
|
||||||
|
All content, data, and tools are provided “AS IS” and “AS
|
||||||
|
AVAILABLE,” without warranties of any kind, whether express or
|
||||||
|
implied.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 5. User Content */}
|
||||||
|
<section>
|
||||||
|
<h2 className="mb-4 text-xl font-semibold">
|
||||||
|
5. User Content & Community Builds
|
||||||
|
</h2>
|
||||||
|
<p>
|
||||||
|
By submitting builds, images, descriptions, comments, or other
|
||||||
|
content (“User Content”), you represent and warrant that:
|
||||||
|
</p>
|
||||||
|
<ul className="mt-3 list-disc space-y-2 pl-5">
|
||||||
|
<li>You own or have rights to submit the content</li>
|
||||||
|
<li>The content does not infringe third-party rights</li>
|
||||||
|
<li>The content is not unlawful, misleading, or harmful</li>
|
||||||
|
<li>The content does not contain malware or malicious code</li>
|
||||||
|
</ul>
|
||||||
|
<p className="mt-4">
|
||||||
|
You grant Battl Builder a worldwide, royalty-free, non-exclusive
|
||||||
|
license to host, display, modify, and distribute your User Content
|
||||||
|
in connection with the Service.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 6. Affiliate & Third-Party Data */}
|
||||||
|
<section>
|
||||||
|
<h2 className="mb-4 text-xl font-semibold">
|
||||||
|
6. Pricing, Affiliates & Third-Party Links
|
||||||
|
</h2>
|
||||||
|
<p>
|
||||||
|
Product pricing, availability, images, and specifications are
|
||||||
|
provided by third parties and may be inaccurate or outdated. Battl
|
||||||
|
Builder does not guarantee the accuracy of such data.
|
||||||
|
</p>
|
||||||
|
<p className="mt-4">
|
||||||
|
We may earn affiliate commissions from qualifying purchases made
|
||||||
|
through links on the Service.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 7. Access & Restrictions */}
|
||||||
|
<section>
|
||||||
|
<h2 className="mb-4 text-xl font-semibold">
|
||||||
|
7. Access, License & Restrictions
|
||||||
|
</h2>
|
||||||
|
<p>
|
||||||
|
Battl Builder grants you a limited, non-exclusive, non-transferable
|
||||||
|
license to access the Service for personal, non-commercial use.
|
||||||
|
</p>
|
||||||
|
<p className="mt-4">You may NOT:</p>
|
||||||
|
<ul className="mt-3 list-disc space-y-2 pl-5">
|
||||||
|
<li>Scrape, crawl, or harvest data from the Service</li>
|
||||||
|
<li>Use automated tools, bots, or data-mining techniques</li>
|
||||||
|
<li>Republish or resell Service data</li>
|
||||||
|
<li>Use the Service for commercial purposes without permission</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 8. Termination */}
|
||||||
|
<section>
|
||||||
|
<h2 className="mb-4 text-xl font-semibold">8. Termination</h2>
|
||||||
|
<p>
|
||||||
|
Battl Builder may suspend or terminate your access to the Service at
|
||||||
|
any time, with or without notice, for any reason.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 9. Limitation of Liability */}
|
||||||
|
<section className="border-t border-white/10 pt-10">
|
||||||
|
<h2 className="mb-4 text-xl font-semibold">
|
||||||
|
9. Limitation of Liability
|
||||||
|
</h2>
|
||||||
|
<p className="uppercase text-xs opacity-70">
|
||||||
|
TO THE MAXIMUM EXTENT PERMITTED BY LAW, BATTL BUILDER SHALL NOT BE
|
||||||
|
LIABLE FOR ANY INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL, OR
|
||||||
|
PUNITIVE DAMAGES, INCLUDING PERSONAL INJURY, LOSS OF DATA, OR LEGAL
|
||||||
|
CONSEQUENCES ARISING FROM USE OF THE SERVICE.
|
||||||
|
</p>
|
||||||
|
<p className="mt-4">
|
||||||
|
OUR TOTAL LIABILITY SHALL NOT EXCEED THE AMOUNT PAID BY YOU TO BATTL
|
||||||
|
BUILDER IN THE TWELVE (12) MONTHS PRECEDING THE CLAIM, OR $100,
|
||||||
|
WHICHEVER IS GREATER.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 10. Indemnification */}
|
||||||
|
<section>
|
||||||
|
<h2 className="mb-4 text-xl font-semibold">10. Indemnification</h2>
|
||||||
|
<p>
|
||||||
|
You agree to indemnify and hold harmless Battl Builder and its
|
||||||
|
operators from any claims, damages, losses, or expenses arising from
|
||||||
|
your use of the Service or violation of these Terms.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 11. Arbitration */}
|
||||||
|
<section>
|
||||||
|
<h2 className="mb-4 text-xl font-semibold">
|
||||||
|
11. Arbitration & Class Action Waiver
|
||||||
|
</h2>
|
||||||
|
<p>
|
||||||
|
Any dispute arising out of or relating to these Terms shall be
|
||||||
|
resolved by binding arbitration on an individual basis. YOU WAIVE
|
||||||
|
THE RIGHT TO PARTICIPATE IN A CLASS ACTION.
|
||||||
|
</p>
|
||||||
|
<p className="mt-4">
|
||||||
|
Any arbitration shall take place in the State of Florida, conducted
|
||||||
|
in the English language, unless otherwise required by applicable
|
||||||
|
law.
|
||||||
|
</p>
|
||||||
|
<p className="mt-4">
|
||||||
|
This provision does not prevent either party from seeking injunctive
|
||||||
|
relief for intellectual property violations.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 12. Misc */}
|
||||||
|
<section>
|
||||||
|
<h2 className="mb-4 text-xl font-semibold">12. Miscellaneous</h2>
|
||||||
|
<p>
|
||||||
|
These Terms constitute the entire agreement between you and Battl
|
||||||
|
Builder. If any provision is found unenforceable, the remaining
|
||||||
|
provisions will remain in effect.
|
||||||
|
</p>
|
||||||
|
<p className="mt-4">
|
||||||
|
These Terms and any dispute arising out of or relating to the
|
||||||
|
Service shall be governed by and construed in accordance with the
|
||||||
|
laws of the State of Florida, without regard to its conflict of law
|
||||||
|
principles.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="pt-10">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="text-sm text-amber-400 hover:text-amber-300 transition-colors"
|
||||||
|
>
|
||||||
|
← Return to Home
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+428
-50
@@ -1,20 +1,123 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { Button, Field, Input } from "@/components/ui/form";
|
import { Button, Field, Input } from "@/components/ui/form";
|
||||||
|
import { useAuth } from "@/context/AuthContext";
|
||||||
|
|
||||||
|
const API_BASE_URL =
|
||||||
|
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
|
||||||
|
|
||||||
|
type AdminBetaRequestDto = {
|
||||||
|
id: number;
|
||||||
|
uuid: string;
|
||||||
|
email: string;
|
||||||
|
displayName?: string | null;
|
||||||
|
invited: boolean;
|
||||||
|
verified: boolean;
|
||||||
|
active: boolean;
|
||||||
|
createdAt?: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PageResponse<T> = {
|
||||||
|
content: T[];
|
||||||
|
number: number;
|
||||||
|
size: number;
|
||||||
|
totalElements: number;
|
||||||
|
totalPages: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Expected invite response shape (adjust fields if your DTO differs)
|
||||||
|
type AdminInviteResponse = {
|
||||||
|
ok?: boolean;
|
||||||
|
email?: string;
|
||||||
|
|
||||||
|
// backend returns this
|
||||||
|
inviteUrl?: string;
|
||||||
|
|
||||||
|
// keep these optional if you ever add them later
|
||||||
|
magicUrl?: string;
|
||||||
|
token?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function fetchJson(
|
||||||
|
path: string,
|
||||||
|
init: RequestInit = {},
|
||||||
|
authHeaders: HeadersInit = {}
|
||||||
|
) {
|
||||||
|
const res = await fetch(`${API_BASE_URL}${path}`, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...(init.headers ?? {}),
|
||||||
|
...authHeaders,
|
||||||
|
},
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = await res.text().catch(() => "");
|
||||||
|
const data = text
|
||||||
|
? (() => {
|
||||||
|
try {
|
||||||
|
return JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const msg =
|
||||||
|
typeof data === "string"
|
||||||
|
? data
|
||||||
|
: data?.message || data?.error || `Request failed (${res.status})`;
|
||||||
|
throw new Error(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Badge({
|
||||||
|
ok,
|
||||||
|
yes = "Yes",
|
||||||
|
no = "No",
|
||||||
|
}: {
|
||||||
|
ok: boolean;
|
||||||
|
yes?: string;
|
||||||
|
no?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={[
|
||||||
|
"inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium",
|
||||||
|
ok
|
||||||
|
? "border border-emerald-500/30 bg-emerald-500/10 text-emerald-200"
|
||||||
|
: "border border-zinc-700 bg-zinc-950/40 text-zinc-400",
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
{ok ? yes : no}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function AdminBetaInvitesPage() {
|
export default function AdminBetaInvitesPage() {
|
||||||
|
const { getAuthHeaders, user } = useAuth();
|
||||||
|
const authHeaders = useMemo(() => getAuthHeaders(), [getAuthHeaders]);
|
||||||
|
|
||||||
|
// ------------------------------
|
||||||
|
// Batch invites
|
||||||
|
// ------------------------------
|
||||||
const [dryRun, setDryRun] = useState(true);
|
const [dryRun, setDryRun] = useState(true);
|
||||||
const [limit, setLimit] = useState("1");
|
const [limit, setLimit] = useState("25");
|
||||||
const [tokenMinutes, setTokenMinutes] = useState("30");
|
const [tokenMinutes, setTokenMinutes] = useState("30");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loadingBatch, setLoadingBatch] = useState(false);
|
||||||
const [result, setResult] = useState<any>(null);
|
const [batchResult, setBatchResult] = useState<any>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [batchError, setBatchError] = useState<string | null>(null);
|
||||||
|
|
||||||
async function runInvites() {
|
async function runInvites() {
|
||||||
setLoading(true);
|
setLoadingBatch(true);
|
||||||
setError(null);
|
setBatchError(null);
|
||||||
setResult(null);
|
setBatchResult(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const qs = new URLSearchParams({
|
const qs = new URLSearchParams({
|
||||||
@@ -23,69 +126,344 @@ export default function AdminBetaInvitesPage() {
|
|||||||
tokenMinutes: String(tokenMinutes || "30"),
|
tokenMinutes: String(tokenMinutes || "30"),
|
||||||
});
|
});
|
||||||
|
|
||||||
const res = await fetch(`/api/admin/beta-invites?${qs.toString()}`, {
|
const data = await fetchJson(
|
||||||
method: "POST",
|
`/api/v1/admin/beta/invites/send?${qs.toString()}`,
|
||||||
});
|
{ method: "POST" },
|
||||||
|
authHeaders
|
||||||
|
);
|
||||||
|
|
||||||
const data = await res.json().catch(() => ({}));
|
setBatchResult(data);
|
||||||
if (!res.ok) throw new Error(data?.message || data?.error || "Request failed");
|
await loadRequests();
|
||||||
|
|
||||||
setResult(data);
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setError(e?.message ?? "Failed");
|
setBatchError(e?.message ?? "Failed");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoadingBatch(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ------------------------------
|
||||||
|
// Requests table
|
||||||
|
// ------------------------------
|
||||||
|
const [page, setPage] = useState(0);
|
||||||
|
const size = 25;
|
||||||
|
|
||||||
|
const [loadingList, setLoadingList] = useState(false);
|
||||||
|
const [listError, setListError] = useState<string | null>(null);
|
||||||
|
const [requests, setRequests] =
|
||||||
|
useState<PageResponse<AdminBetaRequestDto> | null>(null);
|
||||||
|
|
||||||
|
// per-row loading state
|
||||||
|
const [rowBusy, setRowBusy] = useState<Record<number, boolean>>({});
|
||||||
|
|
||||||
|
// last invite response (for visibility / debug)
|
||||||
|
const [lastInvite, setLastInvite] = useState<AdminInviteResponse | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
async function loadRequests() {
|
||||||
|
setLoadingList(true);
|
||||||
|
setListError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = (await fetchJson(
|
||||||
|
`/api/v1/admin/beta/requests?page=${page}&size=${size}`,
|
||||||
|
{ method: "GET" },
|
||||||
|
authHeaders
|
||||||
|
)) as PageResponse<AdminBetaRequestDto>;
|
||||||
|
|
||||||
|
setRequests(data);
|
||||||
|
} catch (e: any) {
|
||||||
|
setListError(e?.message ?? "Failed to load beta requests");
|
||||||
|
} finally {
|
||||||
|
setLoadingList(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function inviteSingle(userId: number): Promise<AdminInviteResponse> {
|
||||||
|
// calls backend: POST /api/v1/admin/beta/requests/{userId}/invite
|
||||||
|
return (await fetchJson(
|
||||||
|
`/api/v1/admin/beta/requests/${userId}/invite`,
|
||||||
|
{ method: "POST" },
|
||||||
|
authHeaders
|
||||||
|
)) as AdminInviteResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSendInviteEmail(userId: number) {
|
||||||
|
setListError(null);
|
||||||
|
setLastInvite(null);
|
||||||
|
setRowBusy((m) => ({ ...m, [userId]: true }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await inviteSingle(userId);
|
||||||
|
setLastInvite(resp);
|
||||||
|
await loadRequests();
|
||||||
|
} catch (e: any) {
|
||||||
|
setListError(e?.message ?? "Failed to send invite");
|
||||||
|
} finally {
|
||||||
|
setRowBusy((m) => ({ ...m, [userId]: false }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onCopyInviteLink(userId: number) {
|
||||||
|
setListError(null);
|
||||||
|
setLastInvite(null);
|
||||||
|
setRowBusy((m) => ({ ...m, [userId]: true }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await inviteSingle(userId);
|
||||||
|
setLastInvite(resp);
|
||||||
|
await loadRequests();
|
||||||
|
|
||||||
|
// build URL
|
||||||
|
const url =
|
||||||
|
resp.inviteUrl || // what your backend returns
|
||||||
|
resp.magicUrl ||
|
||||||
|
(resp.token
|
||||||
|
? `${window.location.origin}/beta/magic?token=${resp.token}`
|
||||||
|
: null);
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
throw new Error(
|
||||||
|
`Invite response missing inviteUrl. Backend should return {"inviteUrl": "..."}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await navigator.clipboard.writeText(url);
|
||||||
|
} catch (e: any) {
|
||||||
|
setListError(e?.message ?? "Failed to copy invite link");
|
||||||
|
} finally {
|
||||||
|
setRowBusy((m) => ({ ...m, [userId]: false }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadRequests();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [page]);
|
||||||
|
|
||||||
|
const isAdmin = (user?.role ?? "").toUpperCase() === "ADMIN";
|
||||||
|
|
||||||
|
if (!isAdmin) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h1 className="text-xl font-semibold">Admin</h1>
|
||||||
|
<p className="text-sm text-zinc-400">
|
||||||
|
You don’t have access to this page.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-semibold">Send Beta Invites</h1>
|
<h1 className="text-xl font-semibold">Beta Access</h1>
|
||||||
<p className="mt-1 text-sm text-zinc-400">
|
<p className="mt-1 text-sm text-zinc-400">
|
||||||
Runs the backend batch invite sender (uses your email templates + tracking).
|
Review pending beta requests and send invite links (batch or
|
||||||
|
per-user).
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-lg border border-zinc-900 bg-zinc-950 p-4 space-y-4">
|
<div className="grid gap-6 lg:grid-cols-[280px_minmax(0,1fr)] xl:grid-cols-[320px_minmax(0,1fr)]">
|
||||||
<label className="flex items-center gap-2 text-sm text-zinc-200">
|
{/* Left: Batch Invites */}
|
||||||
<input
|
<div className="rounded-lg border border-zinc-900 bg-zinc-950 p-4 space-y-4">
|
||||||
type="checkbox"
|
<div className="flex items-center justify-between gap-3">
|
||||||
checked={dryRun}
|
<div>
|
||||||
onChange={(e) => setDryRun(e.target.checked)}
|
<h2 className="text-sm font-semibold uppercase tracking-[0.18em] text-zinc-500">
|
||||||
/>
|
Batch Invites
|
||||||
Dry run (do everything except actually send)
|
</h2>
|
||||||
</label>
|
<p className="mt-1 text-sm text-zinc-400">
|
||||||
|
Runs the backend batch invite sender.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-3">
|
<span className="shrink-0 rounded-full border border-amber-500/30 bg-amber-500/10 px-3 py-1 text-xs text-amber-200">
|
||||||
<Field label="Limit" htmlFor="limit">
|
{dryRun ? "Dry run" : "Live send"}
|
||||||
<Input id="limit" value={limit} onChange={(e) => setLimit(e.target.value)} />
|
</span>
|
||||||
</Field>
|
</div>
|
||||||
|
|
||||||
<Field label="Token minutes" htmlFor="tokenMinutes">
|
<label className="flex items-center gap-2 text-sm text-zinc-200">
|
||||||
<Input
|
<input
|
||||||
id="tokenMinutes"
|
type="checkbox"
|
||||||
value={tokenMinutes}
|
checked={dryRun}
|
||||||
onChange={(e) => setTokenMinutes(e.target.value)}
|
onChange={(e) => setDryRun(e.target.checked)}
|
||||||
/>
|
/>
|
||||||
</Field>
|
Dry run (do everything except actually send)
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<Field label="Limit" htmlFor="limit">
|
||||||
|
<Input
|
||||||
|
id="limit"
|
||||||
|
value={limit}
|
||||||
|
onChange={(e) => setLimit(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Token minutes" htmlFor="tokenMinutes">
|
||||||
|
<Input
|
||||||
|
id="tokenMinutes"
|
||||||
|
value={tokenMinutes}
|
||||||
|
onChange={(e) => setTokenMinutes(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button onClick={runInvites} disabled={loadingBatch}>
|
||||||
|
{loadingBatch ? "Running…" : "Send Beta Invites"}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{batchError && (
|
||||||
|
<div className="rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2 text-sm text-red-200">
|
||||||
|
{batchError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{batchResult && (
|
||||||
|
<pre className="overflow-auto rounded-md border border-zinc-800 bg-black/40 p-3 text-xs text-zinc-200">
|
||||||
|
{JSON.stringify(batchResult, null, 2)}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button onClick={runInvites} disabled={loading}>
|
{/* Right: Requests List */}
|
||||||
{loading ? "Running…" : "Send Beta Invites"}
|
<div className="rounded-lg border border-zinc-900 bg-zinc-950 p-4 space-y-4">
|
||||||
</Button>
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold uppercase tracking-[0.18em] text-zinc-500">
|
||||||
|
Beta Requests
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-sm text-zinc-400">
|
||||||
|
Users with role <span className="text-zinc-200">BETA</span> and{" "}
|
||||||
|
<span className="text-zinc-200">is_active=false</span>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{error && (
|
<Button
|
||||||
<div className="rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2 text-sm text-red-200">
|
onClick={loadRequests}
|
||||||
{error}
|
disabled={loadingList}
|
||||||
|
className="w-auto px-6"
|
||||||
|
>
|
||||||
|
{loadingList ? "Refreshing…" : "Refresh"}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{result && (
|
{listError && (
|
||||||
<pre className="overflow-auto rounded-md border border-zinc-800 bg-black/40 p-3 text-xs text-zinc-200">
|
<div className="rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2 text-sm text-red-200">
|
||||||
{JSON.stringify(result, null, 2)}
|
{listError}
|
||||||
</pre>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className="overflow-x-auto rounded-md border border-zinc-800">
|
||||||
|
{" "}
|
||||||
|
<table className="min-w-[680px] w-full text-left text-sm">
|
||||||
|
<thead className="bg-black/30 text-xs text-zinc-500">
|
||||||
|
<tr className="border-b border-zinc-800">
|
||||||
|
<th className="px-3 py-2">Email</th>
|
||||||
|
<th className="px-3 py-2">Invited</th>
|
||||||
|
<th className="px-3 py-2">Verified</th>
|
||||||
|
<th className="px-3 py-2">Active</th>
|
||||||
|
<th className="px-3 py-2 text-right w-[190px] whitespace-nowrap">
|
||||||
|
Actions
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
<tbody className="text-zinc-200">
|
||||||
|
{requests?.content?.length ? (
|
||||||
|
requests.content.map((u) => {
|
||||||
|
const busy = !!rowBusy[u.id];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={u.id} className="border-b border-zinc-900">
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<div className="font-medium">{u.email}</div>
|
||||||
|
<div className="text-xs text-zinc-500">
|
||||||
|
{u.displayName ?? "—"} • #{u.id}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<Badge ok={u.invited} yes="Invited" no="—" />
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<Badge ok={u.verified} yes="Verified" no="—" />
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<Badge ok={u.active} yes="Active" no="Inactive" />
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="px-3 py-2 w-[190px]">
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => onCopyInviteLink(u.id)}
|
||||||
|
disabled={busy}
|
||||||
|
className="rounded-md border border-zinc-700 bg-zinc-950 px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-900 disabled:opacity-60"
|
||||||
|
title="Dev helper: generate invite and copy URL (also triggers invite generation server-side)"
|
||||||
|
>
|
||||||
|
{busy ? "Working…" : "Copy link (dev)"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => onSendInviteEmail(u.id)}
|
||||||
|
disabled={busy}
|
||||||
|
className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-1.5 text-xs text-amber-200 hover:bg-amber-500/15 disabled:opacity-60"
|
||||||
|
title="Send invite email to this user"
|
||||||
|
>
|
||||||
|
{busy ? "Working…" : "Send invite email"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<tr>
|
||||||
|
<td className="px-3 py-6 text-sm text-zinc-400" colSpan={5}>
|
||||||
|
{loadingList ? "Loading…" : "No pending beta requests."}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* pager */}
|
||||||
|
<div className="flex items-center justify-between text-xs text-zinc-500">
|
||||||
|
<button
|
||||||
|
className="rounded-md border border-zinc-800 bg-black/30 px-2 py-1 disabled:opacity-50"
|
||||||
|
disabled={!requests || requests.number <= 0 || loadingList}
|
||||||
|
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||||
|
>
|
||||||
|
Prev
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span>
|
||||||
|
Page {requests ? requests.number + 1 : 1} of{" "}
|
||||||
|
{requests ? requests.totalPages : 1} •{" "}
|
||||||
|
{requests ? requests.totalElements : 0} total
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="rounded-md border border-zinc-800 bg-black/30 px-2 py-1 disabled:opacity-50"
|
||||||
|
disabled={
|
||||||
|
!requests ||
|
||||||
|
loadingList ||
|
||||||
|
requests.number >= requests.totalPages - 1
|
||||||
|
}
|
||||||
|
onClick={() => setPage((p) => p + 1)}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* optional debug panel */}
|
||||||
|
{lastInvite && (
|
||||||
|
<pre className="overflow-auto rounded-md border border-zinc-800 bg-black/40 p-3 text-xs text-zinc-200">
|
||||||
|
{JSON.stringify(lastInvite, null, 2)}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,22 +1,43 @@
|
|||||||
|
// app/api/beta-signup/route.ts
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
const API_BASE_URL =
|
const API_BASE_URL =
|
||||||
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
|
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
|
||||||
|
|
||||||
|
const TOS_VERSION = "2025-12-27";
|
||||||
|
|
||||||
|
type BetaSignupBody = {
|
||||||
|
email?: string;
|
||||||
|
useCase?: string;
|
||||||
|
acceptedTos?: boolean;
|
||||||
|
tosVersion?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
try {
|
try {
|
||||||
const body = await req.json();
|
const body = (await req.json()) as BetaSignupBody;
|
||||||
|
|
||||||
|
const email = (body.email ?? "").trim().toLowerCase();
|
||||||
|
const useCase = (body.useCase ?? "").trim();
|
||||||
|
const acceptedTos = Boolean(body.acceptedTos);
|
||||||
|
const tosVersion = (body.tosVersion ?? TOS_VERSION).trim() || TOS_VERSION;
|
||||||
|
|
||||||
|
// Keep "no enumeration": always return ok:true, but silently refuse bad input
|
||||||
|
if (!email || !acceptedTos) {
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forward only known fields
|
||||||
|
const payload = { email, useCase, acceptedTos, tosVersion };
|
||||||
|
|
||||||
const res = await fetch(`${API_BASE_URL}/api/auth/beta/signup`, {
|
const res = await fetch(`${API_BASE_URL}/api/auth/beta/signup`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(payload),
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Always return ok=true (matches your server behavior)
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
// Optional: log server response for debugging
|
|
||||||
const text = await res.text().catch(() => "");
|
const text = await res.text().catch(() => "");
|
||||||
console.error("beta-signup proxy failed:", res.status, text);
|
console.error("beta-signup proxy failed:", res.status, text);
|
||||||
}
|
}
|
||||||
@@ -24,6 +45,6 @@ export async function POST(req: Request) {
|
|||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("beta-signup proxy error:", e);
|
console.error("beta-signup proxy error:", e);
|
||||||
return NextResponse.json({ ok: true }); // keep “no enumeration”
|
return NextResponse.json({ ok: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
const API_BASE_URL =
|
||||||
|
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const body = await req.text().catch(() => "");
|
||||||
|
const auth = req.headers.get("authorization") ?? "";
|
||||||
|
|
||||||
|
const upstream = await fetch(`${API_BASE_URL}/api/v1/users/me/password`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"content-type": req.headers.get("content-type") ?? "application/json",
|
||||||
|
...(auth ? { authorization: auth } : {}),
|
||||||
|
},
|
||||||
|
body,
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = await upstream.text().catch(() => "");
|
||||||
|
return new NextResponse(text, {
|
||||||
|
status: upstream.status,
|
||||||
|
headers: { "content-type": upstream.headers.get("content-type") ?? "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
const API_BASE_URL =
|
||||||
|
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
|
||||||
|
|
||||||
|
function passthroughHeaders(req: Request) {
|
||||||
|
const h = new Headers();
|
||||||
|
const auth = req.headers.get("authorization");
|
||||||
|
if (auth) h.set("authorization", auth);
|
||||||
|
h.set("content-type", req.headers.get("content-type") ?? "application/json");
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(req: Request) {
|
||||||
|
const upstream = await fetch(`${API_BASE_URL}/api/v1/users/me`, {
|
||||||
|
method: "GET",
|
||||||
|
headers: passthroughHeaders(req),
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = await upstream.text().catch(() => "");
|
||||||
|
return new NextResponse(text, {
|
||||||
|
status: upstream.status,
|
||||||
|
headers: { "content-type": upstream.headers.get("content-type") ?? "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(req: Request) {
|
||||||
|
const body = await req.text().catch(() => "");
|
||||||
|
|
||||||
|
const upstream = await fetch(`${API_BASE_URL}/api/v1/users/me`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: passthroughHeaders(req),
|
||||||
|
body,
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = await upstream.text().catch(() => "");
|
||||||
|
return new NextResponse(text, {
|
||||||
|
status: upstream.status,
|
||||||
|
headers: { "content-type": upstream.headers.get("content-type") ?? "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
const API_BASE_URL =
|
||||||
|
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
|
||||||
|
|
||||||
|
export async function GET(req: Request) {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const username = url.searchParams.get("username") ?? "";
|
||||||
|
const auth = req.headers.get("authorization") ?? "";
|
||||||
|
|
||||||
|
const upstream = await fetch(
|
||||||
|
`${API_BASE_URL}/api/v1/users/me/username-available?username=${encodeURIComponent(username)}`,
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
...(auth ? { authorization: auth } : {}),
|
||||||
|
},
|
||||||
|
cache: "no-store",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const text = await upstream.text().catch(() => "");
|
||||||
|
return new NextResponse(text, {
|
||||||
|
status: upstream.status,
|
||||||
|
headers: { "content-type": upstream.headers.get("content-type") ?? "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -44,13 +44,21 @@ export default function BetaConfirmPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
const jwt = data.token ?? data.accessToken;
|
const jwt = data.token ?? data.accessToken;
|
||||||
if (!jwt) throw new Error("No token returned from server");
|
if (!jwt) throw new Error("No token returned from server");
|
||||||
|
|
||||||
|
const uuid = data.uuid ?? data.user?.uuid;
|
||||||
|
if (!uuid) throw new Error("No uuid returned from server");
|
||||||
|
|
||||||
|
const email = data.email ?? data.user?.email;
|
||||||
|
if (!email) throw new Error("No email returned from server");
|
||||||
|
|
||||||
setSession(jwt, {
|
setSession(jwt, {
|
||||||
email: data.email,
|
uuid,
|
||||||
displayName: data.displayName ?? null,
|
email,
|
||||||
role: data.role ?? "USER",
|
displayName: data.displayName ?? data.user?.displayName ?? null,
|
||||||
|
role: data.role ?? data.user?.role ?? "USER",
|
||||||
});
|
});
|
||||||
|
|
||||||
setStatus("success");
|
setStatus("success");
|
||||||
|
|||||||
+51
-3
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { FormEvent, useState } from "react";
|
import { FormEvent, useState } from "react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
@@ -9,7 +10,10 @@ export default function HomePage() {
|
|||||||
const [status, setStatus] = useState<
|
const [status, setStatus] = useState<
|
||||||
"idle" | "loading" | "success" | "error"
|
"idle" | "loading" | "success" | "error"
|
||||||
>("idle");
|
>("idle");
|
||||||
|
|
||||||
const [message, setMessage] = useState<string | null>(null);
|
const [message, setMessage] = useState<string | null>(null);
|
||||||
|
const [accepted, setAccepted] = useState(false);
|
||||||
|
const TOS_VERSION = "2025-12-27";
|
||||||
|
|
||||||
async function handleSubmit(e: FormEvent) {
|
async function handleSubmit(e: FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -20,6 +24,14 @@ export default function HomePage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!accepted) {
|
||||||
|
setMessage(
|
||||||
|
"You must accept the Terms and confirm you’re responsible for safe/legal assembly."
|
||||||
|
);
|
||||||
|
setStatus("error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setStatus("loading");
|
setStatus("loading");
|
||||||
setMessage(null);
|
setMessage(null);
|
||||||
@@ -27,7 +39,7 @@ export default function HomePage() {
|
|||||||
const res = await fetch("/api/beta-signup", {
|
const res = await fetch("/api/beta-signup", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ email, useCase }),
|
body: JSON.stringify({ email, useCase, acceptedTos: accepted, tosVersion: TOS_VERSION }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -36,7 +48,9 @@ export default function HomePage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setStatus("success");
|
setStatus("success");
|
||||||
setMessage("✅ You’re on the list. Invites drop soon — we’ll email your access link when it’s go-time.");
|
setMessage(
|
||||||
|
"✅ You’re on the list. Invites drop soon — we’ll email your access link when it’s go-time."
|
||||||
|
);
|
||||||
setEmail("");
|
setEmail("");
|
||||||
setUseCase("");
|
setUseCase("");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -187,9 +201,43 @@ export default function HomePage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-md border border-zinc-800 bg-black/30 p-3">
|
||||||
|
<label className="flex items-start gap-2 text-[11px] text-zinc-400">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={accepted}
|
||||||
|
onChange={(e) => setAccepted(e.target.checked)}
|
||||||
|
disabled={status === "loading" || status === "success"}
|
||||||
|
className="mt-0.5 h-4 w-4 rounded border-zinc-700 bg-zinc-950 text-amber-400 focus:ring-2 focus:ring-amber-500/40"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
I agree to the{" "}
|
||||||
|
<Link
|
||||||
|
href="/tos"
|
||||||
|
className="text-amber-300 hover:text-amber-200 underline"
|
||||||
|
>
|
||||||
|
Terms of Service
|
||||||
|
</Link>{" "}
|
||||||
|
and{" "}
|
||||||
|
<Link
|
||||||
|
href="/privacy"
|
||||||
|
className="text-amber-300 hover:text-amber-200 underline"
|
||||||
|
>
|
||||||
|
Privacy Policy
|
||||||
|
</Link>
|
||||||
|
. I understand Battl Builders is informational only and I’m
|
||||||
|
solely responsible for legality, compatibility, and safe
|
||||||
|
assembly/use of any firearm or components.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={status === "loading" || status === "success"}
|
disabled={
|
||||||
|
status === "loading" || status === "success" || !accepted
|
||||||
|
}
|
||||||
className="flex w-full items-center justify-center rounded-md border border-amber-500/70 bg-amber-500/90 px-3 py-2 text-sm font-medium text-black transition hover:bg-amber-400 disabled:cursor-not-allowed disabled:opacity-70"
|
className="flex w-full items-center justify-center rounded-md border border-amber-500/70 bg-amber-500/90 px-3 py-2 text-sm font-medium text-black transition hover:bg-amber-400 disabled:cursor-not-allowed disabled:opacity-70"
|
||||||
>
|
>
|
||||||
{status === "loading"
|
{status === "loading"
|
||||||
|
|||||||
+111
-59
@@ -7,6 +7,8 @@ import Link from "next/link";
|
|||||||
import { useAuth } from "@/context/AuthContext";
|
import { useAuth } from "@/context/AuthContext";
|
||||||
import { Button, Field, Input } from "@/components/ui/form";
|
import { Button, Field, Input } from "@/components/ui/form";
|
||||||
|
|
||||||
|
const TOS_VERSION = "2025-12-27";
|
||||||
|
|
||||||
export default function RegisterPage() {
|
export default function RegisterPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
@@ -15,6 +17,7 @@ export default function RegisterPage() {
|
|||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [displayName, setDisplayName] = useState("");
|
const [displayName, setDisplayName] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
|
const [accepted, setAccepted] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const next = searchParams.get("next") || "/builder";
|
const next = searchParams.get("next") || "/builder";
|
||||||
@@ -23,76 +26,125 @@ export default function RegisterPage() {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
|
if (!accepted) {
|
||||||
|
setError(
|
||||||
|
"You must accept the Terms and confirm you’re responsible for safe/legal assembly."
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await register({ email, password, displayName });
|
await register({
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
displayName,
|
||||||
|
acceptedTos: accepted,
|
||||||
|
tosVersion: TOS_VERSION,
|
||||||
|
});
|
||||||
|
|
||||||
router.push(next);
|
router.push(next);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message ?? "Failed to create account");
|
setError(
|
||||||
|
typeof err === "string"
|
||||||
|
? err
|
||||||
|
: err?.message ?? "Failed to create account"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen bg-black text-zinc-50">
|
<main className="min-h-screen bg-black text-zinc-50">
|
||||||
<div className="mx-auto flex max-w-md flex-col px-4 py-10">
|
<div className="mx-auto flex max-w-md flex-col px-4 py-10">
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">
|
<h1 className="text-2xl font-semibold tracking-tight">
|
||||||
Join the <span className="text-amber-300">Battl Builder</span> Beta
|
Join the <span className="text-amber-300">Battl Builder</span> Beta
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-2 text-sm text-zinc-400">
|
<p className="mt-2 text-sm text-zinc-400">
|
||||||
Create an account so we can save your builds, watch price drops, and
|
Create an account so we can save your builds, watch price drops, and
|
||||||
roll out new features to you first.
|
roll out new features to you first.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="mt-6 space-y-4">
|
<form onSubmit={handleSubmit} className="mt-6 space-y-4">
|
||||||
{error && (
|
{error && (
|
||||||
<div className="rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2 text-sm text-red-200">
|
<div className="rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2 text-sm text-red-200">
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Field label="Display Name (optional)" htmlFor="displayName">
|
<Field label="Display Name (optional)" htmlFor="displayName">
|
||||||
<Input
|
<Input
|
||||||
id="displayName"
|
id="displayName"
|
||||||
type="text"
|
type="text"
|
||||||
value={displayName}
|
value={displayName}
|
||||||
onChange={(e) => setDisplayName(e.target.value)}
|
onChange={(e) => setDisplayName(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Email" htmlFor="email">
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
required
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Password" htmlFor="password">
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
required
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<div className="rounded-md border border-zinc-800 bg-zinc-950/40 px-3 py-3">
|
||||||
|
<label className="flex items-start gap-2 text-[11px] text-zinc-400">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={accepted}
|
||||||
|
onChange={(e) => setAccepted(e.target.checked)}
|
||||||
|
className="mt-0.5 h-4 w-4 rounded border-zinc-700 bg-zinc-950 text-amber-400 focus:ring-2 focus:ring-amber-500/40"
|
||||||
|
required
|
||||||
/>
|
/>
|
||||||
</Field>
|
<span>
|
||||||
|
I agree to the{" "}
|
||||||
|
<Link
|
||||||
|
href="/tos"
|
||||||
|
className="text-amber-300 hover:text-amber-200 underline"
|
||||||
|
>
|
||||||
|
Terms of Service
|
||||||
|
</Link>{" "}
|
||||||
|
and{" "}
|
||||||
|
<Link
|
||||||
|
href="/privacy"
|
||||||
|
className="text-amber-300 hover:text-amber-200 underline"
|
||||||
|
>
|
||||||
|
Privacy Policy
|
||||||
|
</Link>
|
||||||
|
. I understand Battl Builders is informational only and I’m
|
||||||
|
solely responsible for legality, compatibility, and safe
|
||||||
|
assembly/use of any firearm or components.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Field label="Email" htmlFor="email">
|
<Button type="submit" disabled={loading || !accepted}>
|
||||||
<Input
|
{loading ? "Creating account…" : "Join Beta"}
|
||||||
id="email"
|
</Button>
|
||||||
type="email"
|
</form>
|
||||||
autoComplete="email"
|
|
||||||
required
|
|
||||||
value={email}
|
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field label="Password" htmlFor="password">
|
<p className="mt-4 text-xs text-zinc-500">
|
||||||
<Input
|
Already have an account?{" "}
|
||||||
id="password"
|
<Link href="/login" className="text-amber-300 hover:text-amber-200">
|
||||||
type="password"
|
Log in
|
||||||
autoComplete="new-password"
|
</Link>
|
||||||
required
|
.
|
||||||
value={password}
|
</p>
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
</div>
|
||||||
/>
|
</main>
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Button type="submit" disabled={loading}>
|
|
||||||
{loading ? "Creating account…" : "Join Beta"}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<p className="mt-4 text-xs text-zinc-500">
|
|
||||||
Already have an account?{" "}
|
|
||||||
<Link href="/login" className="text-amber-300 hover:text-amber-200">
|
|
||||||
Log in
|
|
||||||
</Link>
|
|
||||||
.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export function Footer() {
|
||||||
|
const year = new Date().getFullYear();
|
||||||
|
|
||||||
|
const linkClass =
|
||||||
|
"text-zinc-400 hover:text-amber-300 transition-colors";
|
||||||
|
|
||||||
|
const sectionTitle =
|
||||||
|
"text-[11px] font-semibold uppercase tracking-[0.28em] text-zinc-500";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<footer className="border-t border-zinc-900 bg-black/60">
|
||||||
|
<div className="mx-auto max-w-6xl px-4 py-12">
|
||||||
|
<div className="grid gap-10 md:grid-cols-3">
|
||||||
|
{/* Brand */}
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-[0.35em] text-zinc-500">
|
||||||
|
Battl Builders
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className="mt-3 text-sm text-zinc-300">
|
||||||
|
Build rifles smarter, not harder.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className="mt-2 text-[11px] text-zinc-500">
|
||||||
|
Early access platform · Data & pricing may change
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Explore */}
|
||||||
|
<div>
|
||||||
|
<p className={sectionTitle}>Explore</p>
|
||||||
|
<ul className="mt-4 space-y-2 text-sm">
|
||||||
|
<li>
|
||||||
|
<Link href="/builder" className={linkClass}>
|
||||||
|
Builder
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link href="/builds" className={linkClass}>
|
||||||
|
Community Builds
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link href="/vault" className={linkClass}>
|
||||||
|
My Builds
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Platform */}
|
||||||
|
<div>
|
||||||
|
<p className={sectionTitle}>Platform</p>
|
||||||
|
<ul className="mt-4 space-y-2 text-sm">
|
||||||
|
<li>
|
||||||
|
<Link href="/about" className={linkClass}>
|
||||||
|
About
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link href="/privacy" className={linkClass}>
|
||||||
|
Privacy Policy
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link href="/tos" className={linkClass}>
|
||||||
|
Terms of Service
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
{/* Optional, if you have it */}
|
||||||
|
{/* <li>
|
||||||
|
<Link href="/contact" className={linkClass}>
|
||||||
|
Contact
|
||||||
|
</Link>
|
||||||
|
</li> */}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom bar */}
|
||||||
|
<div className="mt-10 flex flex-col gap-3 border-t border-zinc-900 pt-6 text-[11px] text-zinc-500 md:flex-row md:items-center md:justify-between">
|
||||||
|
<span>© {year} Battl Builders</span>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-center gap-2 md:flex-row md:gap-4">
|
||||||
|
<span className="text-zinc-600">
|
||||||
|
Built by people who actually build rifles
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className="hidden text-zinc-700 md:inline">•</span>
|
||||||
|
|
||||||
|
<span className="text-zinc-600">
|
||||||
|
<Link href="/tos" className="hover:text-amber-300 transition-colors">
|
||||||
|
Terms
|
||||||
|
</Link>{" "}
|
||||||
|
·{" "}
|
||||||
|
<Link href="/privacy" className="hover:text-amber-300 transition-colors">
|
||||||
|
Privacy
|
||||||
|
</Link>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
+70
-28
@@ -15,28 +15,32 @@ const API_BASE_URL =
|
|||||||
type AuthUser = {
|
type AuthUser = {
|
||||||
uuid: string;
|
uuid: string;
|
||||||
email: string;
|
email: string;
|
||||||
|
username?: string | null;
|
||||||
|
passwordSetAt?: string | null;
|
||||||
displayName?: string | null;
|
displayName?: string | null;
|
||||||
role: string;
|
role: string;
|
||||||
} | null;
|
} | null;
|
||||||
|
|
||||||
|
export type RegisterParams = {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
displayName?: string;
|
||||||
|
acceptedTos: boolean;
|
||||||
|
tosVersion: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1) update the type
|
||||||
type AuthContextValue = {
|
type AuthContextValue = {
|
||||||
user: AuthUser;
|
user: AuthUser;
|
||||||
token: string | null;
|
token: string | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
login: (params: { email: string; password: string }) => Promise<void>;
|
login: (params: { email: string; password: string }) => Promise<void>;
|
||||||
register: (params: {
|
register: (params: RegisterParams) => Promise<void>;
|
||||||
email: string;
|
|
||||||
password: string;
|
|
||||||
displayName?: string;
|
|
||||||
}) => Promise<void>;
|
|
||||||
logout: () => void;
|
logout: () => void;
|
||||||
getAuthHeaders: () => HeadersInit;
|
getAuthHeaders: () => HeadersInit;
|
||||||
|
setSession: (token: string, user: NonNullable<AuthUser>) => Promise<void>;
|
||||||
|
refreshMeAndSession: () => Promise<void>;
|
||||||
|
|
||||||
/**
|
|
||||||
* Used for non-password auth flows (ex: magic link).
|
|
||||||
* Persists session exactly like login/register.
|
|
||||||
*/
|
|
||||||
setSession: (token: string, user: NonNullable<AuthUser>) => void;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
|
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
|
||||||
@@ -110,24 +114,55 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
* Used by login/register/magic-link
|
* Used by login/register/magic-link
|
||||||
*/
|
*/
|
||||||
const setSession = useCallback(
|
const setSession = useCallback(
|
||||||
(nextToken: string, nextUser: NonNullable<AuthUser>) => {
|
async (nextToken: string, nextUser: NonNullable<AuthUser>) => {
|
||||||
setToken(nextToken);
|
setToken(nextToken);
|
||||||
setUser(nextUser);
|
setUser(nextUser);
|
||||||
persistAuth(nextToken, nextUser);
|
persistAuth(nextToken, nextUser);
|
||||||
|
|
||||||
// ✅ Make middleware / server aware of the session
|
// ✅ await so middleware sees cookie before navigation
|
||||||
fetch("/api/auth/session", {
|
try {
|
||||||
method: "POST",
|
await fetch("/api/auth/session", {
|
||||||
headers: { "Content-Type": "application/json" },
|
method: "POST",
|
||||||
body: JSON.stringify({
|
headers: { "Content-Type": "application/json" },
|
||||||
token: nextToken,
|
body: JSON.stringify({
|
||||||
role: nextUser.role,
|
token: nextToken,
|
||||||
}),
|
role: nextUser.role,
|
||||||
}).catch(() => {});
|
}),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[persistAuth]
|
[persistAuth]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const refreshMeAndSession = useCallback(async () => {
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/users/me`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => res.statusText);
|
||||||
|
throw new Error(text || "Failed to refresh session");
|
||||||
|
}
|
||||||
|
|
||||||
|
const me = await res.json();
|
||||||
|
|
||||||
|
const nextUser = {
|
||||||
|
uuid: me.uuid,
|
||||||
|
email: me.email,
|
||||||
|
username: me.username || null,
|
||||||
|
displayName: me.displayName || null,
|
||||||
|
role: me.role || "USER",
|
||||||
|
passwordSetAt: me.passwordSetAt || null,
|
||||||
|
} as NonNullable<AuthUser>;
|
||||||
|
|
||||||
|
await setSession(token, nextUser);
|
||||||
|
}, [token, setSession]);
|
||||||
|
|
||||||
const login = useCallback(
|
const login = useCallback(
|
||||||
async ({ email, password }: { email: string; password: string }) => {
|
async ({ email, password }: { email: string; password: string }) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -152,10 +187,11 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
uuid: data.uuid,
|
uuid: data.uuid,
|
||||||
email: data.email ?? email,
|
email: data.email ?? email,
|
||||||
displayName: data.displayName ?? null,
|
displayName: data.displayName ?? null,
|
||||||
|
passwordSetAt: data.passwordSetAt ?? null,
|
||||||
role: data.role ?? "USER",
|
role: data.role ?? "USER",
|
||||||
} as AuthUser);
|
} as AuthUser);
|
||||||
|
|
||||||
setSession(nextToken, nextUser as NonNullable<AuthUser>);
|
await setSession(nextToken, nextUser as NonNullable<AuthUser>);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -168,17 +204,21 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
displayName,
|
displayName,
|
||||||
}: {
|
acceptedTos,
|
||||||
email: string;
|
tosVersion,
|
||||||
password: string;
|
}: RegisterParams) => {
|
||||||
displayName?: string;
|
|
||||||
}) => {
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_BASE_URL}/api/auth/register`, {
|
const res = await fetch(`${API_BASE_URL}/api/auth/register`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ email, password, displayName }),
|
body: JSON.stringify({
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
displayName,
|
||||||
|
acceptedTos,
|
||||||
|
tosVersion,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -195,10 +235,11 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
uuid: data.uuid,
|
uuid: data.uuid,
|
||||||
email: data.email ?? email,
|
email: data.email ?? email,
|
||||||
displayName: data.displayName ?? displayName ?? null,
|
displayName: data.displayName ?? displayName ?? null,
|
||||||
|
passwordSetAt: data.passwordSetAt ?? null,
|
||||||
role: data.role ?? "USER",
|
role: data.role ?? "USER",
|
||||||
} as AuthUser);
|
} as AuthUser);
|
||||||
|
|
||||||
setSession(nextToken, nextUser as NonNullable<AuthUser>);
|
await setSession(nextToken, nextUser as NonNullable<AuthUser>);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -228,6 +269,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
logout,
|
logout,
|
||||||
getAuthHeaders,
|
getAuthHeaders,
|
||||||
setSession,
|
setSession,
|
||||||
|
refreshMeAndSession
|
||||||
};
|
};
|
||||||
|
|
||||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||||
|
|||||||
+11
-2
@@ -1,8 +1,17 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
experimental: {
|
experimental: {
|
||||||
appDir: true
|
appDir: true,
|
||||||
}
|
},
|
||||||
|
|
||||||
|
async rewrites() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: "/api/:path*",
|
||||||
|
destination: "http://localhost:8080/api/:path*",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
Reference in New Issue
Block a user