From 2202abe89f49f0f76ed0a4dccfce95fb3d1276ec Mon Sep 17 00:00:00 2001 From: Sean Date: Sat, 27 Dec 2025 20:01:17 -0500 Subject: [PATCH] account settings and account creation --- app/(account)/account/favorites/page.tsx | 8 + app/(account)/account/onboarding/page.tsx | 350 ++++++++++++++ app/(account)/account/page.tsx | 442 +---------------- app/(account)/account/profile/page.tsx | 270 +++++++++++ app/(account)/account/security/page.tsx | 8 + app/(account)/account/vault/page.tsx | 8 + app/(account)/layout.tsx | 50 +- app/(account)/tos/page.tsx | 102 ---- app/(app)/layout.tsx | 3 + app/(app)/tos/page.tsx | 260 ++++++++++ app/admin/beta-invites/page.tsx | 480 +++++++++++++++++-- app/api/beta-signup/route.ts | 31 +- app/api/users/me/password/route.ts | 25 + app/api/users/me/route.ts | 43 ++ app/api/users/me/username-available/route.ts | 27 ++ app/beta/confirm/page.tsx | 14 +- app/page.tsx | 54 ++- app/register/page.tsx | 170 ++++--- components/Footer.tsx | 108 +++++ context/AuthContext.tsx | 98 ++-- next.config.mjs | 15 +- 21 files changed, 1848 insertions(+), 718 deletions(-) create mode 100644 app/(account)/account/favorites/page.tsx create mode 100644 app/(account)/account/onboarding/page.tsx create mode 100644 app/(account)/account/profile/page.tsx create mode 100644 app/(account)/account/security/page.tsx create mode 100644 app/(account)/account/vault/page.tsx delete mode 100644 app/(account)/tos/page.tsx create mode 100644 app/(app)/tos/page.tsx create mode 100644 app/api/users/me/password/route.ts create mode 100644 app/api/users/me/route.ts create mode 100644 app/api/users/me/username-available/route.ts create mode 100644 components/Footer.tsx diff --git a/app/(account)/account/favorites/page.tsx b/app/(account)/account/favorites/page.tsx new file mode 100644 index 0000000..68158f5 --- /dev/null +++ b/app/(account)/account/favorites/page.tsx @@ -0,0 +1,8 @@ +export default function FavoritesPage() { + return ( +
+

Favorite Parts

+

Coming soon.

+
+ ); + } \ No newline at end of file diff --git a/app/(account)/account/onboarding/page.tsx b/app/(account)/account/onboarding/page.tsx new file mode 100644 index 0000000..b4e4f33 --- /dev/null +++ b/app/(account)/account/onboarding/page.tsx @@ -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(null); + + // Username + const [username, setUsername] = useState(""); + const [checking, setChecking] = useState(false); + const [available, setAvailable] = useState(null); + const [userMsg, setUserMsg] = useState(null); + const [savingUsername, setSavingUsername] = useState(false); + + // Display name (optional) + const [displayName, setDisplayName] = useState(""); + const [savingName, setSavingName] = useState(false); + const [nameMsg, setNameMsg] = useState(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
Loading…
; + if (!user) return
Not logged in.
; + + 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 ( +
+

Welcome to the beta 👋

+

+ Step {step} of 3. Password + username required. +

+ + {step === 1 && ( +
+
Set password *
+ + 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" + /> + 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) && ( +
+ {pwTooShort ? "Password must be at least 8 characters." : "Passwords don’t match."} +
+ )} + + + + {pwMsg &&
{pwMsg}
} + +
+ +
+
+ )} + + {step === 2 && ( +
+
Choose username *
+ + { + 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" + /> + +
+ + + + + {available !== null && ( + + {available ? "Available" : "Taken / invalid"} + + )} +
+ + {userMsg &&
{userMsg}
} + +
+ +
+
+ )} + + {step === 3 && ( +
+
+ Display name (optional) +
+ + 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" + /> + + + + {nameMsg &&
{nameMsg}
} + +
+ + + +
+
+ )} +
+ ); +} \ No newline at end of file diff --git a/app/(account)/account/page.tsx b/app/(account)/account/page.tsx index c298fa1..6cbb21a 100644 --- a/app/(account)/account/page.tsx +++ b/app/(account)/account/page.tsx @@ -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(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(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
Loading…
; - - if (!user) { - return ( -
- You’re not logged in. Please log in to view your account. -
- ); - } - 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 ( -
- {/* Welcome / Setup card (only if needed) */} - {showWelcomeCard && ( -
-
-

Welcome to the beta 👋

-

- Quick setup and you’re ready to cook. -

-
- - {/* Display name */} -
- - 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" - /> -
- - {msg && ( - - {msg} - - )} -
-
- - {/* Password */} -
-

- Want password login later? Optional (but recommended). -

- - {!showPassword ? ( - - ) : ( -
- 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" - /> - 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) && ( -
- {pwTooShort - ? "Password must be at least 8 characters." - : "Passwords don’t match."} -
- )} - -
- - -
- - {pwMsg && ( -
- {pwMsg} -
- )} -
- )} -
-
- )} - - {/* Profile header */} -
-

Profile

-

Basic account info.

-
- - {/* Profile card */} -
-
- Email:{" "} - {user.email} -
- - {/* Display name (inline editable) */} -
- Display name:{" "} - {!editingName ? ( - <> - {user.displayName || "—"} - - - ) : ( -
- 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" - /> - -
- - - {msg && ( - - {msg} - - )} -
-
- )} -
- -
- Role:{" "} - {user.role} -
- -
- - - {/* Optional: quick link to set password later */} - -
- - {/* If user clicks "Set/change password" outside welcome flow, show it here */} - {!showWelcomeCard && showPassword && ( -
-
Set password
- 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" - /> - 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) && ( -
- {pwTooShort - ? "Password must be at least 8 characters." - : "Passwords don’t match."} -
- )} - -
- - -
- - {pwMsg && ( -
- {pwMsg} -
- )} -
- )} -
-
- ); -} +export default function AccountIndex() { + redirect("/account/profile"); +} \ No newline at end of file diff --git a/app/(account)/account/profile/page.tsx b/app/(account)/account/profile/page.tsx new file mode 100644 index 0000000..dce29d5 --- /dev/null +++ b/app/(account)/account/profile/page.tsx @@ -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(null); + + const [showPassword, setShowPassword] = useState(false); + const [pw1, setPw1] = useState(""); + const [pw2, setPw2] = useState(""); + const [pwSaving, setPwSaving] = useState(false); + const [pwMsg, setPwMsg] = useState(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
Loading…
; + if (!user) return
Not logged in.
; + + // 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 ( +
+
+

Profile

+

Basic account info.

+
+ +
+
+ Email:{" "} + {user.email} +
+ +
+
+ Username:{" "} + {String((user as any)?.username ?? "—")} +
+ +
Display name
+ { + 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" + /> + +
+ + + {nameMsg && ( + + {nameMsg} + + )} +
+
+ +
+ Role:{" "} + {user.role} +
+ +
+ + + +
+ + {showPassword && ( +
+
Set password
+ 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" + /> + 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) && ( +
+ {pwTooShort ? "Password must be at least 8 characters." : "Passwords don’t match."} +
+ )} + +
+ + + +
+ + {pwMsg && ( +
+ {pwMsg} +
+ )} +
+ )} + +
+ +
+
+
+ ); +} \ No newline at end of file diff --git a/app/(account)/account/security/page.tsx b/app/(account)/account/security/page.tsx new file mode 100644 index 0000000..f7a7ff0 --- /dev/null +++ b/app/(account)/account/security/page.tsx @@ -0,0 +1,8 @@ +export default function SecurityPage() { + return ( +
+

Security

+

Coming soon.

+
+ ); + } \ No newline at end of file diff --git a/app/(account)/account/vault/page.tsx b/app/(account)/account/vault/page.tsx new file mode 100644 index 0000000..cd5a417 --- /dev/null +++ b/app/(account)/account/vault/page.tsx @@ -0,0 +1,8 @@ +export default function VaultPage() { + return ( +
+

My Vault

+

Coming soon.

+
+ ); + } \ No newline at end of file diff --git a/app/(account)/layout.tsx b/app/(account)/layout.tsx index 53d7393..6322ff9 100644 --- a/app/(account)/layout.tsx +++ b/app/(account)/layout.tsx @@ -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 ( -
- - -
- {/* Header */} -
+
+
+
-
+
Account
-

- Settings & Profile -

-

+

Settings & Profile

+

Profile, password, and account settings.

← Back to Builder
-
-
diff --git a/app/(account)/tos/page.tsx b/app/(account)/tos/page.tsx deleted file mode 100644 index 609c544..0000000 --- a/app/(account)/tos/page.tsx +++ /dev/null @@ -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 ( -
-
-

Terms of Service

-

Last Updated: {lastUpdated}

-
- -
- {/* 1. Acceptance */} -
-

1. Acceptance of Terms

-

- 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. -

-
- - {/* 2. Legal Compliance */} -
-

2. Legal Compliance & Firearms Safety

-
-

- IMPORTANT: It is your sole responsibility to ensure that any firearm configuration you assemble - complies with all applicable local, state, federal, and international laws. -

-
    -
  • Laws regarding "assault weapons," magazine capacity, barrel length (NFA regulations), and specific features vary significantly by jurisdiction.
  • -
  • The Site does not provide legal advice regarding the legality of any specific build in your area.
  • -
  • Always consult with a qualified gunsmith or legal professional before commencing a physical build.
  • -
-
-
- - {/* 3. No Guarantee of Compatibility */} -
-

3. Compatibility Disclaimer

-

- 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. -

-

- Shadow GunBuilder AI does not guarantee that parts selected in the digital builder - will fit together physically or function safely. Mechanical tolerances and manufacturer variations - may exist. -

-
- - {/* 4. User Generated Content */} -
-

4. User Builds and the "Vault"

-

- 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. -

-
- - {/* 5. Affiliate and Pricing Disclosure */} -
-

5. Affiliate & Pricing Data

-

- 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. -

-
- - {/* 6. Limitation of Liability */} -
-

6. Limitation of Liability

-

- 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. -

-
- -
- - ← Return to Home - -
-
-
- ); -} diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx index 3c38821..dfd1b12 100644 --- a/app/(app)/layout.tsx +++ b/app/(app)/layout.tsx @@ -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 }) {
{children}
+
); } \ No newline at end of file diff --git a/app/(app)/tos/page.tsx b/app/(app)/tos/page.tsx new file mode 100644 index 0000000..a185a76 --- /dev/null +++ b/app/(app)/tos/page.tsx @@ -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 ( +
+
+

Terms of Service

+

Last Updated: {lastUpdated}

+
+ +
+ {/* 1. Acceptance */} +
+

1. Acceptance of Terms

+

+ 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. +

+

+ 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. +

+

+ The Service is offered only to individuals who are at least 18 years + of age. +

+
+ + {/* 2. Nature of the Service */} +
+

+ 2. Nature of the Service +

+

+ 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. +

+

+ Battl Builder does NOT provide gunsmithing services, mechanical + instructions, professional advice, training, or certification of any + kind. +

+
+ + {/* 3. Legal Compliance & Firearms Safety */} +
+

+ 3. Legal Compliance, Firearms Safety & Assumption of Risk +

+
+

+ You are solely responsible for ensuring that any firearm, + component, or configuration complies with all applicable local, + state, federal, and international laws. +

+
    +
  • + Firearm laws vary significantly by jurisdiction and change + frequently. +
  • +
  • + Battl Builder does not provide legal advice or determine the + legality of any build. +
  • +
  • + Compatibility indicators are informational only and may be + incomplete or inaccurate. +
  • +
+

+ 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. +

+

+ Nothing on the Service constitutes legal, mechanical, firearms, + safety, or professional advice of any kind. +

+

+ You are solely responsible for verifying all component + compatibility, legality, and safe operation before acquiring, + assembling, or using any firearm or related component. +

+
+
+ + {/* 4. No Warranty / No Reliance */} +
+

+ 4. No Warranty; No Reliance +

+

+ 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. +

+

+ All content, data, and tools are provided “AS IS” and “AS + AVAILABLE,” without warranties of any kind, whether express or + implied. +

+
+ + {/* 5. User Content */} +
+

+ 5. User Content & Community Builds +

+

+ By submitting builds, images, descriptions, comments, or other + content (“User Content”), you represent and warrant that: +

+
    +
  • You own or have rights to submit the content
  • +
  • The content does not infringe third-party rights
  • +
  • The content is not unlawful, misleading, or harmful
  • +
  • The content does not contain malware or malicious code
  • +
+

+ 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. +

+
+ + {/* 6. Affiliate & Third-Party Data */} +
+

+ 6. Pricing, Affiliates & Third-Party Links +

+

+ 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. +

+

+ We may earn affiliate commissions from qualifying purchases made + through links on the Service. +

+
+ + {/* 7. Access & Restrictions */} +
+

+ 7. Access, License & Restrictions +

+

+ Battl Builder grants you a limited, non-exclusive, non-transferable + license to access the Service for personal, non-commercial use. +

+

You may NOT:

+
    +
  • Scrape, crawl, or harvest data from the Service
  • +
  • Use automated tools, bots, or data-mining techniques
  • +
  • Republish or resell Service data
  • +
  • Use the Service for commercial purposes without permission
  • +
+
+ + {/* 8. Termination */} +
+

8. Termination

+

+ Battl Builder may suspend or terminate your access to the Service at + any time, with or without notice, for any reason. +

+
+ + {/* 9. Limitation of Liability */} +
+

+ 9. Limitation of Liability +

+

+ 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. +

+

+ 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. +

+
+ + {/* 10. Indemnification */} +
+

10. Indemnification

+

+ 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. +

+
+ + {/* 11. Arbitration */} +
+

+ 11. Arbitration & Class Action Waiver +

+

+ 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. +

+

+ Any arbitration shall take place in the State of Florida, conducted + in the English language, unless otherwise required by applicable + law. +

+

+ This provision does not prevent either party from seeking injunctive + relief for intellectual property violations. +

+
+ + {/* 12. Misc */} +
+

12. Miscellaneous

+

+ These Terms constitute the entire agreement between you and Battl + Builder. If any provision is found unenforceable, the remaining + provisions will remain in effect. +

+

+ 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. +

+
+ +
+ + ← Return to Home + +
+
+
+ ); +} diff --git a/app/admin/beta-invites/page.tsx b/app/admin/beta-invites/page.tsx index 8797672..7175325 100644 --- a/app/admin/beta-invites/page.tsx +++ b/app/admin/beta-invites/page.tsx @@ -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 = { + 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 ( + + {ok ? yes : no} + + ); +} 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(null); - const [error, setError] = useState(null); + const [loadingBatch, setLoadingBatch] = useState(false); + const [batchResult, setBatchResult] = useState(null); + const [batchError, setBatchError] = useState(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(null); + const [requests, setRequests] = + useState | null>(null); + + // per-row loading state + const [rowBusy, setRowBusy] = useState>({}); + + // last invite response (for visibility / debug) + const [lastInvite, setLastInvite] = useState( + 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; + + setRequests(data); + } catch (e: any) { + setListError(e?.message ?? "Failed to load beta requests"); + } finally { + setLoadingList(false); + } + } + + async function inviteSingle(userId: number): Promise { + // 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 ( +
+

Admin

+

+ You don’t have access to this page. +

+
+ ); + } + return (
-

Send Beta Invites

+

Beta Access

- Runs the backend batch invite sender (uses your email templates + tracking). + Review pending beta requests and send invite links (batch or + per-user).

-
- +
+ {/* Left: Batch Invites */} +
+
+
+

+ Batch Invites +

+

+ Runs the backend batch invite sender. +

+
-
- - setLimit(e.target.value)} /> - + + {dryRun ? "Dry run" : "Live send"} + +
- - setTokenMinutes(e.target.value)} + + Dry run (do everything except actually send) + + +
+ + setLimit(e.target.value)} + /> + + + + setTokenMinutes(e.target.value)} + /> + +
+ + + + {batchError && ( +
+ {batchError} +
+ )} + + {batchResult && ( +
+              {JSON.stringify(batchResult, null, 2)}
+            
+ )}
- + {/* Right: Requests List */} +
+
+
+

