From 61935982b37e047c4c358dcf0b348593cbf051d5 Mon Sep 17 00:00:00 2001 From: Sean Date: Sat, 20 Dec 2025 10:01:07 -0500 Subject: [PATCH] new pages for user buils (vault), edit saved builds, and the community build page. --- app/(builder)/builder/page.tsx | 398 ++++++++--- .../[partRole]/[productSlug]/page.tsx | 2 +- app/builds/page.tsx | 471 ++++++++----- app/layout.tsx | 4 +- app/page.tsx | 2 +- app/vault/[uuid]/edit/page.tsx | 640 ++++++++++++++++++ app/vault/page.tsx | 156 +++-- components/TopNav.tsx | 2 +- public/battl/Artboard 1@4x.png | Bin 0 -> 91096 bytes public/battl/battl-logo-mark-f.svg | 46 ++ 10 files changed, 1405 insertions(+), 316 deletions(-) create mode 100644 app/vault/[uuid]/edit/page.tsx create mode 100644 public/battl/Artboard 1@4x.png create mode 100644 public/battl/battl-logo-mark-f.svg diff --git a/app/(builder)/builder/page.tsx b/app/(builder)/builder/page.tsx index 10c552f..53ef847 100644 --- a/app/(builder)/builder/page.tsx +++ b/app/(builder)/builder/page.tsx @@ -1,6 +1,19 @@ // app/(builder)/builder/page.tsx "use client"; +/** + * Battl Builder - Main builder page + * + * Key flows: + * 1) Build selections are stored locally (localStorage) so users can come back. + * 2) "Save As…" creates a NEW saved build in the user's Vault (server-side). + * 3) Vault -> Builder loads builds via ?load= (also supports legacy ?build=). + * + * Notes for devs: + * - We intentionally keep ONE save-to-vault handler: handleSaveAs() + * - Toast is used for clear user feedback (save success/fail) + */ + import { useMemo, useState, useEffect, useCallback, useRef } from "react"; import Link from "next/link"; import { useSearchParams, useRouter } from "next/navigation"; @@ -31,6 +44,11 @@ type GunbuilderProductFromApi = { const PLATFORMS = ["AR-15", "AR-10", "AR-9"] as const; +const isUuid = (v: string) => + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test( + v + ); + const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080"; @@ -114,10 +132,24 @@ export default function GunbuilderPage() { const searchParams = useSearchParams(); const router = useRouter(); + // ----------------------------- + // Save As… modal state (Vault variants) + // ----------------------------- + const [saveAsOpen, setSaveAsOpen] = useState(false); + const [saveAsTitle, setSaveAsTitle] = useState(""); + const [saveAsDesc, setSaveAsDesc] = useState(""); + const [saveAsSaving, setSaveAsSaving] = useState(false); // prevents double-submit + + // ----------------------------- + // Parts data state + // ----------------------------- const [parts, setParts] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + // ----------------------------- + // Platform state (AR-15/AR-10/AR-9) + // ----------------------------- const isValidPlatform = ( value: string | null ): value is (typeof PLATFORMS)[number] => @@ -129,6 +161,9 @@ export default function GunbuilderPage() { return isValidPlatform(initial) ? initial : "AR-15"; }); + // ----------------------------- + // Build state (categoryId -> productId), persisted locally + // ----------------------------- const [build, setBuild] = useState(() => { if (typeof window !== "undefined") { const stored = localStorage.getItem(STORAGE_KEY); @@ -143,6 +178,20 @@ export default function GunbuilderPage() { return {}; }); + // ----------------------------- + // Toast notifications (save success / errors) + // ----------------------------- + type Toast = { type: "success" | "error" | "info"; message: string }; + const [toast, setToast] = useState(null); + + const showToast = useCallback((t: Toast) => { + setToast(t); + window.setTimeout(() => setToast(null), 3500); + }, []); + + // ----------------------------- + // Misc UI state + // ----------------------------- const [shareStatus, setShareStatus] = useState(null); const [shareUrl, setShareUrl] = useState(""); @@ -153,7 +202,9 @@ export default function GunbuilderPage() { // ✅ Guard to prevent infinite loops when processing ?select / ?remove const processedActionKeyRef = useRef(""); - // ---------- Derived ---------- + // ----------------------------- + // Derived collections + // ----------------------------- const partsByCategory: Record = useMemo(() => { const grouped = {} as Record; for (const category of CATEGORIES) { @@ -177,8 +228,26 @@ export default function GunbuilderPage() { }); }, [build, parts]); + const selectedByCategory: Record = useMemo( + () => + Object.fromEntries( + Object.entries(build).map(([categoryId, partId]) => [ + categoryId as CategoryId, + partId ? true : false, + ]) + ) as Record, + [build] + ); + + const totalPrice = useMemo( + () => selectedParts.reduce((sum, p) => sum + p.price, 0), + [selectedParts] + ); + + // ----------------------------- // Role -> Category resolver // NOTE: defensive while backend roles/mappings evolve + // ----------------------------- const resolveCategoryId = (normalizedRole: string): CategoryId | null => { if (normalizedRole === "upper-receiver") return "upper-receiver"; if (normalizedRole.includes("complete-upper")) return "complete-upper"; @@ -271,22 +340,9 @@ export default function GunbuilderPage() { return (PART_ROLE_TO_CATEGORY as any)[normalizedRole] ?? null; }; - const selectedByCategory: Record = useMemo( - () => - Object.fromEntries( - Object.entries(build).map(([categoryId, partId]) => [ - categoryId as CategoryId, - partId ? true : false, - ]) - ) as Record, - [build] - ); - - const totalPrice = useMemo( - () => selectedParts.reduce((sum, p) => sum + p.price, 0), - [selectedParts] - ); - + // ----------------------------- + // Selection handler (with hint warnings) + // ----------------------------- const handleSelectPart = useCallback( (categoryId: CategoryId, partId: string, _opts?: { confirm?: boolean }) => { const warn = (msg: string) => { @@ -305,7 +361,9 @@ export default function GunbuilderPage() { [build] ); - // ---------- Fetch products ---------- + // ----------------------------- + // Fetch products (scoped + universal merge) + // ----------------------------- useEffect(() => { const controller = new AbortController(); @@ -317,7 +375,6 @@ export default function GunbuilderPage() { const scopedUrl = `${API_BASE_URL}/api/v1/products?platform=${encodeURIComponent( platform )}`; - const universalUrl = `${API_BASE_URL}/api/v1/products`; const [scopedRes, universalRes] = await Promise.all([ @@ -350,6 +407,7 @@ export default function GunbuilderPage() { const categoryId = resolveCategoryId(normalizedRole); if (!categoryId) return null; + // Only accept universal fetch results for "universal" categories if ( data === universalData && !UNIVERSAL_CATEGORIES.has(categoryId) @@ -377,6 +435,7 @@ export default function GunbuilderPage() { const scopedParts = normalize(scopedData); const universalParts = normalize(universalData); + // Merge (avoid duplicates across calls) const seen = new Set(); const merged: Part[] = []; for (const p of [...scopedParts, ...universalParts]) { @@ -399,7 +458,9 @@ export default function GunbuilderPage() { return () => controller.abort(); }, [platform]); - // ✅ Persist build state whenever it changes + // ----------------------------- + // Persist build to localStorage + // ----------------------------- useEffect(() => { if (typeof window === "undefined") return; try { @@ -409,7 +470,9 @@ export default function GunbuilderPage() { } }, [build]); - // When the platform changes, clear ONLY platform-scoped selections. + // ----------------------------- + // When platform changes, clear ONLY platform-scoped selections + // ----------------------------- useEffect(() => { const prev = lastPlatformRef.current; lastPlatformRef.current = platform; @@ -433,20 +496,25 @@ export default function GunbuilderPage() { }); }, [platform]); - // ✅ Load a saved build from Vault via ?load= + // ----------------------------- + // Load a saved build from Vault via ?load= OR legacy ?build= + // ----------------------------- useEffect(() => { const loadUuid = searchParams.get("load"); - if (!loadUuid) return; + const buildParam = searchParams.get("build"); + + const uuidToLoad = + (loadUuid && isUuid(loadUuid) ? loadUuid : null) || + (buildParam && isUuid(buildParam) ? buildParam : null); + + if (!uuidToLoad) return; const run = async () => { try { setShareStatus("Loading build…"); - // NOTE: your backend GET route is /api/v1/products/builds/{uuid} const res = await fetch( - `${API_BASE_URL}/api/v1/products/builds/${encodeURIComponent( - loadUuid - )}`, + `${API_BASE_URL}/api/v1/builds/me/${encodeURIComponent(uuidToLoad)}`, { credentials: "include" } ); @@ -466,10 +534,15 @@ export default function GunbuilderPage() { setBuild(nextBuild); setShareStatus(`Loaded: ${dto.title}`); - // Clean URL so reload doesn't re-fetch + // Clean URL so refresh doesn't re-load repeatedly const qp = new URLSearchParams(searchParams.toString()); qp.delete("load"); - router.replace(`/builder?${qp.toString()}`, { scroll: false }); + if (buildParam && isUuid(buildParam)) qp.delete("build"); + + const next = qp.toString(); + router.replace(next ? `/builder?${next}` : "/builder", { + scroll: false, + }); } catch (e: any) { setShareStatus(e?.message ?? "Failed to load build"); } finally { @@ -481,9 +554,86 @@ export default function GunbuilderPage() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [searchParams, router]); + // ----------------------------- + // SAVE AS… (single source of truth for saving to Vault) + // - Creates a NEW saved build server-side + // - Shows toast so users know it worked + // ----------------------------- + const handleSaveAs = async () => { + if (selectedParts.length === 0) { + showToast({ type: "error", message: "Add a few parts before saving." }); + return; + } + + const title = (saveAsTitle || `${platform} Build`).trim(); + if (!title) { + showToast({ type: "error", message: "Title is required." }); + return; + } + + const items = Object.entries(build) + .filter(([, partId]) => !!partId) + .map(([categoryId, partId]) => ({ + productId: Number(partId), + slot: categoryId, + position: 0, + quantity: 1, + })); + + try { + setSaveAsSaving(true); + showToast({ type: "info", message: "Saving build…" }); + + const res = await fetch(`${API_BASE_URL}/api/v1/builds/me`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ + title, + description: saveAsDesc?.trim() || null, + isPublic: false, + items, + }), + }); + + if (!res.ok) { + const txt = await res.text().catch(() => ""); + throw new Error(txt || `Save failed (${res.status})`); + } + + const saved = await res.json(); // BuildDto (expects { uuid, ... }) + + // UX: copy UUID for quick testing + user confidence + try { + if (saved?.uuid) await navigator.clipboard.writeText(saved.uuid); + } catch { + // ignore clipboard failures + } + + showToast({ + type: "success", + message: "Saved to Vault ✅ (UUID copied)", + }); + + // Close modal + reset inputs for next variant + setSaveAsOpen(false); + setSaveAsTitle(""); + setSaveAsDesc(""); + } catch (e: any) { + showToast({ + type: "error", + message: e?.message ?? "Save failed", + }); + } finally { + setSaveAsSaving(false); + } + }; + + // ----------------------------- // Handle URL query parameters: // - ?select=categoryId:partId // - ?remove=categoryId + // ----------------------------- useEffect(() => { const selectParam = searchParams.get("select"); const removeParam = searchParams.get("remove"); @@ -522,12 +672,11 @@ export default function GunbuilderPage() { const nextPlatform = isValidPlatform(qpPlatform) ? qpPlatform : platform; + // Keep URL clean/stable const nextUrl = `/builder?platform=${encodeURIComponent(nextPlatform)}`; if (typeof window !== "undefined") { const current = `${window.location.pathname}${window.location.search}`; - if (current !== nextUrl) { - router.replace(nextUrl, { scroll: false }); - } + if (current !== nextUrl) router.replace(nextUrl, { scroll: false }); } else { router.replace(nextUrl, { scroll: false }); } @@ -541,7 +690,7 @@ export default function GunbuilderPage() { } }, [searchParams, platform]); - // Build share URL whenever build changes + // Build share URL whenever build changes (client-side only) useEffect(() => { if (typeof window === "undefined") return; @@ -554,9 +703,7 @@ export default function GunbuilderPage() { const payload = JSON.stringify(build); const encoded = window.btoa(payload); const origin = window.location?.origin ?? ""; - const url = `${origin}/builder/build?build=${encodeURIComponent( - encoded - )}`; + const url = `${origin}/builder/build?build=${encodeURIComponent(encoded)}`; setShareUrl(url); } catch { setShareUrl(""); @@ -571,53 +718,7 @@ export default function GunbuilderPage() { [] ); - // Handler to Save user build to account - const handleSaveToAccount = async () => { - if (selectedParts.length === 0) { - setShareStatus("Add a few parts before saving."); - return; - } - - const title = `${platform} Build`; - - const items = Object.entries(build) - .filter(([, partId]) => !!partId) - .map(([categoryId, partId]) => ({ - productId: Number(partId), - slot: categoryId, - position: 0, - quantity: 1, - })); - - try { - setShareStatus("Saving build…"); - - const res = await fetch(`${API_BASE_URL}/api/v1/products/me/builds`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - credentials: "include", - body: JSON.stringify({ - title, - description: null, - isPublic: false, - items, - }), - }); - - if (!res.ok) { - const txt = await res.text().catch(() => ""); - throw new Error(`Save failed (${res.status}) ${txt}`); - } - - const saved = await res.json(); // BuildDto - setShareStatus(`Saved! Build UUID: ${saved.uuid}`); - } catch (e: any) { - setShareStatus(e?.message ?? "Save failed"); - } finally { - window.setTimeout(() => setShareStatus(null), 4500); - } - }; - + // Copy summary text to clipboard const handleShare = async () => { if (selectedParts.length === 0) { setShareStatus("Add a few parts before sharing your build."); @@ -625,7 +726,7 @@ export default function GunbuilderPage() { } const lines: string[] = []; - lines.push("B Build"); + lines.push("Battl Build"); lines.push(""); lines.push(`Total: $${totalPrice.toFixed(2)}`); lines.push(""); @@ -685,8 +786,8 @@ export default function GunbuilderPage() { if (navigator.share) { try { await navigator.share({ - title: "Shadow Standard — The Armory Build", - text: "Check out my Shadow Standard Armory build.", + title: "BATTL — The Builder", + text: "Check out my Battl Build.", url: shareUrl, }); setShareStatus("Share dialog opened."); @@ -702,6 +803,99 @@ export default function GunbuilderPage() { return (
+ {/* Toast (top-right) */} + {toast && ( +
+
+ {toast.message} +
+
+ )} + + {/* Save As… Modal */} + {saveAsOpen && ( +
+
+
+
+
Save As…
+
+ Create a new saved build variant in your Vault. +
+
+ + +
+ +
+
+ + setSaveAsTitle(e.target.value)} + placeholder={`e.g. "${platform} Mk2 (Suppressed)"`} + className="mt-1 w-full rounded-md border border-white/10 bg-black/30 px-3 py-2 text-sm text-white outline-none focus:ring-1 focus:ring-amber-400/60" + /> +
+ +
+ +