wired up an accounts page and my builds (my vault)

This commit is contained in:
2025-12-19 08:05:03 -05:00
parent c6d1bce771
commit b76ced45f1
9 changed files with 566 additions and 1055 deletions
+115 -26
View File
@@ -1,3 +1,4 @@
// app/(builder)/builder/page.tsx
"use client";
import { useMemo, useState, useEffect, useCallback, useRef } from "react";
@@ -7,7 +8,7 @@ import { CATEGORIES } from "@/data/gunbuilderParts";
import { BUILDER_SLOTS, isSlotSatisfied } from "@/data/builderSlots";
import type { CategoryId, Part } from "@/types/gunbuilder";
import { PART_ROLE_TO_CATEGORY } from "@/lib/catalogMappings";
import { Pencil, X, Link2, Square, CheckSquare } from "lucide-react";
import { X, Link2, Square, CheckSquare } from "lucide-react";
// ✅ Centralized overlap rules + helpers
import OverlapChip from "@/components/builder/OverlapChip";
@@ -288,10 +289,6 @@ export default function GunbuilderPage() {
const handleSelectPart = useCallback(
(categoryId: CategoryId, partId: string, _opts?: { confirm?: boolean }) => {
// NOTE:
// - We DO NOT auto-remove any existing selections.
// - We show subtle overlap guidance (chips + an optional “heads up” toast).
const warn = (msg: string) => {
setShareStatus(msg);
window.setTimeout(() => setShareStatus(null), 4000);
@@ -300,7 +297,6 @@ export default function GunbuilderPage() {
const hints = getSelectionHints(categoryId, build);
if (hints.length > 0) warn(hints[0]);
// ✅ Only set the selection. No deletes. No clearing.
setBuild((prev) => ({
...prev,
[categoryId]: partId,
@@ -437,6 +433,54 @@ export default function GunbuilderPage() {
});
}, [platform]);
// ✅ Load a saved build from Vault via ?load=<uuid>
useEffect(() => {
const loadUuid = searchParams.get("load");
if (!loadUuid) 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
)}`,
{ credentials: "include" }
);
if (!res.ok) {
const txt = await res.text().catch(() => "");
throw new Error(`Load failed (${res.status}) ${txt}`);
}
const dto = await res.json(); // BuildDto w/ items
const nextBuild: Record<string, string> = {};
for (const it of dto.items ?? []) {
if (!it?.slot || !it?.productId) continue;
nextBuild[it.slot] = String(it.productId);
}
setBuild(nextBuild);
setShareStatus(`Loaded: ${dto.title}`);
// Clean URL so reload doesn't re-fetch
const qp = new URLSearchParams(searchParams.toString());
qp.delete("load");
router.replace(`/builder?${qp.toString()}`, { scroll: false });
} catch (e: any) {
setShareStatus(e?.message ?? "Failed to load build");
} finally {
window.setTimeout(() => setShareStatus(null), 4500);
}
};
run();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchParams, router]);
// Handle URL query parameters:
// - ?select=categoryId:partId
// - ?remove=categoryId
@@ -527,6 +571,53 @@ 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);
}
};
const handleShare = async () => {
if (selectedParts.length === 0) {
setShareStatus("Add a few parts before sharing your build.");
@@ -680,20 +771,6 @@ export default function GunbuilderPage() {
</div>
<div className="mt-2 flex flex-col items-stretch gap-2 sm:flex-row sm:flex-wrap">
<Link
href={{
pathname: "/builder/build",
query: platform ? { platform } : {},
}}
className={`w-full sm:w-auto rounded-md border border-amber-400/60 bg-amber-400/10 px-3 py-2 text-sm font-medium text-amber-200 hover:bg-amber-400/15 text-center transition-colors ${
selectedParts.length === 0
? "opacity-40 cursor-not-allowed pointer-events-none"
: ""
}`}
>
Build Summary
</Link>
<button
type="button"
onClick={handleShare}
@@ -724,6 +801,20 @@ export default function GunbuilderPage() {
>
Clear Build
</button>
{/* Save Build */}
<button
type="button"
onClick={handleSaveToAccount}
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
? "bg-zinc-900/80 text-zinc-600 border-zinc-800 cursor-not-allowed"
: "bg-amber-400 text-black border-amber-300 hover:bg-amber-300"
}`}
>
Save Build
</button>
</div>
</div>
@@ -877,14 +968,12 @@ 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
? categoryParts.find(
(p) => p.id === selectedPartId
) ?? parts.find((p) => p.id === selectedPartId)
? categoryParts.find((p) => p.id === selectedPartId) ??
parts.find((p) => p.id === selectedPartId)
: undefined;
const hasParts = categoryParts.length > 0;
@@ -1031,4 +1120,4 @@ export default function GunbuilderPage() {
</div>
</main>
);
}
}