new pages for user buils (vault), edit saved builds, and the community build page.
This commit is contained in:
+303
-95
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user