+ Beta Requests +

+

+ Users with role BETA and{" "} + is_active=false. +

+
- {error && ( -
- {error} +
- )} - {result && ( -
-            {JSON.stringify(result, null, 2)}
-          
- )} + {listError && ( +
+ {listError} +
+ )} + +
+ {" "} + + + + + + + + + + + + + {requests?.content?.length ? ( + requests.content.map((u) => { + const busy = !!rowBusy[u.id]; + + return ( + + + + + + + + + + ); + }) + ) : ( + + + + )} + +
EmailInvitedVerifiedActive + Actions +
+
{u.email}
+
+ {u.displayName ?? "—"} • #{u.id} +
+
+ + + + + + +
+ + + +
+
+ {loadingList ? "Loading…" : "No pending beta requests."} +
+
+ + {/* pager */} +
+ + + + Page {requests ? requests.number + 1 : 1} of{" "} + {requests ? requests.totalPages : 1} •{" "} + {requests ? requests.totalElements : 0} total + + + +
+ + {/* optional debug panel */} + {lastInvite && ( +
+              {JSON.stringify(lastInvite, null, 2)}
+            
+ )} +
); -} \ No newline at end of file +} diff --git a/app/api/beta-signup/route.ts b/app/api/beta-signup/route.ts index cd940ea..8ea4b29 100644 --- a/app/api/beta-signup/route.ts +++ b/app/api/beta-signup/route.ts @@ -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 }); } } \ No newline at end of file diff --git a/app/api/users/me/password/route.ts b/app/api/users/me/password/route.ts new file mode 100644 index 0000000..f7ac6ff --- /dev/null +++ b/app/api/users/me/password/route.ts @@ -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" }, + }); +} \ No newline at end of file diff --git a/app/api/users/me/route.ts b/app/api/users/me/route.ts new file mode 100644 index 0000000..58bae83 --- /dev/null +++ b/app/api/users/me/route.ts @@ -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" }, + }); +} \ No newline at end of file diff --git a/app/api/users/me/username-available/route.ts b/app/api/users/me/username-available/route.ts new file mode 100644 index 0000000..308cb4b --- /dev/null +++ b/app/api/users/me/username-available/route.ts @@ -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" }, + }); +} \ No newline at end of file diff --git a/app/beta/confirm/page.tsx b/app/beta/confirm/page.tsx index 4b69a8d..4335ee8 100644 --- a/app/beta/confirm/page.tsx +++ b/app/beta/confirm/page.tsx @@ -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"); diff --git a/app/page.tsx b/app/page.tsx index 0e71ffe..77df1d2 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -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(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() { />
+
+ +
+ + - - setPassword(e.target.value)} - /> - - - - - -

- Already have an account?{" "} - - Log in - - . -

-
- +

+ Already have an account?{" "} + + Log in + + . +

+
+ ); } \ No newline at end of file diff --git a/components/Footer.tsx b/components/Footer.tsx new file mode 100644 index 0000000..f30b8b5 --- /dev/null +++ b/components/Footer.tsx @@ -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 ( +
+
+
+ {/* Brand */} +
+

+ Battl Builders +

+ +

+ Build rifles smarter, not harder. +

+ +

+ Early access platform · Data & pricing may change +

+
+ + {/* Explore */} +
+

Explore

+
    +
  • + + Builder + +
  • +
  • + + Community Builds + +
  • +
  • + + My Builds + +
  • +
+
+ + {/* Platform */} +
+

Platform

+
    +
  • + + About + +
  • +
  • + + Privacy Policy + +
  • +
  • + + Terms of Service + +
  • + + {/* Optional, if you have it */} + {/*
  • + + Contact + +
  • */} +
+
+
+ + {/* Bottom bar */} +
+ © {year} Battl Builders + +
+ + Built by people who actually build rifles + + + + + + + Terms + {" "} + ·{" "} + + Privacy + + +
+
+
+
+ ); +} \ No newline at end of file diff --git a/context/AuthContext.tsx b/context/AuthContext.tsx index 96f4628..7af7b33 100644 --- a/context/AuthContext.tsx +++ b/context/AuthContext.tsx @@ -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; - register: (params: { - email: string; - password: string; - displayName?: string; - }) => Promise; + register: (params: RegisterParams) => Promise; logout: () => void; getAuthHeaders: () => HeadersInit; + setSession: (token: string, user: NonNullable) => Promise; + refreshMeAndSession: () => Promise; - /** - * Used for non-password auth flows (ex: magic link). - * Persists session exactly like login/register. - */ - setSession: (token: string, user: NonNullable) => void; }; const AuthContext = createContext(undefined); @@ -110,24 +114,55 @@ export function AuthProvider({ children }: { children: ReactNode }) { * Used by login/register/magic-link */ const setSession = useCallback( - (nextToken: string, nextUser: NonNullable) => { + async (nextToken: string, nextUser: NonNullable) => { 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; + + 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); + await setSession(nextToken, nextUser as NonNullable); } 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); + await setSession(nextToken, nextUser as NonNullable); } finally { setLoading(false); } @@ -228,6 +269,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { logout, getAuthHeaders, setSession, + refreshMeAndSession }; return {children}; diff --git a/next.config.mjs b/next.config.mjs index 4e283b5..c28ed1b 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -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; \ No newline at end of file