From 4c2d767d095246a9140df5171e5b8d594df8cc08 Mon Sep 17 00:00:00 2001 From: Sean Date: Thu, 18 Dec 2025 09:42:25 -0500 Subject: [PATCH] lots of fixes. cant remember them all --- app/(builder)/builder/build/page.tsx | 482 +++++++++++ app/(builder)/builder/page.tsx | 206 ++--- app/(builder)/builder/summary/page.tsx | 116 ++- app/(builder)/layout.tsx | 2 + app/(builder)/parts/[category]/page.tsx | 802 ------------------ .../parts/[partRole]/[productSlug]/page.tsx | 431 ++++++++++ .../_old_prod_details}/data.ts | 0 .../_old_prod_details}/page.tsx | 0 app/(builder)/parts/[partRole]/page.tsx | 14 + .../parts/_components/buildDetailHref.ts | 24 + .../[partRole]/[productSlug]/page.tsx | 589 +++++++++++++ .../parts/p/[platform]/[partRole]/page.tsx | 5 + app/builds/layout.tsx | 30 +- components/PricingHistoryGraph.tsx | 2 +- components/RetailersList.tsx | 2 +- components/builder/OverlapChip.tsx | 34 + components/parts/Filters.tsx | 190 +++++ components/parts/OverlapChip.tsx | 62 ++ components/parts/Pagination.tsx | 39 + components/parts/PartsBrowseClient.tsx | 386 +++++++++ components/parts/PartsGrid.tsx | 162 ++++ components/parts/PartsListPageClient.tsx | 178 ++++ components/parts/PlatformSwitcher.tsx | 60 ++ components/parts/ProductDetailPageClient.tsx | 204 +++++ components/parts/SortBar.tsx | 77 ++ lib/buildOverlaps.ts | 150 ++++ lib/catalog.ts | 90 ++ .../catalogMappings.ts | 1 - 28 files changed, 3342 insertions(+), 996 deletions(-) create mode 100644 app/(builder)/builder/build/page.tsx delete mode 100644 app/(builder)/parts/[category]/page.tsx create mode 100644 app/(builder)/parts/[partRole]/[productSlug]/page.tsx rename app/(builder)/parts/{[category]/[partId] => [partRole]/_old_prod_details}/data.ts (100%) rename app/(builder)/parts/{[category]/[partId] => [partRole]/_old_prod_details}/page.tsx (100%) create mode 100644 app/(builder)/parts/[partRole]/page.tsx create mode 100644 app/(builder)/parts/_components/buildDetailHref.ts create mode 100644 app/(builder)/parts/p/[platform]/[partRole]/[productSlug]/page.tsx create mode 100644 app/(builder)/parts/p/[platform]/[partRole]/page.tsx create mode 100644 components/builder/OverlapChip.tsx create mode 100644 components/parts/Filters.tsx create mode 100644 components/parts/OverlapChip.tsx create mode 100644 components/parts/Pagination.tsx create mode 100644 components/parts/PartsBrowseClient.tsx create mode 100644 components/parts/PartsGrid.tsx create mode 100644 components/parts/PartsListPageClient.tsx create mode 100644 components/parts/PlatformSwitcher.tsx create mode 100644 components/parts/ProductDetailPageClient.tsx create mode 100644 components/parts/SortBar.tsx create mode 100644 lib/buildOverlaps.ts create mode 100644 lib/catalog.ts rename data/partRoleMappings.ts => lib/catalogMappings.ts (97%) diff --git a/app/(builder)/builder/build/page.tsx b/app/(builder)/builder/build/page.tsx new file mode 100644 index 0000000..976253e --- /dev/null +++ b/app/(builder)/builder/build/page.tsx @@ -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>; + +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([ + "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({}); + const [parts, setParts] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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(); + 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 ( +
+
+ {/* Header */} +
+
+
+

+ BATTL BUILDERS +

+

+ Build Summary +

+

+ Snapshot of your current selections. (V1: data resolves from your local build state + live products.) +

+
+ Platform: {platform} +
+
+ +
+
+
+ Total +
+
+ {formatPrice(total)} +
+
+ {filledCount} / {summaryCategoryOrder.length} selected +
+
+ +
+ + + + Edit Build + +
+
+
+
+ + {/* Body */} +
+ {loading ? ( +

Loading…

+ ) : error ? ( +

+ {error} — check that the Ballistic API is running. +

+ ) : ( +
+ {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 ( +
+
+
{group.label}
+ {group.description && ( +
{group.description}
+ )} +
+ +
+ {rows.map(({ cid, cat, part }) => ( +
+
+
+ {cat?.name ?? cid} +
+ {part ? ( +
+ {part.brand}{" "} + {part.name} +
+ ) : ( +
+ Not selected +
+ )} +
+ +
+
Price
+
+ {part ? formatPrice(part.price) : "—"} +
+ +
+ + + + + {part?.url ? ( + + + + ) : ( + + — + + )} +
+
+
+ ))} +
+
+ ); + })} +
+ )} +
+ +
+ Tip: If you landed here from a shared link, it uses ?build= first; otherwise it reads your local build. +
+
+
+ ); +} \ No newline at end of file diff --git a/app/(builder)/builder/page.tsx b/app/(builder)/builder/page.tsx index 5471751..cd9c03a 100644 --- a/app/(builder)/builder/page.tsx +++ b/app/(builder)/builder/page.tsx @@ -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>; // 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([ - "upper-receiver", - "bcg", - "barrel", - "gas-block", - "gas-tube", - "muzzle-device", - "suppressor", - "handguard", - "charging-handle", -]); - -const LOWER_INCLUDED_CATEGORIES = new Set([ - "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

- 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.

