lots of fixes. cant remember them all

This commit is contained in:
2025-12-18 09:42:25 -05:00
parent 607939c468
commit 4c2d767d09
28 changed files with 3342 additions and 996 deletions
+482
View File
@@ -0,0 +1,482 @@
//
// This page is most likely temporary. We probably do not need this page
//
//
//
"use client";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useSearchParams, useRouter } from "next/navigation";
import { CATEGORIES } from "@/data/gunbuilderParts";
import type { CategoryId, Part } from "@/types/gunbuilder";
import {
Pencil,
Link2,
Square,
CheckSquare,
} from "lucide-react";
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
const STORAGE_KEY = "gunbuilder-build-state";
type BuildState = Partial<Record<CategoryId, string>>;
type GunbuilderProductFromApi = {
id: number;
name: string;
brand: string;
platform: string;
partRole: string;
price: number | null;
imageUrl: string | null;
buyUrl: string | null;
};
const PLATFORMS = ["AR-15", "AR-10", "AR-9"] as const;
const UNIVERSAL_CATEGORIES = new Set<CategoryId>([
"magazine",
"weapon-light",
"foregrip",
"bipod",
"sling",
"rail-accessory",
"tools",
"optic",
"sights",
]);
const CATEGORY_GROUPS: {
id: string;
label: string;
description?: string;
categoryIds: CategoryId[];
}[] = [
{
id: "lower-group",
label: "Lower Receiver Parts",
description:
"Everything from the serialized lower to small parts, fire control, and core controls.",
categoryIds: [
"lower-receiver",
"complete-lower",
"lower-parts",
"trigger",
"grip",
"safety",
"buffer",
"stock",
],
},
{
id: "upper-group",
label: "Upper Receiver Parts",
description:
"Barrel, upper, gas system, and the parts that keep the rifle cycling.",
categoryIds: [
"upper-receiver",
"complete-upper",
"bcg",
"barrel",
"gas-block",
"gas-tube",
"muzzle-device",
"suppressor",
"handguard",
"charging-handle",
"sights",
"optic",
],
},
{
id: "accessories-group",
label: "Accessories & Gear",
description:
"Universal add-ons that work across platforms — mags, lights, slings, tools, and more.",
categoryIds: [
"magazine",
"weapon-light",
"foregrip",
"bipod",
"sling",
"rail-accessory",
"tools",
],
},
];
function isValidPlatform(
value: string | null
): value is (typeof PLATFORMS)[number] {
return !!value && (PLATFORMS as readonly string[]).includes(value);
}
function safeDecodeBuildParam(buildParam: string | null): BuildState | null {
if (!buildParam) return null;
try {
// build param is base64(json)
const json = window.atob(buildParam);
const parsed = JSON.parse(json) as BuildState;
return parsed && typeof parsed === "object" ? parsed : null;
} catch {
return null;
}
}
function formatPrice(n: number) {
return `$${n.toFixed(2)}`;
}
export default function BuildSummaryPage() {
const searchParams = useSearchParams();
const router = useRouter();
const qpPlatform = searchParams.get("platform");
const platform = isValidPlatform(qpPlatform) ? qpPlatform : "AR-15";
const [build, setBuild] = useState<BuildState>({});
const [parts, setParts] = useState<Part[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Load build from ?build=... OR localStorage
useEffect(() => {
if (typeof window === "undefined") return;
const buildParam = searchParams.get("build");
const decoded = safeDecodeBuildParam(buildParam);
if (decoded) {
setBuild(decoded);
return;
}
try {
const stored = localStorage.getItem(STORAGE_KEY);
setBuild(stored ? (JSON.parse(stored) as BuildState) : {});
} catch {
setBuild({});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Fetch product data so we can resolve ids -> Part info
useEffect(() => {
const controller = new AbortController();
async function fetchProducts() {
try {
setLoading(true);
setError(null);
// scoped (platform)
const scopedUrl = `${API_BASE_URL}/api/products?platform=${encodeURIComponent(
platform
)}`;
// universal (optional)
const universalUrl = `${API_BASE_URL}/api/products`;
const [scopedRes, universalRes] = await Promise.all([
fetch(scopedUrl, { signal: controller.signal }),
fetch(universalUrl, { signal: controller.signal }).catch(
() => null as any
),
]);
if (!scopedRes || !scopedRes.ok) {
throw new Error(`Failed to load products (${scopedRes?.status ?? ""})`);
}
const scopedData: GunbuilderProductFromApi[] = await scopedRes.json();
let universalData: GunbuilderProductFromApi[] = [];
if (universalRes && universalRes.ok) {
universalData = await universalRes.json();
}
// NOTE: We only need enough to render the summary.
// We will map “everything we can” and ignore unknown categories.
const toCategoryId = (rawRole: string): CategoryId | null => {
const role = (rawRole ?? "").trim().toLowerCase().replace(/_/g, "-");
// quick defensive mapping (mirrors your builder page strategy)
if (role.includes("complete-upper")) return "complete-upper";
if (role.includes("complete-lower")) return "complete-lower";
if (role === "upper-receiver" || role === "upper") return "upper-receiver";
if (role === "lower-receiver" || role === "lower") return "lower-receiver";
if (role.includes("charging")) return "charging-handle";
if (role.includes("handguard")) return "handguard";
if (role.includes("bcg") || role.includes("bolt-carrier")) return "bcg";
if (role.includes("barrel")) return "barrel";
if (role.includes("gas-block") || role.includes("gasblock")) return "gas-block";
if (role.includes("gas-tube") || role.includes("gastube")) return "gas-tube";
if (role.includes("muzzle") || role.includes("flash") || role.includes("brake") || role.includes("comp"))
return "muzzle-device";
if (role.includes("suppress")) return "suppressor";
if (role.includes("lower-parts") || role.includes("lpk")) return "lower-parts";
if (role.includes("trigger")) return "trigger";
if (role.includes("grip")) return "grip";
if (role.includes("safety")) return "safety";
if (role.includes("buffer")) return "buffer";
if (role.includes("stock")) return "stock";
if (role.includes("optic") || role.includes("scope")) return "optic";
if (role.includes("sight")) return "sights";
if (role.includes("mag")) return "magazine";
if (role.includes("weapon-light") || (role.includes("light") && !role.includes("flight")))
return "weapon-light";
if (role.includes("foregrip") || role.includes("grip-vertical")) return "foregrip";
if (role.includes("bipod")) return "bipod";
if (role.includes("sling")) return "sling";
if (role.includes("rail-accessory") || role.includes("rail-attachment")) return "rail-accessory";
if (role.includes("tool")) return "tools";
return null;
};
const normalize = (data: GunbuilderProductFromApi[], isUniversal: boolean): Part[] =>
data
.map((p): Part | null => {
const categoryId = toCategoryId(p.partRole);
if (!categoryId) return null;
if (isUniversal && !UNIVERSAL_CATEGORIES.has(categoryId)) return null;
const buyUrl = p.buyUrl ?? undefined;
return {
id: String(p.id),
categoryId,
name: p.name,
brand: p.brand,
price: p.price ?? 0,
imageUrl: (p.imageUrl as any) ?? (p as any).mainImageUrl ?? undefined,
affiliateUrl: buyUrl,
url: buyUrl,
notes: undefined,
};
})
.filter((p): p is Part => p !== null);
const scopedParts = normalize(scopedData, false);
const universalParts = normalize(universalData, true);
const seen = new Set<string>();
const merged: Part[] = [];
for (const p of [...scopedParts, ...universalParts]) {
const key = `${p.categoryId}:${p.id}`;
if (seen.has(key)) continue;
seen.add(key);
merged.push(p);
}
setParts(merged);
} catch (err: any) {
if (err?.name === "AbortError") return;
setError(err?.message ?? "Failed to load products");
} finally {
setLoading(false);
}
}
fetchProducts();
return () => controller.abort();
}, [platform]);
const summaryCategoryOrder: CategoryId[] = useMemo(
() =>
CATEGORY_GROUPS.flatMap((g) => g.categoryIds).filter((id) =>
CATEGORIES.some((c) => c.id === id)
),
[]
);
const selectedParts = useMemo(() => {
const out: { categoryId: CategoryId; part?: Part }[] = [];
for (const cid of summaryCategoryOrder) {
const partId = build[cid];
const part = partId ? parts.find((p) => p.id === partId && p.categoryId === cid) : undefined;
out.push({ categoryId: cid, part });
}
return out;
}, [build, parts, summaryCategoryOrder]);
const total = useMemo(
() => selectedParts.reduce((sum, row) => sum + (row.part?.price ?? 0), 0),
[selectedParts]
);
const filledCount = useMemo(
() => selectedParts.filter((r) => !!r.part).length,
[selectedParts]
);
return (
<main className="min-h-screen bg-black text-zinc-50">
<div className="mx-auto max-w-6xl px-4 py-6 lg:py-10">
{/* Header */}
<header className="mb-6">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
BATTL BUILDERS
</p>
<h1 className="mt-1 text-2xl md:text-3xl font-semibold tracking-tight">
Build <span className="text-amber-300">Summary</span>
</h1>
<p className="mt-2 text-sm text-zinc-400 max-w-2xl">
Snapshot of your current selections. (V1: data resolves from your local build state + live products.)
</p>
<div className="mt-3 text-xs text-zinc-500">
Platform: <span className="text-zinc-200 font-semibold">{platform}</span>
</div>
</div>
<div className="flex flex-col items-end gap-2">
<div className="text-right">
<div className="text-[10px] uppercase tracking-[0.16em] text-zinc-500">
Total
</div>
<div className="text-2xl font-semibold text-amber-300">
{formatPrice(total)}
</div>
<div className="text-xs text-zinc-500 mt-1">
{filledCount} / {summaryCategoryOrder.length} selected
</div>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={() => {
const qp = new URLSearchParams();
qp.set("platform", platform);
router.push(`/builder?${qp.toString()}`);
}}
className="rounded-md border border-zinc-700 bg-zinc-900/70 px-3 py-2 text-xs font-semibold text-zinc-200 hover:bg-zinc-800 hover:border-zinc-600 transition-colors"
>
Back to Builder
</button>
<Link
href={{ pathname: "/builder", query: platform ? { platform } : {} }}
className="rounded-md bg-amber-400 px-3 py-2 text-xs font-semibold text-black hover:bg-amber-300 transition-colors"
>
Edit Build
</Link>
</div>
</div>
</div>
</header>
{/* Body */}
<section className="rounded-lg border border-zinc-800 bg-zinc-950/60 p-4 md:p-6">
{loading ? (
<p className="py-10 text-center text-sm text-zinc-500">Loading</p>
) : error ? (
<p className="py-10 text-center text-sm text-red-400">
{error} check that the Ballistic API is running.
</p>
) : (
<div className="space-y-4">
{CATEGORY_GROUPS.map((group) => {
const rows = group.categoryIds
.filter((cid) => summaryCategoryOrder.includes(cid))
.map((cid) => {
const cat = CATEGORIES.find((c) => c.id === cid);
const partId = build[cid];
const part = partId
? parts.find((p) => p.id === partId && p.categoryId === cid)
: undefined;
return { cid, cat, part };
});
return (
<div key={group.id} className="rounded-md border border-zinc-800 bg-zinc-950/80">
<div className="border-b border-zinc-800 px-4 py-3">
<div className="text-sm font-semibold text-zinc-100">{group.label}</div>
{group.description && (
<div className="mt-1 text-xs text-zinc-500">{group.description}</div>
)}
</div>
<div className="divide-y divide-zinc-900">
{rows.map(({ cid, cat, part }) => (
<div key={cid} className="flex items-start justify-between gap-4 px-4 py-3">
<div className="min-w-0">
<div className="text-xs font-semibold text-zinc-300">
{cat?.name ?? cid}
</div>
{part ? (
<div className="mt-1 text-sm text-zinc-100 truncate">
<span className="text-zinc-400">{part.brand}</span>{" "}
{part.name}
</div>
) : (
<div className="mt-1 text-sm text-zinc-600">
Not selected
</div>
)}
</div>
<div className="shrink-0 text-right">
<div className="text-xs text-zinc-500">Price</div>
<div className={`text-sm font-semibold ${part ? "text-amber-300" : "text-zinc-600"}`}>
{part ? formatPrice(part.price) : "—"}
</div>
<div className="mt-2 flex justify-end gap-2">
<Link
href={{
pathname: `/parts/p/${platform}/${cid}`,
query: platform ? { platform } : {},
}}
className="inline-flex items-center justify-center rounded-md border border-zinc-700 bg-zinc-900/70 px-2.5 py-1.5 hover:bg-zinc-800 hover:border-zinc-600 transition-colors"
title={part ? "Change part" : "Choose part"}
aria-label={part ? "Change part" : "Choose part"}
>
<Pencil className="h-3.5 w-3.5 text-zinc-300" />
</Link>
{part?.url ? (
<a
href={part.url}
target="_blank"
rel="noreferrer"
className="inline-flex items-center justify-center rounded-md bg-amber-400 px-2.5 py-1.5 hover:bg-amber-300 transition-colors"
title="Buy part"
aria-label="Buy part"
>
<Link2 className="h-3.5 w-3.5 text-black" />
</a>
) : (
<span className="inline-flex items-center justify-center rounded-md border border-zinc-800 bg-zinc-950 px-2.5 py-1.5 text-[11px] text-zinc-600">
</span>
)}
</div>
</div>
</div>
))}
</div>
</div>
);
})}
</div>
)}
</section>
<div className="mt-4 text-center text-xs text-zinc-600">
Tip: If you landed here from a shared link, it uses <code>?build=</code> first; otherwise it reads your local build.
</div>
</div>
</main>
);
}
+68 -138
View File
@@ -6,9 +6,17 @@ import { useSearchParams, useRouter } from "next/navigation";
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 "@/data/partRoleMappings";
import { PART_ROLE_TO_CATEGORY } from "@/lib/catalogMappings";
import { Pencil, Link2, Square, CheckSquare } from "lucide-react";
// ✅ Centralized overlap rules + helpers
import OverlapChip from "@/components/builder/OverlapChip";
import {
getOverlapChipsForCategory,
getSelectionHints,
type BuildState,
} from "@/lib/buildOverlaps";
type GunbuilderProductFromApi = {
id: number; // backend numeric id
name: string;
@@ -25,8 +33,6 @@ const PLATFORMS = ["AR-15", "AR-10", "AR-9"] as const;
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
type BuildState = Partial<Record<CategoryId, string>>; // categoryId -> partId
const STORAGE_KEY = "gunbuilder-build-state";
// Categories that should NOT be filtered by platform (universal accessories / gear)
@@ -103,33 +109,6 @@ const CATEGORY_GROUPS: {
},
];
// ===== Build rules: complete assemblies make sub-parts unnecessary =====
const COMPLETE_UPPER_CATEGORY: CategoryId = "complete-upper";
const COMPLETE_LOWER_CATEGORY: CategoryId = "complete-lower";
// If a complete upper is selected, these categories are considered "included".
const UPPER_INCLUDED_CATEGORIES = new Set<CategoryId>([
"upper-receiver",
"bcg",
"barrel",
"gas-block",
"gas-tube",
"muzzle-device",
"suppressor",
"handguard",
"charging-handle",
]);
const LOWER_INCLUDED_CATEGORIES = new Set<CategoryId>([
"lower-receiver",
"lower-parts",
"trigger",
"grip",
"safety",
"buffer",
"stock",
]);
export default function GunbuilderPage() {
const searchParams = useSearchParams();
const router = useRouter();
@@ -305,97 +284,25 @@ export default function GunbuilderPage() {
[selectedParts]
);
// ---------- Selection logic ----------
const handleSelectPart = useCallback(
(
categoryId: CategoryId,
partId: string,
opts?: { confirm?: boolean }
) => {
const shouldConfirm = opts?.confirm !== false;
(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).
if (shouldConfirm && typeof window !== "undefined") {
if (categoryId === COMPLETE_UPPER_CATEGORY) {
const toClear = Array.from(UPPER_INCLUDED_CATEGORIES).filter(
(cid) => !!build[cid]
);
if (toClear.length > 0) {
const labels = toClear
.map((cid) => CATEGORIES.find((c) => c.id === cid)?.name ?? cid)
.join(", ");
const warn = (msg: string) => {
setShareStatus(msg);
window.setTimeout(() => setShareStatus(null), 4000);
};
const ok = window.confirm(
`Selecting a Complete Upper will remove your currently selected upper parts:\n\n${labels}\n\nContinue?`
);
if (!ok) return;
}
}
const hints = getSelectionHints(categoryId, build);
if (hints.length > 0) warn(hints[0]);
if (categoryId === COMPLETE_LOWER_CATEGORY) {
const toClear = Array.from(LOWER_INCLUDED_CATEGORIES).filter(
(cid) => !!build[cid]
);
if (toClear.length > 0) {
const labels = toClear
.map((cid) => CATEGORIES.find((c) => c.id === cid)?.name ?? cid)
.join(", ");
const ok = window.confirm(
`Selecting a Complete Lower will remove your currently selected lower parts:\n\n${labels}\n\nContinue?`
);
if (!ok) return;
}
}
if (
UPPER_INCLUDED_CATEGORIES.has(categoryId) &&
!!build[COMPLETE_UPPER_CATEGORY]
) {
const ok = window.confirm(
`Selecting this part will remove your selected Complete Upper. Continue?`
);
if (!ok) return;
}
if (
LOWER_INCLUDED_CATEGORIES.has(categoryId) &&
!!build[COMPLETE_LOWER_CATEGORY]
) {
const ok = window.confirm(
`Selecting this part will remove your selected Complete Lower. Continue?`
);
if (!ok) return;
}
}
setBuild((prev) => {
const updated: BuildState = {
...prev,
[categoryId]: partId,
};
if (categoryId === COMPLETE_UPPER_CATEGORY) {
for (const cid of UPPER_INCLUDED_CATEGORIES) {
delete updated[cid];
}
}
if (UPPER_INCLUDED_CATEGORIES.has(categoryId)) {
delete updated[COMPLETE_UPPER_CATEGORY];
}
if (categoryId === COMPLETE_LOWER_CATEGORY) {
for (const cid of LOWER_INCLUDED_CATEGORIES) {
delete updated[cid];
}
}
if (LOWER_INCLUDED_CATEGORIES.has(categoryId)) {
delete updated[COMPLETE_LOWER_CATEGORY];
}
return updated;
});
// ✅ Only set the selection. No deletes. No clearing.
setBuild((prev) => ({
...prev,
[categoryId]: partId,
}));
},
[build]
);
@@ -536,22 +443,18 @@ export default function GunbuilderPage() {
const removeParam = searchParams.get("remove");
const qpPlatform = searchParams.get("platform");
// Build a unique key for this action so we only apply it once.
const actionKey = `${selectParam ?? ""}|${removeParam ?? ""}|${
qpPlatform ?? ""
}`;
// If no action, clear the ref and do nothing.
if (!selectParam && !removeParam) {
processedActionKeyRef.current = "";
return;
}
// Prevent infinite loops: if we already processed this exact actionKey, stop.
if (processedActionKeyRef.current === actionKey) return;
processedActionKeyRef.current = actionKey;
// Apply actions
if (selectParam) {
const [categoryIdRaw, partId] = selectParam.split(":");
const requestedCategoryId = categoryIdRaw as CategoryId;
@@ -573,7 +476,6 @@ export default function GunbuilderPage() {
const nextPlatform = isValidPlatform(qpPlatform) ? qpPlatform : platform;
// Clean URL (remove select/remove) but ONLY if needed.
const nextUrl = `/builder?platform=${encodeURIComponent(nextPlatform)}`;
if (typeof window !== "undefined") {
const current = `${window.location.pathname}${window.location.search}`;
@@ -613,7 +515,6 @@ export default function GunbuilderPage() {
}
}, [build]);
// Use group ordering for the summary as well
const summaryCategoryOrder: CategoryId[] = useMemo(
() =>
CATEGORY_GROUPS.flatMap((group) => group.categoryIds).filter((id) =>
@@ -812,7 +713,9 @@ export default function GunbuilderPage() {
}}
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
@@ -828,7 +731,8 @@ export default function GunbuilderPage() {
Share Your Build
</h3>
<p className="mt-1 text-[0.7rem] text-zinc-500">
Share this link to let others view your build or bookmark it to come back later.
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">
@@ -903,8 +807,9 @@ export default function GunbuilderPage() {
{/* Layout */}
<section className="rounded-lg border border-zinc-800 bg-zinc-950/60 p-3 md:p-4">
<p className="mb-4 text-xs text-zinc-500">
Work top-down through the major sections. Each row shows your current pick for that part type,
with price and a direct buy linkor a quick way to choose a part if you haven&apos;t picked one yet.
Work top-down through the major sections. Each row shows your current
pick for that part type, with price and a direct buy linkor a quick
way to choose a part if you haven&apos;t picked one yet.
</p>
{loading && (
@@ -912,7 +817,8 @@ export default function GunbuilderPage() {
)}
{error && !loading && (
<p className="text-sm text-red-400">
{error} check that the Ballistic API is running and CORS is configured.
{error} check that the Ballistic API is running and CORS is
configured.
</p>
)}
@@ -967,12 +873,14 @@ 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;
@@ -987,10 +895,28 @@ export default function GunbuilderPage() {
<div className="font-medium text-zinc-100">
{category.name}
</div>
{selectedPart ? (
<div className="text-[0.7rem] text-zinc-500 line-clamp-1">
{selectedPart.name}
</div>
<>
<div className="text-[0.7rem] text-zinc-500 line-clamp-1">
{selectedPart.name}
</div>
{/* ✅ Category overlap chips */}
<div className="mt-1 flex flex-wrap gap-1.5">
{getOverlapChipsForCategory(
category.id as CategoryId,
build
).map((c) => (
<OverlapChip
key={c.key}
tone={c.tone}
label={c.label}
detail={c.detail}
/>
))}
</div>
</>
) : hasParts ? (
<div className="text-[0.7rem] text-zinc-600">
No part selected
@@ -1031,8 +957,10 @@ export default function GunbuilderPage() {
<>
<Link
href={{
pathname: `/parts/${category.id}`,
query: platform ? { platform } : {},
pathname: `/parts/p/${platform}/${category.id}`,
query: platform
? { platform }
: {},
}}
className="hidden md:inline-flex items-center justify-center rounded-md border border-zinc-700 bg-zinc-900/70 px-2 py-1 hover:bg-zinc-800 hover:border-zinc-600 transition-colors"
aria-label="Change part"
@@ -1055,8 +983,10 @@ export default function GunbuilderPage() {
) : hasParts ? (
<Link
href={{
pathname: `/parts/${category.id}`,
query: platform ? { platform } : {},
pathname: `/parts/p/${platform}/${category.id}`,
query: platform
? { platform }
: {},
}}
className="inline-flex items-center gap-1.5 whitespace-nowrap rounded-md border border-zinc-700 bg-zinc-900/70 px-3 py-1.5 text-xs font-medium text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600 transition-colors"
>
+77 -39
View File
@@ -1,10 +1,11 @@
"use client";
import { useMemo, useState, useEffect } from "react";
import { useMemo, useStat, useEffect } from "react";
import Link from "next/link";
import { useSearchParams, useRouter } from "next/navigation";
import { CATEGORIES } from "@/data/gunbuilderParts";
import type { CategoryId, Part } from "@/types/gunbuilder";
import { buildDetailHref } from "@/app/parts/_components/buildDetailHref";
type BuildState = Partial<Record<CategoryId, string>>; // categoryId -> partId
const STORAGE_KEY = "gunbuilder-build-state";
@@ -23,7 +24,7 @@ type GunbuilderProductFromApi = {
buyUrl: string | null;
};
// Map backend partRole -> CategoryId (same as /gunbuilder page)
// Map backend partRole -> CategoryId (align with builder categories)
const PART_ROLE_TO_CATEGORY: Record<string, CategoryId> = {
"upper-receiver": "upper",
barrel: "barrel",
@@ -34,6 +35,12 @@ const PART_ROLE_TO_CATEGORY: Record<string, CategoryId> = {
sight: "sights",
};
// Inverse: CategoryId -> backend partRole (for building detail URLs)
const CATEGORY_TO_PART_ROLE: Partial<Record<CategoryId, string>> =
Object.fromEntries(
Object.entries(PART_ROLE_TO_CATEGORY).map(([role, cat]) => [cat, role]),
) as Partial<Record<CategoryId, string>>;
// Encode build state to URL-friendly string
function encodeBuildState(build: BuildState): string {
const entries = Object.entries(build)
@@ -64,6 +71,9 @@ export default function BuildDetailsPage() {
const searchParams = useSearchParams();
const router = useRouter();
// ✅ Option A: platform is a query param and is passed through to backend
const platform = searchParams.get("platform") ?? "AR-15";
const [build, setBuild] = useState<BuildState>({});
const [shareUrl, setShareUrl] = useState<string>("");
const [copied, setCopied] = useState(false);
@@ -78,23 +88,25 @@ export default function BuildDetailsPage() {
if (buildParam) {
const decoded = decodeBuildState(buildParam);
setBuild(decoded);
// Also save to localStorage
localStorage.setItem(STORAGE_KEY, JSON.stringify(decoded));
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(decoded));
} catch {
// ignore storage failures (private mode, etc.)
}
} else if (typeof window !== "undefined") {
// Load from localStorage
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
try {
const parsed = JSON.parse(stored);
setBuild(parsed);
} catch {
// Invalid stored data
// ignore invalid stored data
}
}
}
}, [searchParams]);
// Fetch live parts from backend (same source as /gunbuilder)
// Fetch live parts from backend (uses selected platform)
useEffect(() => {
const controller = new AbortController();
@@ -104,7 +116,7 @@ export default function BuildDetailsPage() {
setPartsError(null);
const res = await fetch(
`${API_BASE_URL}/api/products/gunbuilder?platform=AR-15`,
`${API_BASE_URL}/api/products?platform=${encodeURIComponent(platform)}`,
{ signal: controller.signal },
);
@@ -120,7 +132,7 @@ export default function BuildDetailsPage() {
if (!categoryId) return null;
return {
id: p.id, // already a string UUID
id: p.id,
categoryId,
name: p.name,
brand: p.brand,
@@ -134,26 +146,29 @@ export default function BuildDetailsPage() {
setParts(normalized);
} catch (err: any) {
if (err.name === "AbortError") return;
setPartsError(err.message ?? "Failed to load products");
if (err?.name === "AbortError") return;
setPartsError(err?.message ?? "Failed to load products");
} finally {
setLoadingParts(false);
}
}
fetchProducts();
return () => controller.abort();
}, []);
}, [platform]);
// Generate shareable URL
// Generate shareable URL (includes platform)
useEffect(() => {
if (typeof window !== "undefined" && Object.keys(build).length > 0) {
const encoded = encodeBuildState(build);
const url = `${window.location.origin}/builder/build?build=${encoded}`;
const url = `${window.location.origin}/builder/build?platform=${encodeURIComponent(
platform,
)}&build=${encoded}`;
setShareUrl(url);
} else if (typeof window !== "undefined") {
setShareUrl("");
}
}, [build]);
}, [build, platform]);
const selectedParts: Part[] = useMemo(() => {
if (parts.length === 0) return [];
@@ -173,31 +188,33 @@ export default function BuildDetailsPage() {
);
const handleCopyLink = async () => {
if (shareUrl) {
try {
await navigator.clipboard.writeText(shareUrl);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
console.error("Failed to copy:", err);
}
if (!shareUrl) return;
try {
await navigator.clipboard.writeText(shareUrl);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
console.error("Failed to copy:", err);
}
};
const handleShare = async () => {
if (navigator.share && shareUrl) {
if (!shareUrl) return;
if (navigator.share) {
try {
await navigator.share({
title: "My AR-15 Build",
text: `Check out my AR-15 build: $${totalPrice.toFixed(2)}`,
title: `My ${platform} Build`,
text: `Check out my ${platform} build: $${totalPrice.toFixed(2)}`,
url: shareUrl,
});
return;
} catch {
handleCopyLink();
// fall back to copy
}
} else {
handleCopyLink();
}
await handleCopyLink();
};
if (!loadingParts && selectedParts.length === 0) {
@@ -212,7 +229,7 @@ export default function BuildDetailsPage() {
You need to select at least one part to view your build.
</p>
<Link
href="/builder"
href={`/builder?platform=${encodeURIComponent(platform)}`}
className="inline-block rounded-md border border-amber-400/60 bg-amber-400/10 px-4 py-2 text-sm font-medium text-amber-200 hover:bg-amber-400/15 transition-colors"
>
Go to Builder
@@ -229,11 +246,12 @@ export default function BuildDetailsPage() {
{/* Header */}
<header className="mb-6">
<Link
href="/builder"
href={`/builder?platform=${encodeURIComponent(platform)}`}
className="text-xs text-zinc-400 hover:text-zinc-300 mb-4 inline-block"
>
Back to The Armory
</Link>
<div className="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
<div>
<p className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
@@ -246,6 +264,9 @@ export default function BuildDetailsPage() {
Review your build, purchase parts from our affiliate partners, or
share your build with others.
</p>
<p className="mt-2 text-xs text-zinc-500">
Platform: <span className="text-zinc-200">{platform}</span>
</p>
</div>
<div className="mt-3 md:mt-0">
@@ -293,6 +314,7 @@ export default function BuildDetailsPage() {
</button>
</div>
</div>
{shareUrl && (
<div className="mt-3 p-3 rounded-md bg-zinc-900/50 border border-zinc-800">
<p className="text-xs text-zinc-500 mb-1">Shareable URL:</p>
@@ -327,6 +349,10 @@ export default function BuildDetailsPage() {
p.categoryId === (category.id as CategoryId),
);
const partRole =
(CATEGORY_TO_PART_ROLE[category.id] as string | undefined) ??
"unknown";
return (
<div
key={category.id}
@@ -337,12 +363,14 @@ export default function BuildDetailsPage() {
<div className="text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500 mb-2">
{category.name}
</div>
{part ? (
<>
<div className="text-lg font-semibold text-zinc-50 mb-1">
<span className="text-zinc-400">{part.brand}</span>{" "}
{part.name}
</div>
<div className="mt-3 flex gap-2 flex-wrap">
{part.url && (
<a
@@ -367,8 +395,14 @@ export default function BuildDetailsPage() {
</svg>
</a>
)}
<Link
href={`/builder/${category.id}/${part.id}`}
href={buildDetailHref({
id: part.id,
name: part.name,
platform,
partRole,
} as any)}
className="inline-flex items-center rounded-md border border-zinc-700 bg-zinc-900/50 px-4 py-2 text-sm font-medium text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600 transition-colors"
>
View Details
@@ -376,11 +410,10 @@ export default function BuildDetailsPage() {
</div>
</>
) : (
<div className="text-sm text-zinc-600">
Not selected
</div>
<div className="text-sm text-zinc-600">Not selected</div>
)}
</div>
{part && (
<div className="text-right">
<div className="text-2xl font-semibold text-amber-300">
@@ -398,17 +431,22 @@ export default function BuildDetailsPage() {
{/* Actions */}
<div className="mt-6 flex flex-col gap-3 sm:flex-row sm:justify-between">
<Link
href="/builder"
href={`/builder?platform=${encodeURIComponent(platform)}`}
className="rounded-md border border-zinc-700 bg-zinc-900/50 px-6 py-3 text-sm font-medium text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600 transition-colors text-center"
>
Edit Build
</Link>
<div className="flex gap-3">
<button
type="button"
onClick={() => {
localStorage.removeItem(STORAGE_KEY);
router.push("/builder");
try {
localStorage.removeItem(STORAGE_KEY);
} catch {
// ignore
}
router.push(`/builder?platform=${encodeURIComponent(platform)}`);
}}
className="rounded-md border border-zinc-700 bg-zinc-900/50 px-6 py-3 text-sm font-medium text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600 transition-colors"
>