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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user