@@ -903,8 +807,9 @@ export default function GunbuilderPage() { {/* Layout */}

- Work top-down through the major sections. Each row shows your current pick for that part type, - with price and a direct buy link—or a quick way to choose a part if you haven'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 link—or a quick + way to choose a part if you haven't picked one yet.

{loading && ( @@ -912,7 +817,8 @@ export default function GunbuilderPage() { )} {error && !loading && (

- {error} — check that the Ballistic API is running and CORS is configured. + {error} — check that the Ballistic API is running and CORS is + configured.

)} @@ -967,12 +873,14 @@ export default function GunbuilderPage() { {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() {
{category.name}
+ {selectedPart ? ( -
- {selectedPart.name} -
+ <> +
+ {selectedPart.name} +
+ + {/* ✅ Category overlap chips */} +
+ {getOverlapChipsForCategory( + category.id as CategoryId, + build + ).map((c) => ( + + ))} +
+ ) : hasParts ? (
No part selected @@ -1031,8 +957,10 @@ export default function GunbuilderPage() { <> diff --git a/app/(builder)/builder/summary/page.tsx b/app/(builder)/builder/summary/page.tsx index cfa835a..236a873 100644 --- a/app/(builder)/builder/summary/page.tsx +++ b/app/(builder)/builder/summary/page.tsx @@ -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>; // 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 = { "upper-receiver": "upper", barrel: "barrel", @@ -34,6 +35,12 @@ const PART_ROLE_TO_CATEGORY: Record = { sight: "sights", }; +// Inverse: CategoryId -> backend partRole (for building detail URLs) +const CATEGORY_TO_PART_ROLE: Partial> = + Object.fromEntries( + Object.entries(PART_ROLE_TO_CATEGORY).map(([role, cat]) => [cat, role]), + ) as Partial>; + // 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({}); const [shareUrl, setShareUrl] = useState(""); 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.

Go to Builder @@ -229,11 +246,12 @@ export default function BuildDetailsPage() { {/* Header */}
← Back to The Armory +

@@ -246,6 +264,9 @@ export default function BuildDetailsPage() { Review your build, purchase parts from our affiliate partners, or share your build with others.

+

+ Platform: {platform} +

@@ -293,6 +314,7 @@ export default function BuildDetailsPage() {
+ {shareUrl && (

Shareable URL:

@@ -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 (
{category.name}
+ {part ? ( <>
{part.brand}{" "} {part.name}
+
{part.url && ( )} + View Details @@ -376,11 +410,10 @@ export default function BuildDetailsPage() {
) : ( -
- Not selected -
+
Not selected
)}
+ {part && (
@@ -398,17 +431,22 @@ export default function BuildDetailsPage() { {/* Actions */}
Edit Build +
- -
- )} -
-
- -
-
- {!loading && !error && parts.length > 0 && ( - - )} - -
- {!loading && !error && parts.length > 0 && ( -
-
- Showing{" "} - - {visibleRange.start}-{visibleRange.end} - {" "} - of{" "} - - {filteredParts.length} - {" "} - matching parts -
-
-
- -
- setSearchQuery(e.target.value)} - placeholder={`Search ${category.name.toLowerCase()}...`} - className="rounded-md border border-zinc-700 bg-zinc-900/70 px-2 py-1 pr-6 text-xs text-zinc-200 focus:outline-none focus:ring-1 focus:ring-amber-400" - /> - {searchQuery && ( - - )} -
-
-
- - -
-
- - -
-
-
- )} - - {loading ? ( -

- Loading {category.name.toLowerCase()}… -

- ) : error ? ( -

- {error} -

- ) : filteredParts.length === 0 ? ( -

- No parts available for this category yet. -

- ) : viewMode === "card" ? ( -
- {paginatedParts.map((part) => { - const isSelected = build[categoryId] === part.id; - - return ( -
-
-
-
- {part.brand}{" "} - — {part.name} -
-
-
- ${part.price.toFixed(2)} -
-
-
- - View Details - - -
-
- ); - })} -
- ) : ( - <> -
- Part - Brand - Price - Actions -
- -
- {paginatedParts.map((part) => { - const isSelected = build[categoryId] === part.id; - - return ( -
-
-
-
- {part.name} -
-
-
- {part.brand} -
-
- ${part.price.toFixed(2)} -
-
- - View Details - - -
-
-
- ); - })} -
- - )} - - {filteredParts.length > 0 && totalPages > 1 && ( -
-
- Page{" "} - - {currentPage} - {" "} - of{" "} - - {totalPages} - -
-
- - -
-
- )} -
-
-
-
- - ); -} \ No newline at end of file diff --git a/app/(builder)/parts/[partRole]/[productSlug]/page.tsx b/app/(builder)/parts/[partRole]/[productSlug]/page.tsx new file mode 100644 index 0000000..1f824b9 --- /dev/null +++ b/app/(builder)/parts/[partRole]/[productSlug]/page.tsx @@ -0,0 +1,431 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import Link from "next/link"; +import { useParams, useRouter, useSearchParams } from "next/navigation"; +import type { CategoryId } from "@/types/gunbuilder"; +import { + PART_ROLE_TO_CATEGORY, + normalizePartRole, +} from "@/lib/catalogMappings"; + +type GunbuilderProductFromApi = { + id: number; + name: string; + brand: string; + platform: string; + partRole: string; + price: number | null; + imageUrl: string | null; + buyUrl: string | null; + inStock?: boolean | null; +}; + +type BuildState = Partial>; + +const API_BASE_URL = + process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080"; + +const STORAGE_KEY = "gunbuilder-build-state"; + +const PLATFORMS = ["AR-15", "AR-10", "AR-9"] as const; + +function extractNumericId(productSlug: string): number | null { + if (!productSlug) return null; + const first = productSlug.split("-")[0]; + const n = Number(first); + return Number.isFinite(n) && n > 0 ? n : null; +} + +function formatPrice(price: number | null | undefined): string { + if (price == null) return "—"; + return `$${price.toFixed(2)}`; +} + +export default function ProductDetailsPage() { + const params = useParams(); + const router = useRouter(); + const searchParams = useSearchParams(); + + // NEW ROUTE PARAMS: + const platformParam = String(params.platform ?? ""); + const partRoleParam = String(params.partRole ?? ""); + const productSlug = String(params.productSlug ?? ""); + + // Platform comes from path segment (fallback to ?platform just in case) + const platform = + platformParam || + (searchParams.get("platform") as string) || + "AR-15"; + + const normalizedRole = useMemo( + () => normalizePartRole(partRoleParam), + [partRoleParam] + ); + + const categoryId = useMemo(() => { + return PART_ROLE_TO_CATEGORY[partRoleParam] ?? PART_ROLE_TO_CATEGORY[normalizedRole] ?? null; + }, [partRoleParam, normalizedRole]); + + const numericId = useMemo( + () => extractNumericId(productSlug), + [productSlug] + ); + + // Read-only build state (builder page owns writes) + const [build] = useState(() => { + if (typeof window === "undefined") return {}; + try { + const stored = window.localStorage.getItem(STORAGE_KEY); + return stored ? (JSON.parse(stored) as BuildState) : {}; + } catch { + return {}; + } + }); + + const selectedPartIdForCategory = categoryId ? build?.[categoryId] : undefined; + const isSelected = + !!categoryId && + selectedPartIdForCategory === (numericId ? String(numericId) : ""); + + const [product, setProduct] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // If platform segment is missing/empty, normalize into the new route (not /builder) + useEffect(() => { + if (platformParam) return; + + const qp = new URLSearchParams(searchParams.toString()); + qp.set("platform", platform || "AR-15"); + + router.replace( + `/parts/p/${encodeURIComponent(platform || "AR-15")}/${encodeURIComponent( + partRoleParam + )}/${encodeURIComponent(productSlug)}?${qp.toString()}` + ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [platformParam, platform, partRoleParam, productSlug, router, searchParams]); + + // Fetch product + useEffect(() => { + if (!numericId) { + setError("Invalid product id."); + setLoading(false); + return; + } + + const controller = new AbortController(); + + async function fetchProduct() { + try { + setLoading(true); + setError(null); + + const url = `${API_BASE_URL}/api/products/${numericId}`; + const res = await fetch(url, { signal: controller.signal }); + + if (!res.ok) { + throw new Error(`Failed to load product (${res.status})`); + } + + const data: GunbuilderProductFromApi = await res.json(); + setProduct(data); + } catch (err: any) { + if (err?.name === "AbortError") return; + setError(err?.message ?? "Failed to load product"); + } finally { + setLoading(false); + } + } + + fetchProduct(); + + return () => controller.abort(); + }, [numericId]); + + const handleTogglePart = () => { + if (!numericId) return; + + // If mapping is missing, don’t send a bogus builder action + if (!categoryId) { + alert( + `No CategoryId mapping found for partRole "${partRoleParam}". Add it in catalogMappings.` + ); + return; + } + + const qp = new URLSearchParams(); + qp.set("platform", platform || "AR-15"); + + if (isSelected) { + qp.set("remove", String(categoryId)); + router.push(`/builder?${qp.toString()}`); + return; + } + + qp.set("select", `${categoryId}:${numericId}`); + router.push(`/builder?${qp.toString()}`); + }; + + if (!categoryId) { + return ( +
+
+
+

+ Unknown Part Role +

+

+ No mapping found for {partRoleParam}. + Add it to catalogMappings. +

+ + Back to Parts List + +
+
+
+ ); + } + + return ( +
+
+ {/* Breadcrumbs */} +
+
+ + The Armory + + / + + Parts + + / + Details +
+ +
+
+

+ BATTL BUILDERS +

+

+ Product Details +

+

+ Live product data pulled from your Ballistic backend. Add it to your build + or jump back to compare options. +

+
+ + {/* Platform switch (updates NEW route) */} +
+ + +
+
+
+ + {/* Body */} +
+ {loading ? ( +

Loading product…

+ ) : error ? ( +

+ {error} — check that the Ballistic API is running. +

+ ) : !product ? ( +

Product not found.

+ ) : ( +
+ {/* Left: image */} +
+
+ {product.imageUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + {product.name} + ) : ( +
+ No image +
+ )} +
+ +
+ + Stock + + + {product.inStock === false ? "Out of stock" : "In stock"} + +
+ +
+ + Back to List + + +
+
+ + {/* Right: details */} +
+
+
+
+

+ {product.brand} +

+

+ {product.name} +

+ +
+ + Platform:{" "} + + {product.platform || platform} + + + + Role:{" "} + + {product.partRole || partRoleParam} + + + + ID:{" "} + + {product.id} + + +
+
+ +
+
+ Current price +
+
+ {formatPrice(product.price)} +
+
+
+ +
+ + + {product.buyUrl ? ( + + Buy from Merchant → + + ) : ( + + No buy link available + + )} +
+ +
+

+ Notes +

+

+ This details page is intentionally “thin” for now — once we wire in + offers, price history, and richer product fields, this section becomes + the spec + comparison hub. +

+
+
+ +
+ + Tip: use “Back to List” to compare multiple parts quickly. + + + Platform comes from the URL segment:{" "} + /parts/p/{platform}/... + +
+
+
+ )} +
+
+
+ ); +} \ No newline at end of file diff --git a/app/(builder)/parts/[category]/[partId]/data.ts b/app/(builder)/parts/[partRole]/_old_prod_details/data.ts similarity index 100% rename from app/(builder)/parts/[category]/[partId]/data.ts rename to app/(builder)/parts/[partRole]/_old_prod_details/data.ts diff --git a/app/(builder)/parts/[category]/[partId]/page.tsx b/app/(builder)/parts/[partRole]/_old_prod_details/page.tsx similarity index 100% rename from app/(builder)/parts/[category]/[partId]/page.tsx rename to app/(builder)/parts/[partRole]/_old_prod_details/page.tsx diff --git a/app/(builder)/parts/[partRole]/page.tsx b/app/(builder)/parts/[partRole]/page.tsx new file mode 100644 index 0000000..768646b --- /dev/null +++ b/app/(builder)/parts/[partRole]/page.tsx @@ -0,0 +1,14 @@ +import { redirect } from "next/navigation"; + +export default async function PartsRoleRedirectPage({ + params, + searchParams, +}: { + params: { partRole: string }; + searchParams?: { platform?: string }; +}) { + const partRole = params.partRole; + const platform = searchParams?.platform ?? "AR-15"; + + redirect(`/parts/p/${encodeURIComponent(platform)}/${encodeURIComponent(partRole)}`); +} \ No newline at end of file diff --git a/app/(builder)/parts/_components/buildDetailHref.ts b/app/(builder)/parts/_components/buildDetailHref.ts new file mode 100644 index 0000000..e48f584 --- /dev/null +++ b/app/(builder)/parts/_components/buildDetailHref.ts @@ -0,0 +1,24 @@ +// app/(builder)/parts/_components/buildDetailHref.ts + +const toSlug = (str: string) => + str + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/(^-|-$)/g, ""); + +export type BuildDetailInput = { + id: string; + name: string; + brand?: string; +}; + +export function buildDetailHref( + platform: string, + partRole: string, + p: BuildDetailInput +) { + const slug = toSlug(`${p.brand ?? ""} ${p.name ?? ""}`); + return `/parts/p/${encodeURIComponent(platform)}/${encodeURIComponent( + partRole + )}/${p.id}-${slug}`; +} \ No newline at end of file diff --git a/app/(builder)/parts/p/[platform]/[partRole]/[productSlug]/page.tsx b/app/(builder)/parts/p/[platform]/[partRole]/[productSlug]/page.tsx new file mode 100644 index 0000000..97154c6 --- /dev/null +++ b/app/(builder)/parts/p/[platform]/[partRole]/[productSlug]/page.tsx @@ -0,0 +1,589 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import Link from "next/link"; +import { useParams, useRouter, useSearchParams } from "next/navigation"; + +import type { CategoryId } from "@/types/gunbuilder"; +import { PART_ROLE_TO_CATEGORY, normalizePartRole } from "@/lib/catalogMappings"; + +type OfferFromApi = { + merchantName?: string | null; + price?: number | null; + buyUrl?: string | null; + inStock?: boolean | null; + lastUpdated?: string | null; +}; + +type GunbuilderProductFromApi = { + id: number; + name: string; + brand: string; + platform: string; + partRole: string; + price: number | null; + + // image fields can vary depending on endpoint/version + imageUrl?: string | null; + mainImageUrl?: string | null; + + // single best-link (legacy) + buyUrl?: string | null; + + // stock info + inStock?: boolean | null; + + // richer details (optional) + shortDescription?: string | null; + description?: string | null; + + // offers (optional) + offers?: OfferFromApi[] | null; +}; + +type BuildState = Partial>; + +const API_BASE_URL = + process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080"; + +const STORAGE_KEY = "gunbuilder-build-state"; + +const PLATFORMS = ["AR-15", "AR-10", "AR-9"] as const; + +function extractNumericId(productSlug: string): number | null { + if (!productSlug) return null; + const first = productSlug.split("-")[0]; + const n = Number(first); + return Number.isFinite(n) && n > 0 ? n : null; +} + +function formatPrice(price: number | null | undefined): string { + if (price == null) return "—"; + return `$${price.toFixed(2)}`; +} + +function sortOffers(offers: OfferFromApi[]): OfferFromApi[] { + return [...offers].sort((a, b) => { + const ap = a.price ?? Number.POSITIVE_INFINITY; + const bp = b.price ?? Number.POSITIVE_INFINITY; + if (ap !== bp) return ap - bp; + + // in-stock first + const aStock = a.inStock === false ? 1 : 0; + const bStock = b.inStock === false ? 1 : 0; + if (aStock !== bStock) return aStock - bStock; + + // merchant name + return (a.merchantName ?? "").localeCompare(b.merchantName ?? ""); + }); +} + +export default function ProductDetailsPage() { + const params = useParams(); + const router = useRouter(); + const searchParams = useSearchParams(); + + // Route params + const platformParam = String(params.platform ?? ""); + const partRoleParam = String(params.partRole ?? ""); + const productSlug = String(params.productSlug ?? ""); + + // Platform comes from path segment (fallback to ?platform just in case) + const platform = + platformParam || (searchParams.get("platform") as string) || "AR-15"; + + const normalizedRole = useMemo( + () => normalizePartRole(partRoleParam), + [partRoleParam] + ); + + const categoryId = useMemo(() => { + return ( + PART_ROLE_TO_CATEGORY[partRoleParam] ?? + PART_ROLE_TO_CATEGORY[normalizedRole] ?? + null + ); + }, [partRoleParam, normalizedRole]); + + const numericId = useMemo(() => extractNumericId(productSlug), [productSlug]); + + // Read-only build state (builder page owns writes) + const [build, setBuild] = useState(() => { + if (typeof window === "undefined") return {}; + try { + const stored = window.localStorage.getItem(STORAGE_KEY); + return stored ? (JSON.parse(stored) as BuildState) : {}; + } catch { + return {}; + } + }); + + // Keep build in sync if user changes build elsewhere + useEffect(() => { + if (typeof window === "undefined") return; + const onStorage = (e: StorageEvent) => { + if (e.key !== STORAGE_KEY) return; + try { + const stored = window.localStorage.getItem(STORAGE_KEY); + setBuild(stored ? (JSON.parse(stored) as BuildState) : {}); + } catch { + // ignore + } + }; + window.addEventListener("storage", onStorage); + return () => window.removeEventListener("storage", onStorage); + }, []); + + const selectedPartIdForCategory = categoryId ? build?.[categoryId] : undefined; + const isSelected = + !!categoryId && selectedPartIdForCategory === (numericId ? String(numericId) : ""); + + const [product, setProduct] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // If platform segment is missing/empty, normalize into the new route + useEffect(() => { + if (platformParam) return; + + const qp = new URLSearchParams(searchParams.toString()); + qp.set("platform", platform || "AR-15"); + + router.replace( + `/parts/p/${encodeURIComponent(platform || "AR-15")}/${encodeURIComponent( + partRoleParam + )}/${encodeURIComponent(productSlug)}?${qp.toString()}` + ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [platformParam, platform, partRoleParam, productSlug, router, searchParams]); + + // Fetch product + useEffect(() => { + if (!numericId) { + setError("Invalid product id."); + setLoading(false); + return; + } + + const controller = new AbortController(); + + async function fetchProduct() { + try { + setLoading(true); + setError(null); + + const url = `${API_BASE_URL}/api/products/${numericId}`; + const res = await fetch(url, { signal: controller.signal }); + + if (!res.ok) { + throw new Error(`Failed to load product (${res.status})`); + } + + const data: GunbuilderProductFromApi = await res.json(); + setProduct(data); + } catch (err: any) { + if (err?.name === "AbortError") return; + setError(err?.message ?? "Failed to load product"); + } finally { + setLoading(false); + } + } + + fetchProduct(); + return () => controller.abort(); + }, [numericId]); + + const handleTogglePart = () => { + if (!numericId) return; + + // If mapping is missing, don’t send a bogus builder action + if (!categoryId) { + alert( + `No CategoryId mapping found for partRole "${partRoleParam}". Add it in catalogMappings.` + ); + return; + } + + const qp = new URLSearchParams(); + qp.set("platform", platform || "AR-15"); + + if (isSelected) { + qp.set("remove", String(categoryId)); + router.push(`/builder?${qp.toString()}`); + return; + } + + qp.set("select", `${categoryId}:${numericId}`); + router.push(`/builder?${qp.toString()}`); + }; + + const imageUrl = product?.imageUrl ?? product?.mainImageUrl ?? null; + + // Offers normalization: + // - If product.offers exists, use it. + // - Otherwise, fall back to a single “offer” from product.buyUrl/product.price. + const offers = useMemo(() => { + if (!product) return []; + + const raw = (product.offers ?? []).filter(Boolean) as OfferFromApi[]; + if (raw.length > 0) return sortOffers(raw); + + if (product.buyUrl) { + return sortOffers([ + { + merchantName: "Merchant", + price: product.price, + buyUrl: product.buyUrl, + inStock: product.inStock ?? true, + }, + ]); + } + + return []; + }, [product]); + + if (!categoryId) { + return ( +
+
+
+

+ Unknown Part Role +

+

+ No mapping found for{" "} + {partRoleParam}. Add it to{" "} + catalogMappings. +

+ + Back to Parts List + +
+
+
+ ); + } + + return ( +
+
+ {/* Breadcrumbs */} +
+
+ + The Armory + + / + + Parts + + / + Details +
+ +
+
+

+ BATTL BUILDERS +

+

+ Product Details +

+

+ Offers, pricing placeholders, and builder actions — all on the canonical + /parts/p route. +

+
+ + {/* Platform switch (updates NEW route) */} +
+ + +
+
+
+ + {/* Body */} +
+ {loading ? ( +

+ Loading product… +

+ ) : error ? ( +

+ {error} — check that the Ballistic API is running. +

+ ) : !product ? ( +

+ Product not found. +

+ ) : ( +
+ {/* Left: image + core actions */} +
+
+
+ {imageUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + {product.name} + ) : ( +
+ No image +
+ )} +
+ +
+ + Stock + + + {product.inStock === false ? "Out of stock" : "In stock"} + +
+ +
+ + Back to List + + + +
+
+ + {/* Pricing history placeholder */} +
+
+

+ Price History +

+ placeholder +
+
+ Pricing graph will go here +
+

+ We’ll wire this to price snapshots once the offer model is stable. +

+
+
+ + {/* Right: details + offers */} +
+ {/* Product meta */} +
+
+
+

+ {product.brand} +

+

+ {product.name} +

+ +
+ + Platform:{" "} + + {product.platform || platform} + + + + Role:{" "} + + {product.partRole || partRoleParam} + + + + ID:{" "} + + {product.id} + + +
+
+ +
+
+ Current price +
+
+ {formatPrice(product.price)} +
+
+
+ + {(product.shortDescription || product.description) && ( +
+ {product.shortDescription ? ( +

+ {product.shortDescription} +

+ ) : null} + {product.description ? ( +

+ {product.description} +

+ ) : null} +
+ )} +
+ + {/* Offers */} +
+
+

+ Merchant Offers +

+ + {offers.length ? `${offers.length} offers` : "no offers"} + +
+ + {offers.length ? ( +
+ + + + + + + + + + + {offers.map((o, idx) => ( + + + + + + + ))} + +
MerchantStockPriceAction
+ {o.merchantName ?? "Merchant"} + + {o.inStock === false ? ( + Out of stock + ) : ( + In stock + )} + + {o.price != null ? `$${o.price.toFixed(2)}` : "—"} + + {o.buyUrl ? ( + + Buy + + ) : ( + + )} +
+
+ ) : ( +

+ No offers attached yet. Once offers are available, this becomes the + “where to buy” table. +

+ )} + + {/* Keep a single "best" buy CTA if buyUrl exists */} + {product.buyUrl ? ( + + ) : null} +
+ + {/* Footer tips */} +
+ + Tip: Add this part, then go back and compare alternates. + + + Canonical route:{" "} + + /parts/p/{platform}/{partRoleParam}/{productSlug} + + +
+
+
+ )} +
+
+
+ ); +} \ No newline at end of file diff --git a/app/(builder)/parts/p/[platform]/[partRole]/page.tsx b/app/(builder)/parts/p/[platform]/[partRole]/page.tsx new file mode 100644 index 0000000..77c3b5c --- /dev/null +++ b/app/(builder)/parts/p/[platform]/[partRole]/page.tsx @@ -0,0 +1,5 @@ +import PartsBrowseClient from "@/components/parts/PartsBrowseClient"; + +export default function Page({ params }: { params: { platform: string; partRole: string } }) { + return ; +} \ No newline at end of file diff --git a/app/builds/layout.tsx b/app/builds/layout.tsx index a14e64f..f0fd9f0 100644 --- a/app/builds/layout.tsx +++ b/app/builds/layout.tsx @@ -1,16 +1,18 @@ -export const metadata = { - title: 'Next.js', - description: 'Generated by Next.js', -} +// app/(builder)/layout.tsx +import type { ReactNode } from "react"; -export default function RootLayout({ - children, -}: { - children: React.ReactNode -}) { +import { AuthProvider } from "@/context/AuthContext"; +import { BuilderNav } from "@/components/BuilderNav"; +import { TopNav } from "@/components/TopNav"; + +export default function BuilderLayout({ children }: { children: ReactNode }) { return ( - - {children} - - ) -} +
+ + + +
{children}
+
+
+ ); +} \ No newline at end of file diff --git a/components/PricingHistoryGraph.tsx b/components/PricingHistoryGraph.tsx index abcbda8..161e432 100644 --- a/components/PricingHistoryGraph.tsx +++ b/components/PricingHistoryGraph.tsx @@ -1,6 +1,6 @@ "use client"; -import type { PriceHistoryPoint } from "@/app/(builder)/parts/[category]/[partId]/data"; +import type { PriceHistoryPoint } from "@/app/(builder)/parts/[partRole]/_old_prod_details/data"; interface PricingHistoryGraphProps { data: PriceHistoryPoint[]; diff --git a/components/RetailersList.tsx b/components/RetailersList.tsx index 500a490..bec4b13 100644 --- a/components/RetailersList.tsx +++ b/components/RetailersList.tsx @@ -1,6 +1,6 @@ "use client"; -import type { RetailerOffer } from "@/app/(builder)/parts/[category]/[partId]/data"; +import type { RetailerOffer } from "@/app/(builder)/parts/[partRole]/_old_prod_details/data"; interface RetailersListProps { retailers: RetailerOffer[]; diff --git a/components/builder/OverlapChip.tsx b/components/builder/OverlapChip.tsx new file mode 100644 index 0000000..a99abad --- /dev/null +++ b/components/builder/OverlapChip.tsx @@ -0,0 +1,34 @@ +// /components/builder/OverlapChip.tsx +"use client"; + +import { useEffect, useState } from "react"; + +export default function OverlapChip(props: { + tone?: "warning" | "info"; + label: string; + detail?: string; +}) { + const { tone = "info", label, detail } = props; + + const [ready, setReady] = useState(false); + useEffect(() => { + const id = window.setTimeout(() => setReady(true), 10); + return () => window.clearTimeout(id); + }, []); + + const base = + "inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[0.65rem] leading-4 transition-all duration-200"; + const pop = ready ? "opacity-100 scale-100" : "opacity-0 scale-95"; + + const toneCls = + tone === "warning" + ? "border-amber-400/40 bg-amber-400/10 text-amber-200" + : "border-zinc-700 bg-zinc-900/60 text-zinc-200"; + + return ( + + {label} + {detail ? · {detail} : null} + + ); +} \ No newline at end of file diff --git a/components/parts/Filters.tsx b/components/parts/Filters.tsx new file mode 100644 index 0000000..0fd2338 --- /dev/null +++ b/components/parts/Filters.tsx @@ -0,0 +1,190 @@ +"use client"; + +import React from "react"; + +export default function Filters(props: { + partRoleLabel: string; + + availableBrands: string[]; + brandFilter: string[]; + setBrandFilter: (v: string[]) => void; + + priceBounds: { min: number | null; max: number | null }; + priceRange: { min: number | null; max: number | null }; + setPriceRange: (v: { min: number | null; max: number | null }) => void; + + inStockOnly: boolean; + setInStockOnly: (v: boolean) => void; +}) { + const { + availableBrands, + brandFilter, + setBrandFilter, + priceBounds, + priceRange, + setPriceRange, + inStockOnly, + setInStockOnly, + } = props; + + return ( + + ); +} \ No newline at end of file diff --git a/components/parts/OverlapChip.tsx b/components/parts/OverlapChip.tsx new file mode 100644 index 0000000..2cd6a3c --- /dev/null +++ b/components/parts/OverlapChip.tsx @@ -0,0 +1,62 @@ +"use client"; + +import { useEffect, useId, useState } from "react"; + +export default function OverlapChip(props: { + label: string; + tooltip?: string; + /** stable key so chip doesn't re-animate on trivial rerenders */ + persistKey?: string; + className?: string; +}) { + const { label, tooltip, persistKey, className } = props; + + // Animate only once per persistKey per session + const storageKey = persistKey ? `bb_overlapchip_seen:${persistKey}` : null; + + const [animateIn, setAnimateIn] = useState(false); + const tooltipId = useId(); + + useEffect(() => { + if (!storageKey) { + // No key provided: animate once on mount + setAnimateIn(true); + return; + } + + try { + const seen = sessionStorage.getItem(storageKey); + if (!seen) { + sessionStorage.setItem(storageKey, "1"); + setAnimateIn(true); + } + } catch { + // sessionStorage blocked — still animate + setAnimateIn(true); + } + }, [storageKey]); + + return ( + + + + {label} + + + {/* Optional: if you later want a real tooltip instead of title, + you can wire one here using tooltipId. */} + {tooltip ? {tooltip} : null} + + ); +} \ No newline at end of file diff --git a/components/parts/Pagination.tsx b/components/parts/Pagination.tsx new file mode 100644 index 0000000..4b5d2e2 --- /dev/null +++ b/components/parts/Pagination.tsx @@ -0,0 +1,39 @@ +"use client"; + +export default function Pagination(props: { + currentPage: number; + totalPages: number; + onPrev: () => void; + onNext: () => void; +}) { + const { currentPage, totalPages, onPrev, onNext } = props; + + return ( +
+
+ Page{" "} + {currentPage}{" "} + of{" "} + {totalPages} +
+
+ + +
+
+ ); +} \ No newline at end of file diff --git a/components/parts/PartsBrowseClient.tsx b/components/parts/PartsBrowseClient.tsx new file mode 100644 index 0000000..7ddf627 --- /dev/null +++ b/components/parts/PartsBrowseClient.tsx @@ -0,0 +1,386 @@ +"use client"; + +/** + * PartsBrowseClient + * ----------------------------------------------------------------------------- + * Browse shell for parts listing pages. + * Owns: + * - fetching parts from backend + * - filter + sort + pagination state + * - layout + list/card views + */ + +import { useEffect, useMemo, useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; + +import Filters from "@/components/parts/Filters"; +import SortBar from "@/components/parts/SortBar"; +import PartsGrid from "@/components/parts/PartsGrid"; +import Pagination from "@/components/parts/Pagination"; +import PlatformSwitcher from "@/components/parts/PlatformSwitcher"; + +import type { CategoryId } from "@/types/gunbuilder"; +import { PART_ROLE_TO_CATEGORY, normalizePartRole } from "@/lib/catalogMappings"; + +type ViewMode = "card" | "list"; +type SortOption = "relevance" | "price-asc" | "price-desc" | "brand-asc"; + +type GunbuilderProductFromApi = { + id: string | number; + name: string; + brand: string; + platform: string; + partRole: string; + price: number | null; + mainImageUrl: string | null; + buyUrl: string | null; + inStock?: boolean | null; +}; + +type UiPart = { + id: string; + name: string; + brand: string; + platform: string; + partRole: string; + price: number; // normalized + imageUrl?: string; + buyUrl?: string; + inStock?: boolean; +}; + +const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080"; +const PAGE_SIZE = 24; + +function normalizeId(id: string | number) { + return typeof id === "number" ? String(id) : id; +} + +function toSlug(s: string) { + return (s ?? "") + .toLowerCase() + .replaceAll(/[^a-z0-9]+/g, "-") + .replaceAll(/(^-|-$)/g, "") + .trim(); +} + +/** + * Canonical product details route: + * /parts/p/[platform]/[partRole]/[productSlug] + */ +function buildDetailHref(platform: string, partRole: string, p: UiPart) { + const slug = toSlug(`${p.brand ?? ""} ${p.name ?? ""}`); + return `/parts/p/${encodeURIComponent(platform)}/${encodeURIComponent(partRole)}/${encodeURIComponent( + `${p.id}-${slug}` + )}`; +} + +export default function PartsBrowseClient(props: { + partRole: string; + platform?: string | null; // if null, read from query param or default + title?: string; + subtitle?: string; +}) { + const router = useRouter(); + const searchParams = useSearchParams(); + + const effectivePlatform = props.platform ?? searchParams.get("platform") ?? "AR-15"; + const partRole = props.partRole; + + const [viewMode, setViewMode] = useState("list"); + const [parts, setParts] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const [brandFilter, setBrandFilter] = useState([]); + const [sortBy, setSortBy] = useState("relevance"); + const [searchQuery, setSearchQuery] = useState(""); + const [priceRange, setPriceRange] = useState<{ min: number | null; max: number | null }>({ + min: null, + max: null, + }); + const [inStockOnly, setInStockOnly] = useState(false); + const [currentPage, setCurrentPage] = useState(1); + + useEffect(() => { + if (!partRole) return; + + const controller = new AbortController(); + + async function fetchParts() { + try { + setLoading(true); + setError(null); + + const search = new URLSearchParams(); + search.set("platform", effectivePlatform); + search.append("partRoles", partRole); + + const url = `${API_BASE_URL}/api/products?${search.toString()}`; + const res = await fetch(url, { signal: controller.signal }); + + if (!res.ok) throw new Error(`Failed to load products (${res.status})`); + + const data: GunbuilderProductFromApi[] = await res.json(); + + const normalized: UiPart[] = data.map((p) => ({ + id: normalizeId(p.id), + name: p.name, + brand: p.brand, + platform: p.platform, + partRole: p.partRole, + price: p.price ?? 0, + imageUrl: p.mainImageUrl ?? undefined, + buyUrl: p.buyUrl ?? undefined, + inStock: p.inStock ?? true, + })); + + setParts(normalized); + } catch (err: any) { + if (err?.name === "AbortError") return; + setError(err?.message ?? "Failed to load products"); + } finally { + setLoading(false); + } + } + + fetchParts(); + return () => controller.abort(); + }, [partRole, effectivePlatform]); + + useEffect(() => { + setCurrentPage(1); + }, [partRole, effectivePlatform, brandFilter, sortBy, searchQuery, priceRange, inStockOnly]); + + const availableBrands = useMemo( + () => + Array.from(new Set(parts.map((p) => p.brand))) + .filter(Boolean) + .sort((a, b) => a.localeCompare(b)), + [parts] + ); + + const priceBounds = useMemo(() => { + if (parts.length === 0) return { min: null as number | null, max: null as number | null }; + + let min = Number.POSITIVE_INFINITY; + let max = Number.NEGATIVE_INFINITY; + + for (const p of parts) { + const price = p.price ?? 0; + if (price < min) min = price; + if (price > max) max = price; + } + + if (!Number.isFinite(min) || !Number.isFinite(max)) { + return { min: null as number | null, max: null as number | null }; + } + + return { min, max }; + }, [parts]); + + useEffect(() => { + if (priceBounds.min == null || priceBounds.max == null) return; + + setPriceRange((prev) => { + const min = prev.min ?? priceBounds.min!; + const max = prev.max ?? priceBounds.max!; + return { + min: Math.max(priceBounds.min!, Math.min(min, priceBounds.max!)), + max: Math.min(priceBounds.max!, Math.max(max, priceBounds.min!)), + }; + }); + }, [priceBounds.min, priceBounds.max]); + + const filteredParts = useMemo(() => { + let result = [...parts]; + + const effectiveMin = priceRange.min ?? priceBounds.min; + const effectiveMax = priceRange.max ?? priceBounds.max; + if (effectiveMin != null && effectiveMax != null) { + result = result.filter((p) => p.price >= effectiveMin && p.price <= effectiveMax); + } + + if (brandFilter.length > 0) { + result = result.filter((p) => brandFilter.includes(p.brand)); + } + + if (inStockOnly) { + result = result.filter((p) => p.inStock ?? true); + } + + const q = searchQuery.trim().toLowerCase(); + if (q) { + result = result.filter((p) => { + const name = (p.name ?? "").toLowerCase(); + const brand = (p.brand ?? "").toLowerCase(); + return name.includes(q) || brand.includes(q); + }); + } + + switch (sortBy) { + case "price-asc": + result.sort((a, b) => a.price - b.price); + break; + case "price-desc": + result.sort((a, b) => b.price - a.price); + break; + case "brand-asc": + result.sort((a, b) => a.brand.localeCompare(b.brand)); + break; + case "relevance": + default: + break; + } + + return result; + }, [parts, brandFilter, sortBy, searchQuery, priceRange, priceBounds.min, priceBounds.max, inStockOnly]); + + const totalPages = useMemo( + () => (filteredParts.length === 0 ? 1 : Math.ceil(filteredParts.length / PAGE_SIZE)), + [filteredParts.length] + ); + + const paginatedParts = useMemo(() => { + if (filteredParts.length === 0) return []; + const startIndex = (currentPage - 1) * PAGE_SIZE; + return filteredParts.slice(startIndex, startIndex + PAGE_SIZE); + }, [filteredParts, currentPage]); + + const visibleRange = useMemo(() => { + if (filteredParts.length === 0) return { start: 0, end: 0 }; + const start = (currentPage - 1) * PAGE_SIZE + 1; + const end = Math.min(start + paginatedParts.length - 1, filteredParts.length); + return { start, end }; + }, [filteredParts.length, currentPage, paginatedParts.length]); + + const headingTitle = props.title ?? `${partRole.replaceAll("-", " ")} parts`; + const headingSubtitle = props.subtitle ?? "Browse available parts pulled from your Ballistic backend."; + + // ✅ Add-to-build handler: deep-links into builder’s existing ?select= logic + const handleAddToBuild = (p: UiPart) => { + const normalizedRole = normalizePartRole(partRole); + const categoryId: CategoryId | null = + PART_ROLE_TO_CATEGORY[partRole] ?? PART_ROLE_TO_CATEGORY[normalizedRole] ?? null; + + if (!categoryId) { + alert(`No CategoryId mapping found for role "${partRole}". Add it to catalogMappings.`); + return; + } + + const qp = new URLSearchParams(); + qp.set("platform", effectivePlatform); + qp.set("select", `${categoryId}:${p.id}`); + + router.push(`/builder?${qp.toString()}`); + }; + + return ( +
+
+
+
+
+

+ Battl Builders +

+ +

+ {headingTitle} {effectivePlatform} +

+ +

{headingSubtitle}

+ +
+ +
+
+ + {!loading && !error && parts.length > 0 && ( +
+ + +
+ )} +
+
+ +
+
+ {!loading && !error && parts.length > 0 && ( + + )} + +
+ {!loading && !error && parts.length > 0 && ( + + )} + + {loading ? ( +

Loading…

+ ) : error ? ( +

+ {error} — check that the Ballistic API is running. +

+ ) : filteredParts.length === 0 ? ( +

No parts found for this role yet.

+ ) : ( + buildDetailHref(effectivePlatform, partRole, p)} + onAddToBuild={handleAddToBuild} + addLabel="Add to Build" + /> + )} + + {!loading && !error && filteredParts.length > 0 && totalPages > 1 && ( + setCurrentPage((p) => Math.max(1, p - 1))} + onNext={() => setCurrentPage((p) => Math.min(totalPages, p + 1))} + /> + )} +
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/components/parts/PartsGrid.tsx b/components/parts/PartsGrid.tsx new file mode 100644 index 0000000..e0690de --- /dev/null +++ b/components/parts/PartsGrid.tsx @@ -0,0 +1,162 @@ +"use client"; + +import Link from "next/link"; + +type UiPart = { + id: string; + name: string; + brand: string; + platform: string; + partRole: string; + price: number; + imageUrl?: string; + buyUrl?: string; + inStock?: boolean; +}; + +export default function PartsGrid(props: { + viewMode: "card" | "list"; + parts: UiPart[]; + buildDetailHref: (p: UiPart) => string; + + // NEW: optional Add-to-Build behavior + onAddToBuild?: (p: UiPart) => void; + addLabel?: string; +}) { + const { viewMode, parts, buildDetailHref, onAddToBuild } = props; + const addLabel = props.addLabel ?? "Add to Build"; + + if (viewMode === "card") { + return ( +
+ {parts.map((part) => ( +
+
+
+
+ {part.brand} — {part.name} +
+
+
+ ${part.price.toFixed(2)} +
+
+ +
+ + View Details + + + {/* NEW: Add to Build (preferred primary action if provided) */} + {onAddToBuild ? ( + + ) : part.buyUrl ? ( + + Buy + + ) : ( + + )} +
+ +
+ ))} +
+ ); + } + + // LIST view + return ( + <> +
+ Part + Brand + Price + Actions +
+ +
+ {parts.map((part) => ( +
+
+
+
+ {part.name} +
+
+ +
+ {part.brand} +
+ +
+ ${part.price.toFixed(2)} +
+ +
+ + View Details + + + {onAddToBuild ? ( + + ) : part.buyUrl ? ( + + Buy + + ) : ( + + )} +
+
+ +
+ ))} +
+ + ); +} \ No newline at end of file diff --git a/components/parts/PartsListPageClient.tsx b/components/parts/PartsListPageClient.tsx new file mode 100644 index 0000000..ef76f31 --- /dev/null +++ b/components/parts/PartsListPageClient.tsx @@ -0,0 +1,178 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import Link from "next/link"; +import PlatformSwitcher from "./PlatformSwitcher"; + +const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080"; + +type ProductListDto = { + id: string; // your API returns strings sometimes; keep it flexible + name: string; + brand: string; + platform: string; + partRole: string; + categoryKey: string | null; + price: number | null; + buyUrl: string | null; + imageUrl: string | null; +}; + +function safeSlugify(input: string) { + return (input ?? "") + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/(^-|-$)/g, "") + .slice(0, 80); +} + +export default function PartsListPageClient(props: { + platform: string; + partRole: string; +}) { + const { platform, partRole } = props; + + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // Simple client-side search (fast + good enough for now) + const [q, setQ] = useState(""); + + useEffect(() => { + let cancelled = false; + + async function load() { + try { + setLoading(true); + setError(null); + + // Your current list endpoint: + // GET /api/products?platform=AR-15&partRoles=upper-receiver + const url = `${API_BASE_URL}/api/products?platform=${encodeURIComponent(platform)}&partRoles=${encodeURIComponent(partRole)}`; + + const res = await fetch(url, { headers: { Accept: "application/json" } }); + if (!res.ok) { + const txt = await res.text().catch(() => ""); + throw new Error(`Failed to load products (${res.status}): ${txt}`); + } + + const data: ProductListDto[] = await res.json(); + if (!cancelled) setItems(data); + } catch (e: any) { + if (!cancelled) setError(e?.message ?? "Failed to load products"); + } finally { + if (!cancelled) setLoading(false); + } + } + + load(); + return () => { cancelled = true; }; + }, [platform, partRole]); + + const filtered = useMemo(() => { + const needle = q.trim().toLowerCase(); + if (!needle) return items; + + return items.filter((p) => { + const blob = `${p.brand ?? ""} ${p.name ?? ""} ${p.categoryKey ?? ""}`.toLowerCase(); + return blob.includes(needle); + }); + }, [items, q]); + + return ( +
+
+
+
+
+

+ Battl Builders +

+

+ Parts: {partRole} +

+
+ + +
+ +
+ setQ(e.target.value)} + placeholder="Search brand, name, category…" + className="w-full rounded-md border border-zinc-700 bg-zinc-950/80 px-3 py-2 text-sm text-zinc-100 outline-none focus:border-amber-400" + /> +
+
+ + {loading &&

Loading parts…

} + {error && ( +
+ {error} +
+ )} + + {!loading && !error && ( +
+
+

+ Results +

+

+ {filtered.length} items +

+
+ +
+ {filtered.map((p) => { + const id = String(p.id); + const slug = safeSlugify(p.name); + const href = `/parts/${encodeURIComponent(platform)}/${encodeURIComponent(partRole)}/${id}-${slug}`; + + return ( + +
+
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {p.imageUrl ? ( + {p.name} + ) : ( +
+ — +
+ )} +
+ +
+

+ {p.brand} +

+

+ {p.name} +

+
+

+ {p.categoryKey ?? "—"} +

+

+ {typeof p.price === "number" ? `$${p.price.toFixed(2)}` : "—"} +

+
+
+
+ + ); + })} +
+
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/components/parts/PlatformSwitcher.tsx b/components/parts/PlatformSwitcher.tsx new file mode 100644 index 0000000..27b0687 --- /dev/null +++ b/components/parts/PlatformSwitcher.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { useMemo } from "react"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; + +/** + * PlatformSwitcher + * + * Works on BOTH: + * - /parts/[partRole] (default browse route) + * - /parts/[platform]/[partRole] (canonical route) + * + * Behavior: + * - If you're on /parts/[partRole], switching platform navigates to /parts/[platform]/[partRole] + * (so your URL becomes explicit/shareable). + * - If you're already on /parts/[platform]/[partRole], it swaps the platform segment. + */ +export default function PlatformSwitcher(props: { + currentPlatform: string; + partRole: string; + // If true, we keep query params when switching (nice for sort/paging) + preserveQuery?: boolean; +}) { + const { currentPlatform, partRole, preserveQuery = true } = props; + + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + + const queryString = useMemo(() => { + if (!preserveQuery) return ""; + const q = searchParams?.toString(); + return q ? `?${q}` : ""; + }, [searchParams, preserveQuery]); + + function go(platform: string) { + // We always navigate to canonical to keep it simple & consistent + router.push(`/parts/${encodeURIComponent(platform)}/${encodeURIComponent(partRole)}${queryString}`); + } + + return ( +
+ Platform + + + + {pathname} + +
+ ); +} \ No newline at end of file diff --git a/components/parts/ProductDetailPageClient.tsx b/components/parts/ProductDetailPageClient.tsx new file mode 100644 index 0000000..a4b0695 --- /dev/null +++ b/components/parts/ProductDetailPageClient.tsx @@ -0,0 +1,204 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import Link from "next/link"; +import PlatformSwitcher from "./PlatformSwitcher"; + +const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080"; + +type ProductDetailDto = { + id: string; + name: string; + brand: string; + platform: string; + partRole: string; + categoryKey: string | null; + price: number | null; + buyUrl: string | null; + imageUrl: string | null; +}; + +function parseIdFromProductSlug(productSlug: string): string | null { + // Expected: "1217-some-slug" + const m = /^(\d+)(?:-|$)/.exec(productSlug ?? ""); + return m?.[1] ?? null; +} + +/** + * Attempts to fetch a product detail. + * If you don't have a dedicated endpoint yet, it falls back to list + filter by id. + */ +async function fetchProductDetail(params: { + platform: string; + partRole: string; + productSlug: string; +}): Promise { + const { platform, partRole, productSlug } = params; + const id = parseIdFromProductSlug(productSlug); + + if (!id) throw new Error(`Invalid product URL slug: '${productSlug}' (could not parse id)`); + + // ---- Preferred (if you add it): GET /api/products/{id} + // If it 404s, we fall back. + try { + const res = await fetch(`${API_BASE_URL}/api/products/${encodeURIComponent(id)}`, { + headers: { Accept: "application/json" }, + }); + + if (res.ok) { + return await res.json(); + } + } catch { + // ignore and fall back + } + + // ---- Fallback: use list endpoint + find by id (works right now with your current API) + const listRes = await fetch( + `${API_BASE_URL}/api/products?platform=${encodeURIComponent(platform)}&partRoles=${encodeURIComponent(partRole)}`, + { headers: { Accept: "application/json" } } + ); + + if (!listRes.ok) { + const txt = await listRes.text().catch(() => ""); + throw new Error(`Failed to load product (fallback) (${listRes.status}): ${txt}`); + } + + const list: ProductDetailDto[] = await listRes.json(); + const found = list.find((p) => String(p.id) === String(id)); + + if (!found) { + throw new Error(`Product id=${id} not found in ${platform}/${partRole} list`); + } + + return found; +} + +export default function ProductDetailPageClient(props: { + platform: string; + partRole: string; + productSlug: string; +}) { + const { platform, partRole, productSlug } = props; + + const [p, setP] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const id = useMemo(() => parseIdFromProductSlug(productSlug), [productSlug]); + + useEffect(() => { + let cancelled = false; + + async function load() { + try { + setLoading(true); + setError(null); + + const data = await fetchProductDetail({ platform, partRole, productSlug }); + + if (!cancelled) setP(data); + } catch (e: any) { + if (!cancelled) setError(e?.message ?? "Failed to load product"); + } finally { + if (!cancelled) setLoading(false); + } + } + + load(); + return () => { cancelled = true; }; + }, [platform, partRole, productSlug]); + + return ( +
+
+
+
+
+

+ Battl Builders +

+

+ Part: {partRole} +

+
+ + +
+ +
+ + ← Back to list + + + id: {id ?? "—"} +
+
+ + {loading &&

Loading product…

} + + {error && ( +
+ {error} +
+ )} + + {!loading && !error && p && ( +
+
+
+
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {p.imageUrl ? ( + {p.name} + ) : ( +
+ No image +
+ )} +
+
+ +
+

+ {p.brand} • {p.platform} +

+

+ {p.name} +

+ +
+
+

Category

+

{p.categoryKey ?? "—"}

+
+ +
+

Best price

+

+ {typeof p.price === "number" ? `$${p.price.toFixed(2)}` : "—"} +

+
+
+ +
+ {p.buyUrl ? ( + + Buy / View offer + + ) : ( + No buy link + )} +
+
+
+
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/components/parts/SortBar.tsx b/components/parts/SortBar.tsx new file mode 100644 index 0000000..fbadd27 --- /dev/null +++ b/components/parts/SortBar.tsx @@ -0,0 +1,77 @@ +"use client"; + +type SortOption = "relevance" | "price-asc" | "price-desc" | "brand-asc"; + +export default function SortBar(props: { + visibleRange: { start: number; end: number }; + totalCount: number; + + searchQuery: string; + setSearchQuery: (v: string) => void; + + sortBy: SortOption; + setSortBy: (v: SortOption) => void; +}) { + const { visibleRange, totalCount, searchQuery, setSearchQuery, sortBy, setSortBy } = props; + + return ( +
+
+ Showing{" "} + + {visibleRange.start}-{visibleRange.end} + {" "} + of{" "} + + {totalCount} + {" "} + matching parts +
+ +
+
+ +
+ setSearchQuery(e.target.value)} + placeholder="Search..." + className="rounded-md border border-zinc-700 bg-zinc-900/70 px-2 py-1 pr-6 text-xs text-zinc-200 focus:outline-none focus:ring-1 focus:ring-amber-400" + /> + {searchQuery && ( + + )} +
+
+ +
+ + +
+
+
+ ); +} \ No newline at end of file diff --git a/lib/buildOverlaps.ts b/lib/buildOverlaps.ts new file mode 100644 index 0000000..c1d8b78 --- /dev/null +++ b/lib/buildOverlaps.ts @@ -0,0 +1,150 @@ +// /lib/buildOverlaps.ts +import type { CategoryId } from "@/types/gunbuilder"; + +export type OverlapChipModel = { + key: string; + tone: "warning" | "info"; + label: string; + detail?: string; +}; + +export const COMPLETE_UPPER_CATEGORY: CategoryId = "complete-upper"; +export const COMPLETE_LOWER_CATEGORY: CategoryId = "complete-lower"; + +export const UPPER_OVERLAP_CATEGORIES = new Set([ + "upper-receiver", + "bcg", + "barrel", + "gas-block", + "gas-tube", + "muzzle-device", + "suppressor", + "handguard", + "charging-handle", +]); + +export const LOWER_OVERLAP_CATEGORIES = new Set([ + "lower-receiver", + "lower-parts", + "trigger", + "grip", + "safety", + "buffer", + "stock", +]); + +export type BuildState = Partial>; + +function selectedCount(build: BuildState, set: Set) { + let count = 0; + for (const cid of set) if (build[cid]) count++; + return count; +} + +function selectedList(build: BuildState, set: Set) { + const list: CategoryId[] = []; + for (const cid of set) if (build[cid]) list.push(cid); + return list; +} + +/** + * Chips to show on a given category row (education / “you may not need both”) + */ +export function getOverlapChipsForCategory( + categoryId: CategoryId, + build: BuildState +): OverlapChipModel[] { + const chips: OverlapChipModel[] = []; + + const hasCompleteUpper = !!build[COMPLETE_UPPER_CATEGORY]; + const hasCompleteLower = !!build[COMPLETE_LOWER_CATEGORY]; + + // ---- Upper overlap ---- + if (categoryId === COMPLETE_UPPER_CATEGORY) { + const count = selectedCount(build, UPPER_OVERLAP_CATEGORIES); + if (count > 0) { + chips.push({ + key: "upper-complete-overlaps-subparts", + tone: "warning", + label: "Overlaps selected upper parts", + detail: `${count} upper part${count === 1 ? "" : "s"} also selected`, + }); + } + } + + if (UPPER_OVERLAP_CATEGORIES.has(categoryId) && hasCompleteUpper) { + chips.push({ + key: "upper-subpart-in-complete", + tone: "info", + label: "Included in Complete Upper", + detail: "You have both selected (compare if you want)", + }); + } + + // ---- Lower overlap ---- + if (categoryId === COMPLETE_LOWER_CATEGORY) { + const count = selectedCount(build, LOWER_OVERLAP_CATEGORIES); + if (count > 0) { + chips.push({ + key: "lower-complete-overlaps-subparts", + tone: "warning", + label: "Overlaps selected lower parts", + detail: `${count} lower part${count === 1 ? "" : "s"} also selected`, + }); + } + } + + if (LOWER_OVERLAP_CATEGORIES.has(categoryId) && hasCompleteLower) { + chips.push({ + key: "lower-subpart-in-complete", + tone: "info", + label: "Included in Complete Lower", + detail: "You have both selected (compare if you want)", + }); + } + + return chips; +} + +/** + * Non-blocking “heads up” messages when selecting a part. + * (Use for toasts / shareStatus strip — no auto-removal) + */ +export function getSelectionHints( + nextCategoryId: CategoryId, + build: BuildState +): string[] { + const hints: string[] = []; + + if (nextCategoryId === COMPLETE_UPPER_CATEGORY) { + const overlaps = selectedList(build, UPPER_OVERLAP_CATEGORIES); + if (overlaps.length > 0) { + hints.push( + "Heads up: Complete Upper overlaps with some selected upper parts. Nothing was removed — compare options and keep what you want." + ); + } + } + + if (UPPER_OVERLAP_CATEGORIES.has(nextCategoryId) && build[COMPLETE_UPPER_CATEGORY]) { + hints.push( + "Heads up: You also have a Complete Upper selected. Nothing was removed — compare options and keep what you want." + ); + } + + if (nextCategoryId === COMPLETE_LOWER_CATEGORY) { + const overlaps = selectedList(build, LOWER_OVERLAP_CATEGORIES); + if (overlaps.length > 0) { + hints.push( + "Heads up: Complete Lower overlaps with some selected lower parts. Nothing was removed — compare options and keep what you want." + ); + } + } + + if (LOWER_OVERLAP_CATEGORIES.has(nextCategoryId) && build[COMPLETE_LOWER_CATEGORY]) { + hints.push( + "Heads up: You also have a Complete Lower selected. Nothing was removed — compare options and keep what you want." + ); + } + + return hints; +} \ No newline at end of file diff --git a/lib/catalog.ts b/lib/catalog.ts new file mode 100644 index 0000000..c6014c3 --- /dev/null +++ b/lib/catalog.ts @@ -0,0 +1,90 @@ +// app/lib/catalog.ts +export const API_BASE_URL = + process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080"; + +export type ProductListItem = { + id: string; + name: string; + brand: string; + platform: string; + partRole: string; + categoryKey?: string | null; + price?: number | null; + buyUrl?: string | null; + imageUrl?: string | null; + slug?: string | null; // if your API returns it; otherwise we derive it client-side +}; + +export type ProductDetail = { + id: string; + name: string; + brand: string; + platform: string; + partRole: string; + rawCategoryKey?: string | null; + description?: string | null; + shortDescription?: string | null; + imageUrl?: string | null; + offers?: Array<{ + merchantName?: string; + price?: number | null; + buyUrl?: string | null; + inStock?: boolean | null; + }>; +}; + +export function slugify(input: string) { + return (input ?? "") + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/(^-|-$)/g, ""); +} + +/** + * Your existing list endpoint: + * GET /api/products?platform=AR-15&partRoles=complete-upper + */ +export async function fetchProducts(params: { + platform: string; + partRole: string; +}) { + const { platform, partRole } = params; + + const url = + `${API_BASE_URL}/api/products?` + + new URLSearchParams({ + platform, + partRoles: partRole, + }); + + const res = await fetch(url, { cache: "no-store" }); + if (!res.ok) throw new Error(`Failed to load products (${res.status})`); + const data = (await res.json()) as ProductListItem[]; + + // Ensure each item has a "productSlug" of the form "{id}-{slugified-name}" + return data.map((p) => ({ + ...p, + productSlug: `${p.id}-${slugify(p.name)}`, + })); +} + +/** + * You likely have (or should add) a detail endpoint like: + * GET /api/products/{id}?platform=AR-15 OR GET /api/products/{id} + * + * We'll call /api/products/{id} and optionally include platform as a query param. + */ +export async function fetchProductById(params: { + id: string; + platform?: string; +}) { + const { id, platform } = params; + + const url = + `${API_BASE_URL}/api/products/${encodeURIComponent(id)}` + + (platform ? `?${new URLSearchParams({ platform })}` : ""); + + const res = await fetch(url, { cache: "no-store" }); + if (!res.ok) throw new Error(`Failed to load product (${res.status})`); + return (await res.json()) as ProductDetail; +} \ No newline at end of file diff --git a/data/partRoleMappings.ts b/lib/catalogMappings.ts similarity index 97% rename from data/partRoleMappings.ts rename to lib/catalogMappings.ts index eaab27f..0bd8107 100644 --- a/data/partRoleMappings.ts +++ b/lib/catalogMappings.ts @@ -35,7 +35,6 @@ export const CATEGORY_TO_PART_ROLES: Partial> = { // ===== LOWER ===== "lower-receiver": ["lower-receiver", "lower", "LOWER_RECEIVER_STRIPPED"], // (optional) Back-compat if anything still uses the old ids: - lower: ["lower-receiver", "lower", "LOWER_RECEIVER_STRIPPED"], "complete-lower": ["complete-lower"], "lower-parts": ["lower-parts-kit", "lower-parts"],