From 978b9df96c6d18cec66e13a4d38e720326a01a42 Mon Sep 17 00:00:00 2001 From: Sean Date: Sat, 13 Dec 2025 06:02:05 -0500 Subject: [PATCH] fixing role ,apping. added accessories to ui --- app/(builder)/builder/page.tsx | 181 ++++++++++++++++++++++----------- data/gunbuilderParts.ts | 42 ++++---- data/partRoleMappings.ts | 109 +++++--------------- types/gunbuilder.ts | 105 +++++++++++++------ 4 files changed, 243 insertions(+), 194 deletions(-) diff --git a/app/(builder)/builder/page.tsx b/app/(builder)/builder/page.tsx index 5118569..d2cfec1 100644 --- a/app/(builder)/builder/page.tsx +++ b/app/(builder)/builder/page.tsx @@ -29,6 +29,20 @@ type BuildState = Partial>; // categoryId -> partId const STORAGE_KEY = "gunbuilder-build-state"; +// Categories that should NOT be filtered by platform (universal accessories / gear) +const UNIVERSAL_CATEGORIES = new Set([ + "magazine", + "weapon-light", + "foregrip", + "bipod", + "sling", + "rail-accessory", + "tools", + // Optics & sights are intentionally treated as universal + "optic", + "sights", +]); + // Category groups for the main grid const CATEGORY_GROUPS: { id: string; @@ -43,8 +57,8 @@ const CATEGORY_GROUPS: { "Everything from the serialized lower to small parts, fire control, and core controls.", categoryIds: [ "lower", - "completeLower", - "lowerParts", + "complete-lower", + "lower-parts", "trigger", "grip", "safety", @@ -59,26 +73,34 @@ const CATEGORY_GROUPS: { "Barrel, upper, gas system, and the parts that keep the rifle cycling.", categoryIds: [ "upper", - "completeUpper", + "complete-upper", "bcg", "barrel", - "gasBlock", - "gasTube", - "muzzleDevice", + "gas-block", + "gas-tube", + "muzzle-device", "suppressor", "handguard", - "chargingHandle", + "charging-handle", "sights", "optic", ], }, - // Optional: future group for accessories/furniture if needed - // { - // id: "accessories-group", - // label: "Accessories & Furniture", - // description: "Optics, lights, slings, and other supporting gear.", - // categoryIds: ["optic", "sights", ...] as CategoryId[], - // }, + { + 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", + ], + }, ]; export default function GunbuilderPage() { @@ -121,47 +143,78 @@ export default function GunbuilderPage() { setLoading(true); setError(null); - const res = await fetch( - `${API_BASE_URL}/api/products?platform=${encodeURIComponent( - platform - )}`, - { signal: controller.signal } - ); + // 1) Platform-scoped products + const scopedUrl = `${API_BASE_URL}/api/products?platform=${encodeURIComponent( + platform + )}`; - if (!res.ok) { - throw new Error(`Failed to load products (${res.status})`); + // 2) Universal products (no platform filter) + // NOTE: This assumes your backend supports /api/products with no platform param. + // If it doesn't yet, this call will fail quietly and you'll still see scoped results. + 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) { + const status = scopedRes?.status ?? ""; + throw new Error(`Failed to load products (${status})`); } - const data: GunbuilderProductFromApi[] = await res.json(); + const scopedData: GunbuilderProductFromApi[] = await scopedRes.json(); - const normalized: Part[] = data - .map((p): Part | null => { - const categoryId = PART_ROLE_TO_CATEGORY[p.partRole]; - if (!categoryId) { - // Skip any parts we don't know how to map yet - return null; - } + let universalData: GunbuilderProductFromApi[] = []; + if (universalRes && universalRes.ok) { + universalData = await universalRes.json(); + } - const buyUrl = p.buyUrl ?? undefined; + // Normalize both lists + const normalize = (data: GunbuilderProductFromApi[]): Part[] => + data + .map((p): Part | null => { + const categoryId = PART_ROLE_TO_CATEGORY[p.partRole]; + if (!categoryId) return null; - return { - id: String(p.id), // ALWAYS string - categoryId, - name: p.name, - brand: p.brand, - price: p.price ?? 0, - imageUrl: p.mainImageUrl ?? undefined, - affiliateUrl: buyUrl, // main link - url: buyUrl, // 👈optional alias if you still reference .url. maybe pretty url? - notes: undefined, - }; - }) - .filter((p): p is Part => p !== null); + // Only keep truly-universal categories from the universal feed + // so we don't accidentally mix platforms for platform-scoped parts. + if (data === universalData && !UNIVERSAL_CATEGORIES.has(categoryId)) { + return null; + } - setParts(normalized); + const buyUrl = p.buyUrl ?? undefined; + return { + id: String(p.id), + categoryId, + name: p.name, + brand: p.brand, + price: p.price ?? 0, + imageUrl: p.mainImageUrl ?? undefined, + affiliateUrl: buyUrl, + url: buyUrl, + notes: undefined, + }; + }) + .filter((p): p is Part => p !== null); + + const scopedParts = normalize(scopedData); + const universalParts = normalize(universalData); + + // Merge + de-dupe by (categoryId + id) + 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"); + if (err?.name === "AbortError") return; + setError(err?.message ?? "Failed to load products"); } finally { setLoading(false); } @@ -173,12 +226,18 @@ export default function GunbuilderPage() { }, [platform]); useEffect(() => { - // When the platform changes, reset the current build so we don't - // show stale part selections from a different platform. - setBuild({}); - if (typeof window !== "undefined") { - localStorage.removeItem(STORAGE_KEY); - } + // When the platform changes, clear ONLY platform-scoped selections. + // Keep universal accessories (optic/light/sling/etc) so the user doesn't lose them. + setBuild((prev) => { + const next: BuildState = {}; + for (const [categoryId, partId] of Object.entries(prev)) { + const cid = categoryId as CategoryId; + if (UNIVERSAL_CATEGORIES.has(cid)) { + next[cid] = partId; + } + } + return next; + }); }, [platform]); // Handle URL query parameter for part selection (?select=upper:165) @@ -280,10 +339,16 @@ export default function GunbuilderPage() { }, [build]); const handleSelectPart = (categoryId: CategoryId, partId: string) => { - setBuild((prev) => ({ - ...prev, - [categoryId]: partId, - })); + setBuild((prev) => { + const updated = { + ...prev, + [categoryId]: partId, + }; + if (typeof window !== "undefined") { + localStorage.setItem(STORAGE_KEY, JSON.stringify(updated)); + } + return updated; + }); }; // Use group ordering for the summary as well @@ -302,7 +367,7 @@ export default function GunbuilderPage() { } const lines: string[] = []; - lines.push("Shadow Standard — The Armory Build"); + lines.push("B Build"); lines.push(""); lines.push(`Total: $${totalPrice.toFixed(2)}`); lines.push(""); diff --git a/data/gunbuilderParts.ts b/data/gunbuilderParts.ts index 91a0b3e..57c08fe 100644 --- a/data/gunbuilderParts.ts +++ b/data/gunbuilderParts.ts @@ -1,9 +1,9 @@ import type { Category } from "@/types/gunbuilder"; -export const CATEGORY_SLUGS = { +export const CATEGORY_SLUGS: Record = { lower: "lower", - "complete-lower": "complete-lower", - "lower-parts": "lower-parts", + completeLower: "complete-lower", + lowerParts: "lower-parts", trigger: "trigger", grip: "grip", safety: "safety", @@ -11,32 +11,32 @@ export const CATEGORY_SLUGS = { stock: "stock", upper: "upper", - "complete-upper": "complete-upper", + completeUpper: "complete-upper", barrel: "barrel", handguard: "handguard", - "gas-block": "gas-block", - "gas-tube": "gas-tube", - "muzzle-device": "muzzle-device", + gasBlock: "gas-block", + gasTube: "gas-tube", + muzzleDevice: "muzzle-device", bcg: "bcg", sights: "sights", - "charging-handle": "charging-handle", + chargingHandle: "charging-handle", suppressor: "suppressor", optic: "optic", magazine: "magazine", - "weapon-light": "weapon-light", + weaponLight: "weapon-light", foregrip: "foregrip", bipod: "bipod", sling: "sling", - "rail-accessory": "rail-accessory", + railAccessory: "rail-accessory", tools: "tools", -} as const; +}; export const CATEGORIES: Category[] = [ // Lower group { id: "lower", name: "Stripped Lowers", group: "lower", slug: CATEGORY_SLUGS.lower }, - { id: "complete-lower", name: "Complete Lower", group: "lower", slug: CATEGORY_SLUGS["complete-lower"] }, - { id: "lower-parts", name: "Lower Parts Kit", group: "lower", slug: CATEGORY_SLUGS["lower-parts"] }, + { id: "completeLower", name: "Complete Lower", group: "lower", slug: CATEGORY_SLUGS.completeLower }, + { id: "lowerParts", name: "Lower Parts Kit", group: "lower", slug: CATEGORY_SLUGS.lowerParts }, { id: "trigger", name: "Trigger / Fire Control", group: "lower", slug: CATEGORY_SLUGS.trigger }, { id: "grip", name: "Pistol Grip", group: "lower", slug: CATEGORY_SLUGS.grip }, { id: "safety", name: "Safety / Selector", group: "lower", slug: CATEGORY_SLUGS.safety }, @@ -45,24 +45,24 @@ export const CATEGORIES: Category[] = [ // Upper group { id: "upper", name: "Stripped Upper", group: "upper", slug: CATEGORY_SLUGS.upper }, - { id: "complete-upper", name: "Complete Upper", group: "upper", slug: CATEGORY_SLUGS["complete-upper"] }, + { id: "completeUpper", name: "Complete Upper", group: "upper", slug: CATEGORY_SLUGS.completeUpper }, { id: "barrel", name: "Barrels", group: "upper", slug: CATEGORY_SLUGS.barrel }, { id: "handguard", name: "Handguards / Rails", group: "upper", slug: CATEGORY_SLUGS.handguard }, - { id: "gas-block", name: "Gas Block", group: "upper", slug: CATEGORY_SLUGS["gas-block"] }, - { id: "gas-tube", name: "Gas Tube", group: "upper", slug: CATEGORY_SLUGS["gas-tube"] }, - { id: "muzzle-device", name: "Muzzle Device", group: "upper", slug: CATEGORY_SLUGS["muzzle-device"] }, + { id: "gasBlock", name: "Gas Block", group: "upper", slug: CATEGORY_SLUGS.gasBlock }, + { id: "gasTube", name: "Gas Tube", group: "upper", slug: CATEGORY_SLUGS.gasTube }, + { id: "muzzleDevice", name: "Muzzle Device", group: "upper", slug: CATEGORY_SLUGS.muzzleDevice }, { id: "sights", name: "Iron Sights", group: "upper", slug: CATEGORY_SLUGS.sights }, { id: "bcg", name: "Bolt Carrier Group", group: "upper", slug: CATEGORY_SLUGS.bcg }, - { id: "charging-handle", name: "Charging Handle", group: "upper", slug: CATEGORY_SLUGS["charging-handle"] }, + { id: "chargingHandle", name: "Charging Handle", group: "upper", slug: CATEGORY_SLUGS.chargingHandle }, { id: "suppressor", name: "Suppressors", group: "upper", slug: CATEGORY_SLUGS.suppressor }, { id: "optic", name: "Optics", group: "upper", slug: CATEGORY_SLUGS.optic }, - // Accessories + // Accessories (platform-agnostic) { id: "magazine", name: "Magazines", group: "accessories", slug: CATEGORY_SLUGS.magazine }, - { id: "weapon-light", name: "Weapon Lights & Lasers", group: "accessories", slug: CATEGORY_SLUGS["weapon-light"] }, + { id: "weaponLight", name: "Weapon Lights & Lasers", group: "accessories", slug: CATEGORY_SLUGS.weaponLight }, { id: "foregrip", name: "Vertical / Angled Grips", group: "accessories", slug: CATEGORY_SLUGS.foregrip }, { id: "bipod", name: "Bipods", group: "accessories", slug: CATEGORY_SLUGS.bipod }, { id: "sling", name: "Slings & Mounts", group: "accessories", slug: CATEGORY_SLUGS.sling }, - { id: "rail-accessory", name: "Rail Accessories", group: "accessories", slug: CATEGORY_SLUGS["rail-accessory"] }, + { id: "railAccessory", name: "Rail Accessories", group: "accessories", slug: CATEGORY_SLUGS.railAccessory }, { id: "tools", name: "Tools & Maintenance", group: "accessories", slug: CATEGORY_SLUGS.tools }, ]; \ No newline at end of file diff --git a/data/partRoleMappings.ts b/data/partRoleMappings.ts index 15f01ba..6b4be99 100644 --- a/data/partRoleMappings.ts +++ b/data/partRoleMappings.ts @@ -1,102 +1,41 @@ import type { CategoryId } from "@/types/gunbuilder"; -// CategoryId -> partRoles export const CATEGORY_TO_PART_ROLES: Record = { - // CORE / EXISTING - upper: ["upper-receiver"], - barrel: ["barrel"], - handguard: ["handguard"], - chargingHandle: ["charging-handle"], - buffer: ["buffer-kit"], - lowerParts: ["lower-parts-kit"], - sights: ["sight"], - lower: ["LOWER_RECEIVER_STRIPPED"], - optic: ["optic"], - stock: ["stock"], - - // NEW LOWER PARTS - trigger: ["trigger", "trigger-kit"], - grip: ["pistol-grip", "grip"], - safety: ["safety", "safety-selector"], - completeLower: ["complete-lower"], - - // NEW UPPER PARTS + upper: ["upper-receiver", "upper"], completeUpper: ["complete-upper"], - bcg: ["bcg", "bolt-carrier-group"], + barrel: ["barrel"], gasBlock: ["gas-block"], gasTube: ["gas-tube"], muzzleDevice: ["muzzle-device", "compensator", "brake"], suppressor: ["suppressor"], + handguard: ["handguard"], + chargingHandle: ["charging-handle"], + bcg: ["bcg", "bolt-carrier-group"], - // ===== ACCESSORIES ===== + lower: ["lower-receiver", "lower", "LOWER_RECEIVER_STRIPPED"], + completeLower: ["complete-lower"], + lowerParts: ["lower-parts-kit", "lower-parts"], + trigger: ["trigger", "trigger-kit"], + grip: ["pistol-grip", "grip"], + safety: ["safety", "safety-selector"], + buffer: ["buffer-kit", "buffer"], + stock: ["stock"], - // Magazines - magazine: [ - "magazine", - "mag", - "magazine-ar15", - "magazine-308", - "drum-magazine", - ], + sights: ["sight", "sights", "iron-sights"], + optic: ["optic", "optics"], - // Lights / Lasers - weaponLight: [ - "weapon-light", - "light", - "weapon-light-laser", - "light-laser-combo", - "laser", - ], - - // Foregrips - foregrip: [ - "vertical-grip", - "angled-foregrip", - "foregrip", - "handstop", - ], - - // Bipods - bipod: [ - "bipod", - ], - - // Slings & mounts - sling: [ - "sling", - "sling-mount", - "sling-swivel", - "qd-sling-mount", - ], - - // Rail sections & covers - railAccessory: [ - "rail-section", - "picatinny-rail-section", - "m-lok-rail-section", - "keymod-rail-section", - "rail-cover", - "rail-panel", - ], - - // Tools & maintenance - tools: [ - "tool", - "armorer-tool", - "armorer-wrench", - "cleaning-kit", - "bore-snake", - "vise-block", - "torque-wrench", - ], + magazine: ["magazine", "mag", "magazine-ar15", "magazine-308", "drum-magazine"], + weaponLight: ["weapon-light", "light", "weapon-light-laser", "light-laser-combo", "laser"], + foregrip: ["vertical-grip", "angled-foregrip", "foregrip", "handstop"], + bipod: ["bipod"], + sling: ["sling", "sling-mount", "sling-swivel", "qd-sling-mount"], + railAccessory: ["rail-section", "picatinny-rail-section", "m-lok-rail-section", "keymod-rail-section", "rail-cover", "rail-panel"], + tools: ["tool", "armorer-tool", "armorer-wrench", "cleaning-kit", "bore-snake", "vise-block", "torque-wrench"], }; -// Invert to get partRole -> CategoryId export const PART_ROLE_TO_CATEGORY: Record = Object.entries( - CATEGORY_TO_PART_ROLES, + CATEGORY_TO_PART_ROLES ).reduce((acc, [categoryId, roles]) => { - for (const role of roles) { - acc[role] = categoryId as CategoryId; - } + for (const role of roles) acc[role] = categoryId as CategoryId; return acc; }, {} as Record); \ No newline at end of file diff --git a/types/gunbuilder.ts b/types/gunbuilder.ts index 740a18e..082730c 100644 --- a/types/gunbuilder.ts +++ b/types/gunbuilder.ts @@ -1,35 +1,58 @@ // Grouping for nav + layout export type CategoryGroup = "lower" | "upper" | "accessories"; +/** + * CategoryId is the canonical identifier used throughout the UI + localStorage. + * + * ✅ Canonical IDs are kebab-case to match route segments and `gunbuilderParts.ts`. + * + * 🧯 Legacy camelCase IDs are temporarily supported to avoid breaking older + * localStorage/build-state and older code paths. Prefer kebab-case going forward. + */ export type CategoryId = - | "upper" - | "completeUpper" - | "barrel" - | "gasBlock" - | "gasTube" - | "muzzleDevice" - | "suppressor" - | "handguard" - | "chargingHandle" - | "bcg" - | "buffer" + // ===== LOWER ===== | "lower" - | "completeLower" - | "lowerParts" + | "complete-lower" + | "lower-parts" | "trigger" | "grip" | "safety" + | "buffer" + | "stock" + + // ===== UPPER ===== + | "upper" + | "complete-upper" + | "barrel" + | "handguard" + | "gas-block" + | "gas-tube" + | "muzzle-device" + | "bcg" + | "charging-handle" + | "suppressor" | "sights" | "optic" - | "stock" - // NEW ACCESSORY CATEGORIES + + // ===== ACCESSORIES ===== | "magazine" - | "weaponLight" + | "weapon-light" | "foregrip" | "bipod" | "sling" - | "railAccessory" - | "tools"; + | "rail-accessory" + | "tools" + + // ===== LEGACY (temporary) ===== + | "completeUpper" + | "gasBlock" + | "gasTube" + | "muzzleDevice" + | "chargingHandle" + | "completeLower" + | "lowerParts" + | "weaponLight" + | "railAccessory"; export interface Category { id: CategoryId; @@ -37,6 +60,8 @@ export interface Category { description?: string; /** used for nav & grouping */ group?: CategoryGroup; + /** optional slug override (mostly for legacy IDs) */ + slug?: string; } export interface Part { @@ -46,39 +71,59 @@ export interface Part { categoryId: CategoryId; price: number; affiliateUrl?: string; - url?: string; + url?: string; imageUrl?: string; notes?: string; } -export const CATEGORY_SLUGS: Record = { +/** + * Route / UI slug for each CategoryId. + * + * - Canonical kebab-case ids map 1:1. + * - Legacy camelCase ids map to the same slug. + */ +export const CATEGORY_SLUGS = { + // LOWER lower: "lower", - completeLower: "complete-lower", - lowerParts: "lower-parts", + "complete-lower": "complete-lower", + "lower-parts": "lower-parts", trigger: "trigger", grip: "grip", safety: "safety", buffer: "buffer", stock: "stock", + // UPPER upper: "upper", - completeUpper: "complete-upper", + "complete-upper": "complete-upper", barrel: "barrel", handguard: "handguard", - gasBlock: "gas-block", - gasTube: "gas-tube", - muzzleDevice: "muzzle-device", + "gas-block": "gas-block", + "gas-tube": "gas-tube", + "muzzle-device": "muzzle-device", sights: "sights", bcg: "bcg", - chargingHandle: "charging-handle", + "charging-handle": "charging-handle", suppressor: "suppressor", optic: "optic", + // ACCESSORIES magazine: "magazine", - weaponLight: "weapon-light", + "weapon-light": "weapon-light", foregrip: "foregrip", bipod: "bipod", sling: "sling", - railAccessory: "rail-accessory", + "rail-accessory": "rail-accessory", tools: "tools", -}; \ No newline at end of file + + // LEGACY (temporary) + completeUpper: "complete-upper", + gasBlock: "gas-block", + gasTube: "gas-tube", + muzzleDevice: "muzzle-device", + chargingHandle: "charging-handle", + completeLower: "complete-lower", + lowerParts: "lower-parts", + weaponLight: "weapon-light", + railAccessory: "rail-accessory", +} as const satisfies Record; \ No newline at end of file