new pages for user buils (vault), edit saved builds, and the community build page.

This commit is contained in:
2025-12-20 10:01:07 -05:00
parent f81974cc0b
commit 61935982b3
10 changed files with 1405 additions and 316 deletions
+303 -95
View File
@@ -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=<uuid> (also supports legacy ?build=<uuid>).
*
* 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<Part[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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<BuildState>(() => {
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<Toast | null>(null);
const showToast = useCallback((t: Toast) => {
setToast(t);
window.setTimeout(() => setToast(null), 3500);
}, []);
// -----------------------------
// Misc UI state
// -----------------------------
const [shareStatus, setShareStatus] = useState<string | null>(null);
const [shareUrl, setShareUrl] = useState<string>("");
@@ -153,7 +202,9 @@ export default function GunbuilderPage() {
// ✅ Guard to prevent infinite loops when processing ?select / ?remove
const processedActionKeyRef = useRef<string>("");
// ---------- Derived ----------
// -----------------------------
// Derived collections
// -----------------------------
const partsByCategory: Record<CategoryId, Part[]> = useMemo(() => {
const grouped = {} as Record<CategoryId, Part[]>;
for (const category of CATEGORIES) {
@@ -177,8 +228,26 @@ export default function GunbuilderPage() {
});
}, [build, parts]);
const selectedByCategory: Record<CategoryId, unknown> = useMemo(
() =>
Object.fromEntries(
Object.entries(build).map(([categoryId, partId]) => [
categoryId as CategoryId,
partId ? true : false,
])
) as Record<CategoryId, unknown>,
[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<CategoryId, unknown> = useMemo(
() =>
Object.fromEntries(
Object.entries(build).map(([categoryId, partId]) => [
categoryId as CategoryId,
partId ? true : false,
])
) as Record<CategoryId, unknown>,
[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<string>();
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=<uuid>
// -----------------------------
// Load a saved build from Vault via ?load=<uuid> OR legacy ?build=<uuid>
// -----------------------------
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 (
<main className="min-h-screen bg-white text-zinc-900 dark:bg-black dark:text-zinc-50">
{/* Toast (top-right) */}
{toast && (
<div className="fixed top-4 right-4 z-50">
<div
className={`rounded-lg border px-4 py-3 text-sm shadow-lg backdrop-blur
${
toast.type === "success"
? "border-emerald-500/30 bg-emerald-500/15 text-emerald-100"
: toast.type === "error"
? "border-red-500/30 bg-red-500/15 text-red-100"
: "border-white/10 bg-white/10 text-white"
}`}
>
{toast.message}
</div>
</div>
)}
{/* Save As… Modal */}
{saveAsOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
<div className="w-full max-w-lg rounded-xl border border-white/10 bg-zinc-950 p-4">
<div className="flex items-start justify-between gap-4">
<div>
<div className="text-sm font-semibold">Save As</div>
<div className="text-xs text-zinc-400">
Create a new saved build variant in your Vault.
</div>
</div>
<button
type="button"
onClick={() => setSaveAsOpen(false)}
className="rounded-md border border-white/10 bg-white/5 px-2 py-1 text-xs hover:bg-white/10"
>
Close
</button>
</div>
<div className="mt-4 space-y-3">
<div>
<label className="block text-xs font-medium text-zinc-300">
Title
</label>
<input
value={saveAsTitle}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-xs font-medium text-zinc-300">
Description (optional)
</label>
<textarea
value={saveAsDesc}
onChange={(e) => setSaveAsDesc(e.target.value)}
rows={3}
placeholder="Notes for future-you…"
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"
/>
</div>
<div className="flex items-center justify-end gap-2 pt-2">
<button
type="button"
onClick={() => setSaveAsOpen(false)}
className="text-sm text-zinc-300 hover:underline"
disabled={saveAsSaving}
>
Cancel
</button>
<button
type="button"
onClick={handleSaveAs}
disabled={saveAsSaving}
className={`rounded-md px-4 py-2 text-sm font-semibold text-black ${
saveAsSaving
? "bg-amber-400/60 cursor-not-allowed"
: "bg-amber-400 hover:bg-amber-300"
}`}
>
{saveAsSaving ? "Saving…" : "Save"}
</button>
</div>
</div>
</div>
</div>
)}
<div className="mx-auto max-w-6xl px-4 py-6 lg:py-10">
{/* Header */}
<header className="mb-6">
@@ -728,6 +922,7 @@ export default function GunbuilderPage() {
const next = e.target.value as (typeof PLATFORMS)[number];
setPlatform(next);
// Keep URL in sync, and clear transient action params
const qp = new URLSearchParams(searchParams.toString());
qp.set("platform", next);
qp.delete("select");
@@ -760,6 +955,7 @@ export default function GunbuilderPage() {
<h2 className="text-xs font-semibold uppercase tracking-[0.16em] text-amber-300">
My Build Breakdown
</h2>
<div>
<div className="text-xs text-zinc-400">Current build total</div>
<div className="text-2xl font-semibold text-amber-300">
@@ -787,25 +983,25 @@ export default function GunbuilderPage() {
<button
type="button"
onClick={() => {
// Reset local build state
setBuild({});
if (typeof window !== "undefined") {
localStorage.removeItem(STORAGE_KEY);
}
showToast({ type: "info", message: "Build cleared." });
}}
disabled={selectedParts.length === 0}
className={`w-full sm:w-auto rounded-md border border-zinc-700 bg-zinc-900/50 px-3 py-2 text-sm font-medium text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600 transition-colors ${
selectedParts.length === 0
? "opacity-40 cursor-not-allowed"
: ""
selectedParts.length === 0 ? "opacity-40 cursor-not-allowed" : ""
}`}
>
Clear Build
</button>
{/* Save Build */}
{/* Save As… triggers modal */}
<button
type="button"
onClick={handleSaveToAccount}
onClick={() => setSaveAsOpen(true)}
disabled={selectedParts.length === 0}
className={`w-full sm:w-auto rounded-md px-3 py-2 text-sm font-semibold transition-colors border ${
selectedParts.length === 0
@@ -813,8 +1009,16 @@ export default function GunbuilderPage() {
: "bg-amber-400 text-black border-amber-300 hover:bg-amber-300"
}`}
>
Save Build
Save As
</button>
{/* Optional: quick access to Vault */}
<Link
href="/vault"
className="w-full sm:w-auto rounded-md border border-white/10 bg-white/5 px-3 py-2 text-sm font-medium text-zinc-200 hover:bg-white/10 text-center"
>
Go to Vault
</Link>
</div>
</div>
@@ -829,6 +1033,7 @@ export default function GunbuilderPage() {
Share this link to let others view your build or bookmark it
to come back later.
</p>
<div className="mt-2 flex flex-col gap-2 md:flex-row md:items-center">
<div className="flex-1">
<div className="text-[0.65rem] uppercase tracking-[0.16em] text-zinc-500 mb-1">
@@ -841,6 +1046,7 @@ export default function GunbuilderPage() {
className="w-full rounded-md border border-zinc-700 bg-zinc-950/80 px-2 py-1.5 text-[0.7rem] text-zinc-200 font-mono outline-none focus:ring-1 focus:ring-amber-400"
/>
</div>
<div className="flex gap-2 md:flex-none md:mt-5">
<button
type="button"
@@ -849,6 +1055,7 @@ export default function GunbuilderPage() {
>
Share
</button>
<button
type="button"
onClick={handleCopyLink}
@@ -968,7 +1175,8 @@ export default function GunbuilderPage() {
<tbody>
{groupCategories.map((category) => {
const categoryParts = partsByCategory[category.id] ?? [];
const categoryParts =
partsByCategory[category.id] ?? [];
const selectedPartId = build[category.id];
const selectedPart = selectedPartId
@@ -995,7 +1203,7 @@ export default function GunbuilderPage() {
{selectedPart.name}
</div>
{/* ✅ Category overlap chips */}
{/* Overlap chips help explain conflicts / dependencies */}
<div className="mt-1 flex flex-wrap gap-1.5">
{getOverlapChipsForCategory(
category.id as CategoryId,
@@ -1038,7 +1246,7 @@ export default function GunbuilderPage() {
</td>
{/* Caliber placeholder */}
{/* Caliber placeholder (future: derive from build profile) */}
<td className="px-3 py-2 align-top text-zinc-400">
</td>
@@ -591,7 +591,7 @@ export default function ProductDetailsPage() {
{isBestOffer(o) && (
<span className="ml-2 inline-flex items-center rounded bg-amber-400 px-2 py-0.5 text-[10px] font-semibold text-black">
BEST
BEST DEAL
</span>
)}
</td>