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
|
||||
"use client";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
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>
|
||||
);
|
||||
}
|
||||
export default function AccountIndex() {
|
||||
redirect("/account/profile");
|
||||
}
|
||||
@@ -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 AccountChrome from "./AccountChrome";
|
||||
|
||||
const nav = [
|
||||
{ href: "/account", label: "My Account" },
|
||||
{ href: "/account/settings", label: "Settings" },
|
||||
{ href: "/vault", label: "My Vault" },
|
||||
{ name: "Profile", href: "/account/profile" },
|
||||
{ name: "Security", href: "/account/security" },
|
||||
{ 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 (
|
||||
<div className="min-h-screen">
|
||||
<AccountChrome />
|
||||
|
||||
<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 className="min-h-screen bg-black text-zinc-50">
|
||||
<div className="mx-auto max-w-6xl px-4 py-10">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-[11px] uppercase tracking-[0.22em] text-white/50">
|
||||
<div className="text-xs tracking-[0.25em] uppercase text-zinc-400">
|
||||
Account
|
||||
</div>
|
||||
<h1 className="mt-1 text-2xl font-semibold text-white/90">
|
||||
Settings & Profile
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-white/60">
|
||||
<h1 className="mt-2 text-3xl font-semibold">Settings & Profile</h1>
|
||||
<p className="mt-2 text-sm text-zinc-400">
|
||||
Profile, password, and account settings.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
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
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-12">
|
||||
<aside className="lg:col-span-3 rounded-2xl border border-white/10 bg-white/5 p-4">
|
||||
<nav className="flex flex-col gap-1">
|
||||
<div className="mt-10 grid grid-cols-1 gap-8 lg:grid-cols-12">
|
||||
{/* Sidebar */}
|
||||
<aside className="lg:col-span-3">
|
||||
<nav className="rounded-xl border border-white/10 bg-white/5 p-2">
|
||||
{nav.map((item) => (
|
||||
<Link
|
||||
key={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>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<main className="lg:col-span-9 rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||
{children}
|
||||
</main>
|
||||
{/* Content */}
|
||||
<main className="lg:col-span-9">{children}</main>
|
||||
</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 { TopNav } from "@/components/TopNav";
|
||||
import { Import } from "lucide-react";
|
||||
import { Footer } from "@/components/Footer";
|
||||
|
||||
export default function BuilderLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
@@ -10,6 +12,7 @@ export default function BuilderLayout({ children }: { children: ReactNode }) {
|
||||
<TopNav />
|
||||
<BuilderNav />
|
||||
<main className="min-h-screen">{children}</main>
|
||||
<Footer />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
+429
-51
@@ -1,20 +1,123 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
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() {
|
||||
const { getAuthHeaders, user } = useAuth();
|
||||
const authHeaders = useMemo(() => getAuthHeaders(), [getAuthHeaders]);
|
||||
|
||||
// ------------------------------
|
||||
// Batch invites
|
||||
// ------------------------------
|
||||
const [dryRun, setDryRun] = useState(true);
|
||||
const [limit, setLimit] = useState("1");
|
||||
const [limit, setLimit] = useState("25");
|
||||
const [tokenMinutes, setTokenMinutes] = useState("30");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [result, setResult] = useState<any>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loadingBatch, setLoadingBatch] = useState(false);
|
||||
const [batchResult, setBatchResult] = useState<any>(null);
|
||||
const [batchError, setBatchError] = useState<string | null>(null);
|
||||
|
||||
async function runInvites() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setResult(null);
|
||||
setLoadingBatch(true);
|
||||
setBatchError(null);
|
||||
setBatchResult(null);
|
||||
|
||||
try {
|
||||
const qs = new URLSearchParams({
|
||||
@@ -23,70 +126,345 @@ export default function AdminBetaInvitesPage() {
|
||||
tokenMinutes: String(tokenMinutes || "30"),
|
||||
});
|
||||
|
||||
const res = await fetch(`/api/admin/beta-invites?${qs.toString()}`, {
|
||||
method: "POST",
|
||||
});
|
||||
const data = await fetchJson(
|
||||
`/api/v1/admin/beta/invites/send?${qs.toString()}`,
|
||||
{ method: "POST" },
|
||||
authHeaders
|
||||
);
|
||||
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data?.message || data?.error || "Request failed");
|
||||
|
||||
setResult(data);
|
||||
setBatchResult(data);
|
||||
await loadRequests();
|
||||
} catch (e: any) {
|
||||
setError(e?.message ?? "Failed");
|
||||
setBatchError(e?.message ?? "Failed");
|
||||
} 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 (
|
||||
<div className="space-y-6">
|
||||
<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">
|
||||
Runs the backend batch invite sender (uses your email templates + tracking).
|
||||
Review pending beta requests and send invite links (batch or
|
||||
per-user).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-zinc-900 bg-zinc-950 p-4 space-y-4">
|
||||
<label className="flex items-center gap-2 text-sm text-zinc-200">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={dryRun}
|
||||
onChange={(e) => setDryRun(e.target.checked)}
|
||||
/>
|
||||
Dry run (do everything except actually send)
|
||||
</label>
|
||||
<div className="grid gap-6 lg:grid-cols-[280px_minmax(0,1fr)] xl:grid-cols-[320px_minmax(0,1fr)]">
|
||||
{/* Left: Batch Invites */}
|
||||
<div className="rounded-lg border border-zinc-900 bg-zinc-950 p-4 space-y-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold uppercase tracking-[0.18em] text-zinc-500">
|
||||
Batch Invites
|
||||
</h2>
|
||||
<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">
|
||||
<Field label="Limit" htmlFor="limit">
|
||||
<Input id="limit" value={limit} onChange={(e) => setLimit(e.target.value)} />
|
||||
</Field>
|
||||
<span className="shrink-0 rounded-full border border-amber-500/30 bg-amber-500/10 px-3 py-1 text-xs text-amber-200">
|
||||
{dryRun ? "Dry run" : "Live send"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Field label="Token minutes" htmlFor="tokenMinutes">
|
||||
<Input
|
||||
id="tokenMinutes"
|
||||
value={tokenMinutes}
|
||||
onChange={(e) => setTokenMinutes(e.target.value)}
|
||||
<label className="flex items-center gap-2 text-sm text-zinc-200">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={dryRun}
|
||||
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>
|
||||
|
||||
<Button onClick={runInvites} disabled={loading}>
|
||||
{loading ? "Running…" : "Send Beta Invites"}
|
||||
</Button>
|
||||
{/* Right: Requests List */}
|
||||
<div className="rounded-lg border border-zinc-900 bg-zinc-950 p-4 space-y-4">
|
||||
<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 && (
|
||||
<div className="rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2 text-sm text-red-200">
|
||||
{error}
|
||||
<Button
|
||||
onClick={loadRequests}
|
||||
disabled={loadingList}
|
||||
className="w-auto px-6"
|
||||
>
|
||||
{loadingList ? "Refreshing…" : "Refresh"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result && (
|
||||
<pre className="overflow-auto rounded-md border border-zinc-800 bg-black/40 p-3 text-xs text-zinc-200">
|
||||
{JSON.stringify(result, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
{listError && (
|
||||
<div className="rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2 text-sm text-red-200">
|
||||
{listError}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,43 @@
|
||||
// app/api/beta-signup/route.ts
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
const API_BASE_URL =
|
||||
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) {
|
||||
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`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
body: JSON.stringify(payload),
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
// Always return ok=true (matches your server behavior)
|
||||
if (!res.ok) {
|
||||
// Optional: log server response for debugging
|
||||
const text = await res.text().catch(() => "");
|
||||
console.error("beta-signup proxy failed:", res.status, text);
|
||||
}
|
||||
@@ -24,6 +45,6 @@ export async function POST(req: Request) {
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (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 jwt = data.token ?? data.accessToken;
|
||||
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, {
|
||||
email: data.email,
|
||||
displayName: data.displayName ?? null,
|
||||
role: data.role ?? "USER",
|
||||
uuid,
|
||||
email,
|
||||
displayName: data.displayName ?? data.user?.displayName ?? null,
|
||||
role: data.role ?? data.user?.role ?? "USER",
|
||||
});
|
||||
|
||||
setStatus("success");
|
||||
|
||||
+51
-3
@@ -2,6 +2,7 @@
|
||||
|
||||
import { FormEvent, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function HomePage() {
|
||||
const [email, setEmail] = useState("");
|
||||
@@ -9,7 +10,10 @@ export default function HomePage() {
|
||||
const [status, setStatus] = useState<
|
||||
"idle" | "loading" | "success" | "error"
|
||||
>("idle");
|
||||
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [accepted, setAccepted] = useState(false);
|
||||
const TOS_VERSION = "2025-12-27";
|
||||
|
||||
async function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
@@ -20,6 +24,14 @@ export default function HomePage() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!accepted) {
|
||||
setMessage(
|
||||
"You must accept the Terms and confirm you’re responsible for safe/legal assembly."
|
||||
);
|
||||
setStatus("error");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setStatus("loading");
|
||||
setMessage(null);
|
||||
@@ -27,7 +39,7 @@ export default function HomePage() {
|
||||
const res = await fetch("/api/beta-signup", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, useCase }),
|
||||
body: JSON.stringify({ email, useCase, acceptedTos: accepted, tosVersion: TOS_VERSION }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
@@ -36,7 +48,9 @@ export default function HomePage() {
|
||||
}
|
||||
|
||||
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("");
|
||||
setUseCase("");
|
||||
} catch (err) {
|
||||
@@ -187,9 +201,43 @@ export default function HomePage() {
|
||||
/>
|
||||
</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
|
||||
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"
|
||||
>
|
||||
{status === "loading"
|
||||
|
||||
+111
-59
@@ -7,6 +7,8 @@ import Link from "next/link";
|
||||
import { useAuth } from "@/context/AuthContext";
|
||||
import { Button, Field, Input } from "@/components/ui/form";
|
||||
|
||||
const TOS_VERSION = "2025-12-27";
|
||||
|
||||
export default function RegisterPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
@@ -15,6 +17,7 @@ export default function RegisterPage() {
|
||||
const [email, setEmail] = useState("");
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [accepted, setAccepted] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const next = searchParams.get("next") || "/builder";
|
||||
@@ -23,76 +26,125 @@ export default function RegisterPage() {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
if (!accepted) {
|
||||
setError(
|
||||
"You must accept the Terms and confirm you’re responsible for safe/legal assembly."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await register({ email, password, displayName });
|
||||
await register({
|
||||
email,
|
||||
password,
|
||||
displayName,
|
||||
acceptedTos: accepted,
|
||||
tosVersion: TOS_VERSION,
|
||||
});
|
||||
|
||||
router.push(next);
|
||||
} catch (err: any) {
|
||||
setError(err.message ?? "Failed to create account");
|
||||
setError(
|
||||
typeof err === "string"
|
||||
? err
|
||||
: err?.message ?? "Failed to create account"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-black text-zinc-50">
|
||||
<div className="mx-auto flex max-w-md flex-col px-4 py-10">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
Join the <span className="text-amber-300">Battl Builder</span> Beta
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-zinc-400">
|
||||
Create an account so we can save your builds, watch price drops, and
|
||||
roll out new features to you first.
|
||||
</p>
|
||||
<main className="min-h-screen bg-black text-zinc-50">
|
||||
<div className="mx-auto flex max-w-md flex-col px-4 py-10">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
Join the <span className="text-amber-300">Battl Builder</span> Beta
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-zinc-400">
|
||||
Create an account so we can save your builds, watch price drops, and
|
||||
roll out new features to you first.
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit} className="mt-6 space-y-4">
|
||||
{error && (
|
||||
<div className="rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2 text-sm text-red-200">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<form onSubmit={handleSubmit} className="mt-6 space-y-4">
|
||||
{error && (
|
||||
<div className="rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2 text-sm text-red-200">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Field label="Display Name (optional)" htmlFor="displayName">
|
||||
<Input
|
||||
id="displayName"
|
||||
type="text"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
<Field label="Display Name (optional)" htmlFor="displayName">
|
||||
<Input
|
||||
id="displayName"
|
||||
type="text"
|
||||
value={displayName}
|
||||
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">
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Button type="submit" disabled={loading || !accepted}>
|
||||
{loading ? "Creating account…" : "Join Beta"}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<Field label="Password" htmlFor="password">
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</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>
|
||||
<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 = {
|
||||
uuid: string;
|
||||
email: string;
|
||||
username?: string | null;
|
||||
passwordSetAt?: string | null;
|
||||
displayName?: string | null;
|
||||
role: string;
|
||||
} | null;
|
||||
|
||||
export type RegisterParams = {
|
||||
email: string;
|
||||
password: string;
|
||||
displayName?: string;
|
||||
acceptedTos: boolean;
|
||||
tosVersion: string;
|
||||
};
|
||||
|
||||
// 1) update the type
|
||||
type AuthContextValue = {
|
||||
user: AuthUser;
|
||||
token: string | null;
|
||||
loading: boolean;
|
||||
login: (params: { email: string; password: string }) => Promise<void>;
|
||||
register: (params: {
|
||||
email: string;
|
||||
password: string;
|
||||
displayName?: string;
|
||||
}) => Promise<void>;
|
||||
register: (params: RegisterParams) => Promise<void>;
|
||||
logout: () => void;
|
||||
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);
|
||||
@@ -110,24 +114,55 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
* Used by login/register/magic-link
|
||||
*/
|
||||
const setSession = useCallback(
|
||||
(nextToken: string, nextUser: NonNullable<AuthUser>) => {
|
||||
async (nextToken: string, nextUser: NonNullable<AuthUser>) => {
|
||||
setToken(nextToken);
|
||||
setUser(nextUser);
|
||||
persistAuth(nextToken, nextUser);
|
||||
|
||||
// ✅ Make middleware / server aware of the session
|
||||
fetch("/api/auth/session", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
token: nextToken,
|
||||
role: nextUser.role,
|
||||
}),
|
||||
}).catch(() => {});
|
||||
// ✅ await so middleware sees cookie before navigation
|
||||
try {
|
||||
await fetch("/api/auth/session", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
token: nextToken,
|
||||
role: nextUser.role,
|
||||
}),
|
||||
});
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
},
|
||||
[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(
|
||||
async ({ email, password }: { email: string; password: string }) => {
|
||||
setLoading(true);
|
||||
@@ -152,10 +187,11 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
uuid: data.uuid,
|
||||
email: data.email ?? email,
|
||||
displayName: data.displayName ?? null,
|
||||
passwordSetAt: data.passwordSetAt ?? null,
|
||||
role: data.role ?? "USER",
|
||||
} as AuthUser);
|
||||
|
||||
setSession(nextToken, nextUser as NonNullable<AuthUser>);
|
||||
await setSession(nextToken, nextUser as NonNullable<AuthUser>);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -168,17 +204,21 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
email,
|
||||
password,
|
||||
displayName,
|
||||
}: {
|
||||
email: string;
|
||||
password: string;
|
||||
displayName?: string;
|
||||
}) => {
|
||||
acceptedTos,
|
||||
tosVersion,
|
||||
}: RegisterParams) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE_URL}/api/auth/register`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, password, displayName }),
|
||||
body: JSON.stringify({
|
||||
email,
|
||||
password,
|
||||
displayName,
|
||||
acceptedTos,
|
||||
tosVersion,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
@@ -195,10 +235,11 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
uuid: data.uuid,
|
||||
email: data.email ?? email,
|
||||
displayName: data.displayName ?? displayName ?? null,
|
||||
passwordSetAt: data.passwordSetAt ?? null,
|
||||
role: data.role ?? "USER",
|
||||
} as AuthUser);
|
||||
|
||||
setSession(nextToken, nextUser as NonNullable<AuthUser>);
|
||||
await setSession(nextToken, nextUser as NonNullable<AuthUser>);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -228,6 +269,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
logout,
|
||||
getAuthHeaders,
|
||||
setSession,
|
||||
refreshMeAndSession
|
||||
};
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
|
||||
+12
-3
@@ -1,8 +1,17 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
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