diff --git a/app/(builder)/builder/[categoryId]/page.tsx b/app/(builder)/builder/[categoryId]/page.tsx index a606572..e8c0b92 100644 --- a/app/(builder)/builder/[categoryId]/page.tsx +++ b/app/(builder)/builder/[categoryId]/page.tsx @@ -5,6 +5,7 @@ import Link from "next/link"; import { useParams, useRouter, useSearchParams } from "next/navigation"; import { CATEGORIES } from "@/data/gunbuilderParts"; import type { CategoryId, Part } from "@/types/gunbuilder"; +import { CATEGORY_TO_PART_ROLES } from "@/data/partRoleMappings"; type ViewMode = "card" | "list"; @@ -44,9 +45,21 @@ const slugify = (str: string) => .replace(/[^a-z0-9]+/g, "-") .replace(/(^-|-$)/g, ""); +const normalizeRole = (role: string) => role.trim().toLowerCase().replace(/_/g, "-"); + +function getNormalizedPartRolesForCategory(categoryId: string): string[] { + const roles = (CATEGORY_TO_PART_ROLES as any)[categoryId] as string[] | undefined; + if (!roles || roles.length === 0) return []; + return Array.from(new Set(roles.map(normalizeRole))); +} + export default function CategoryPage() { const params = useParams(); const categoryId = params.categoryId as CategoryId; + const partRoles = useMemo( + () => getNormalizedPartRolesForCategory(String(categoryId)), + [categoryId] + ); const router = useRouter(); const searchParams = useSearchParams(); @@ -102,6 +115,11 @@ export default function CategoryPage() { const search = new URLSearchParams(); search.set("platform", platform); + // ✅ Scope results to this category's backend roles + for (const r of partRoles) { + search.append("partRoles", r); + } + const url = `${API_BASE_URL}/api/products?${search.toString()}`; const res = await fetch(url, { signal: controller.signal }); @@ -136,7 +154,7 @@ export default function CategoryPage() { fetchCategoryParts(); return () => controller.abort(); - }, [categoryId, platform]); + }, [categoryId, platform, partRoles]); // persist build to localStorage whenever it changes useEffect(() => { diff --git a/app/(builder)/builder/page.tsx b/app/(builder)/builder/page.tsx index d2cfec1..856bc61 100644 --- a/app/(builder)/builder/page.tsx +++ b/app/(builder)/builder/page.tsx @@ -155,7 +155,9 @@ export default function GunbuilderPage() { const [scopedRes, universalRes] = await Promise.all([ fetch(scopedUrl, { signal: controller.signal }), - fetch(universalUrl, { signal: controller.signal }).catch(() => null as any), + fetch(universalUrl, { signal: controller.signal }).catch( + () => null as any + ), ]); if (!scopedRes || !scopedRes.ok) { @@ -174,12 +176,20 @@ export default function GunbuilderPage() { const normalize = (data: GunbuilderProductFromApi[]): Part[] => data .map((p): Part | null => { - const categoryId = PART_ROLE_TO_CATEGORY[p.partRole]; + const normalizedRole = (p.partRole ?? "") + .trim() + .toLowerCase() + .replace(/_/g, "-"); + + const categoryId = PART_ROLE_TO_CATEGORY[normalizedRole]; if (!categoryId) return 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)) { + if ( + data === universalData && + !UNIVERSAL_CATEGORIES.has(categoryId) + ) { return null; } @@ -471,13 +481,15 @@ export default function GunbuilderPage() { onChange={(e) => { const next = e.target.value as (typeof PLATFORMS)[number]; setPlatform(next); - + // Keep URL in sync so navigation + refresh preserve platform const qp = new URLSearchParams(searchParams.toString()); qp.set("platform", next); qp.delete("select"); - - router.replace(`/builder?${qp.toString()}`, { scroll: false }); + + router.replace(`/builder?${qp.toString()}`, { + scroll: false, + }); }} className="rounded-md border border-zinc-700 bg-zinc-900 px-2 py-1 text-xs text-zinc-100 focus:outline-none focus:ring-1 focus:ring-amber-400" > diff --git a/data/gunbuilderParts.ts b/data/gunbuilderParts.ts index 57c08fe..dc4cad7 100644 --- a/data/gunbuilderParts.ts +++ b/data/gunbuilderParts.ts @@ -1,42 +1,11 @@ -import type { Category } from "@/types/gunbuilder"; - -export const CATEGORY_SLUGS: Record = { - lower: "lower", - completeLower: "complete-lower", - lowerParts: "lower-parts", - trigger: "trigger", - grip: "grip", - safety: "safety", - buffer: "buffer", - stock: "stock", - - upper: "upper", - completeUpper: "complete-upper", - barrel: "barrel", - handguard: "handguard", - gasBlock: "gas-block", - gasTube: "gas-tube", - muzzleDevice: "muzzle-device", - bcg: "bcg", - sights: "sights", - chargingHandle: "charging-handle", - suppressor: "suppressor", - optic: "optic", - - magazine: "magazine", - weaponLight: "weapon-light", - foregrip: "foregrip", - bipod: "bipod", - sling: "sling", - railAccessory: "rail-accessory", - tools: "tools", -}; +import type { Category, CategoryId } from "@/types/gunbuilder"; +import { CATEGORY_SLUGS } from "@/types/gunbuilder"; export const CATEGORIES: Category[] = [ // Lower group { id: "lower", name: "Stripped Lowers", group: "lower", slug: CATEGORY_SLUGS.lower }, - { id: "completeLower", name: "Complete Lower", group: "lower", slug: CATEGORY_SLUGS.completeLower }, - { id: "lowerParts", name: "Lower Parts Kit", group: "lower", slug: CATEGORY_SLUGS.lowerParts }, + { 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: "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 +14,24 @@ export const CATEGORIES: Category[] = [ // Upper group { id: "upper", name: "Stripped Upper", group: "upper", slug: CATEGORY_SLUGS.upper }, - { id: "completeUpper", name: "Complete Upper", group: "upper", slug: CATEGORY_SLUGS.completeUpper }, + { id: "complete-upper", name: "Complete Upper", group: "upper", slug: CATEGORY_SLUGS["complete-upper"] }, { id: "barrel", name: "Barrels", group: "upper", slug: CATEGORY_SLUGS.barrel }, { id: "handguard", name: "Handguards / Rails", group: "upper", slug: CATEGORY_SLUGS.handguard }, - { 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: "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: "sights", name: "Iron Sights", group: "upper", slug: CATEGORY_SLUGS.sights }, { id: "bcg", name: "Bolt Carrier Group", group: "upper", slug: CATEGORY_SLUGS.bcg }, - { id: "chargingHandle", name: "Charging Handle", group: "upper", slug: CATEGORY_SLUGS.chargingHandle }, + { id: "charging-handle", name: "Charging Handle", group: "upper", slug: CATEGORY_SLUGS["charging-handle"] }, { id: "suppressor", name: "Suppressors", group: "upper", slug: CATEGORY_SLUGS.suppressor }, { id: "optic", name: "Optics", group: "upper", slug: CATEGORY_SLUGS.optic }, // Accessories (platform-agnostic) { id: "magazine", name: "Magazines", group: "accessories", slug: CATEGORY_SLUGS.magazine }, - { id: "weaponLight", name: "Weapon Lights & Lasers", group: "accessories", slug: CATEGORY_SLUGS.weaponLight }, + { id: "weapon-light", name: "Weapon Lights & Lasers", group: "accessories", slug: CATEGORY_SLUGS["weapon-light"] }, { 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: "railAccessory", name: "Rail Accessories", group: "accessories", slug: CATEGORY_SLUGS.railAccessory }, + { id: "rail-accessory", name: "Rail Accessories", group: "accessories", slug: CATEGORY_SLUGS["rail-accessory"] }, { 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 6b4be99..f6ff731 100644 --- a/data/partRoleMappings.ts +++ b/data/partRoleMappings.ts @@ -1,41 +1,55 @@ import type { CategoryId } from "@/types/gunbuilder"; -export const CATEGORY_TO_PART_ROLES: Record = { +/** + * Maps canonical kebab-case CategoryIds to their associated part roles from the backend. + */ +export const CATEGORY_TO_PART_ROLES: Partial> = { + // ===== UPPER ===== upper: ["upper-receiver", "upper"], - completeUpper: ["complete-upper"], + "complete-upper": ["complete-upper"], barrel: ["barrel"], - gasBlock: ["gas-block"], - gasTube: ["gas-tube"], - muzzleDevice: ["muzzle-device", "compensator", "brake"], + "gas-block": ["gas-block"], + "gas-tube": ["gas-tube"], + "muzzle-device": ["muzzle-device", "compensator", "brake"], suppressor: ["suppressor"], handguard: ["handguard"], - chargingHandle: ["charging-handle"], + "charging-handle": ["charging-handle"], bcg: ["bcg", "bolt-carrier-group"], + // ===== LOWER ===== lower: ["lower-receiver", "lower", "LOWER_RECEIVER_STRIPPED"], - completeLower: ["complete-lower"], - lowerParts: ["lower-parts-kit", "lower-parts"], + "complete-lower": ["complete-lower"], + "lower-parts": ["lower-parts-kit", "lower-parts"], trigger: ["trigger", "trigger-kit"], grip: ["pistol-grip", "grip"], safety: ["safety", "safety-selector"], buffer: ["buffer-kit", "buffer"], stock: ["stock"], + // ===== OPTICS ===== sights: ["sight", "sights", "iron-sights"], optic: ["optic", "optics"], + // ===== ACCESSORIES ===== magazine: ["magazine", "mag", "magazine-ar15", "magazine-308", "drum-magazine"], - weaponLight: ["weapon-light", "light", "weapon-light-laser", "light-laser-combo", "laser"], + "weapon-light": ["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"], + "rail-accessory": ["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"], }; export const PART_ROLE_TO_CATEGORY: Record = Object.entries( CATEGORY_TO_PART_ROLES ).reduce((acc, [categoryId, roles]) => { - for (const role of roles) acc[role] = categoryId as CategoryId; + for (const role of roles) { + // store the original string + acc[role] = categoryId as CategoryId; + + // also store a normalized version (handles ENUM_CASE and snake_case) + const normalized = role.trim().toLowerCase().replace(/_/g, "-"); + acc[normalized] = categoryId as CategoryId; + } return acc; }, {} as Record); \ No newline at end of file