"use client"; import { useEffect, useMemo, useState } from "react"; import Link from "next/link"; import { useParams, useRouter } from "next/navigation"; import { CATEGORIES } from "@/data/gunbuilderParts"; import { CATEGORY_TO_PART_ROLES } from "@/data/partRoleMappings"; import type { CategoryId, Part } from "@/types/gunbuilder"; type ViewMode = "card" | "list"; type GunbuilderProductFromApi = { id: string; name: string; brand: string; platform: string; partRole: string; price: number | null; mainImageUrl: string | null; buyUrl: string | null; // optional stock flag if/when backend sends it inStock?: boolean | null; }; type UiPart = Part & { // local-only stock flag for filtering inStock?: boolean; }; const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080"; // sort options type SortOption = "relevance" | "price-asc" | "price-desc" | "brand-asc"; // build state type + storage key (matches /gunbuilder) type BuildState = Partial>; const STORAGE_KEY = "gunbuilder-build-state"; export default function CategoryPage() { const params = useParams(); const categoryId = params.categoryId as CategoryId; const router = useRouter(); const [viewMode, setViewMode] = useState("list"); const [parts, setParts] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); // filter + sort state 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); const PAGE_SIZE = 24; // build state for this page, synced with localStorage 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 {}; } }); const category = useMemo( () => CATEGORIES.find((c) => c.id === categoryId), [categoryId], ); useEffect(() => { if (!categoryId) return; const controller = new AbortController(); async function fetchCategoryParts() { try { setLoading(true); setError(null); // FIX: determine which backend partRoles map to this category const roles = CATEGORY_TO_PART_ROLES[categoryId] ?? []; const search = new URLSearchParams(); search.set("platform", "AR-15"); roles.forEach((r) => search.append("partRoles", r)); const url = `${API_BASE_URL}/api/products/gunbuilder${ roles.length ? `?${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: p.id, categoryId, name: p.name, brand: p.brand, price: p.price ?? 0, imageUrl: p.mainImageUrl ?? undefined, url: p.buyUrl ?? undefined, notes: 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); } } fetchCategoryParts(); return () => controller.abort(); }, [categoryId]); // persist build to localStorage whenever it changes useEffect(() => { if (typeof window === "undefined") return; try { window.localStorage.setItem(STORAGE_KEY, JSON.stringify(build)); } catch { // ignore } }, [build]); useEffect(() => { // whenever the category or filters change, jump back to page 1 setCurrentPage(1); }, [categoryId, brandFilter, sortBy, searchQuery]); // handler to toggle Add / Remove for this category const handleTogglePart = (categoryId: CategoryId, partId: string) => { const isSelected = build[categoryId] === partId; if (isSelected) { // Remove from build setBuild((prev) => { const updated: BuildState = { ...prev }; delete updated[categoryId]; return updated; }); } else { // Add to build and navigate back to main gunbuilder page setBuild((prev) => ({ ...prev, [categoryId]: partId, })); router.push("/builder"); } }; // available brands for filter 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]); // initialize / clamp priceRange whenever bounds change 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]; // Price range filter const effectiveMin = priceRange.min ?? (priceBounds.min != null ? priceBounds.min : null); const effectiveMax = priceRange.max ?? (priceBounds.max != null ? priceBounds.max : null); if (effectiveMin != null && effectiveMax != null) { result = result.filter((p) => { const price = p.price ?? 0; return price >= effectiveMin && price <= effectiveMax; }); } // Brand filter if (brandFilter.length > 0) { result = result.filter((p) => brandFilter.includes(p.brand)); } // In-stock filter if (inStockOnly) { result = result.filter((p) => p.inStock ?? true); } const normalizedQuery = searchQuery.trim().toLowerCase(); if (normalizedQuery) { result = result.filter((p) => { const name = p.name.toLowerCase(); const brand = p.brand.toLowerCase(); return ( name.includes(normalizedQuery) || brand.includes(normalizedQuery) ); }); } 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: // leave in backend order break; } // Pin the currently selected part (for this category) to the top, if any const selectedId = build[categoryId]; if (selectedId) { const selected = result.filter((p) => p.id === selectedId); const others = result.filter((p) => p.id !== selectedId); result = [...selected, ...others]; } return result; }, [parts, brandFilter, sortBy, build, categoryId, searchQuery]); const totalPages = useMemo( () => (filteredParts.length === 0 ? 1 : Math.ceil(filteredParts.length / PAGE_SIZE)), [filteredParts, PAGE_SIZE] ); const paginatedParts = useMemo(() => { if (filteredParts.length === 0) return []; const startIndex = (currentPage - 1) * PAGE_SIZE; return filteredParts.slice(startIndex, startIndex + PAGE_SIZE); }, [filteredParts, currentPage, PAGE_SIZE]); useEffect(() => { // whenever the category or filters change, jump back to page 1 setCurrentPage(1); }, [categoryId, brandFilter, sortBy, searchQuery, priceRange, inStockOnly]); 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, PAGE_SIZE]); if (!category) { return (

Category Not Found

Return to Builder
); } return (
{/* Header */}
← Back to The Armory

Shadow Standard

{category.name} Parts

Browse all available {category.name.toLowerCase()} options for your build, pulled live from your Ballistic backend.

{!loading && !error && parts.length > 0 && (
)}
{/* Parts Grid/List */}
{/* Left sidebar: filters */} {!loading && !error && parts.length > 0 && ( )} {/* Right side: toolbar + results */}
{/* Top toolbar: count + search + sort */} {!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 && ( )}
)} {/* Results / states */} {loading ? (

Loading {category.name.toLowerCase()}…

) : error ? (

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

) : 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.notes && (

{part.notes}

)}
${part.price.toFixed(2)}
View Details
); })}
) : ( <> {/* Header row for list view */}
Part Brand Price Actions
{paginatedParts.map((part) => { const isSelected = build[categoryId] === part.id; return (
{part.name}
{part.notes && (

{part.notes}

)}
{part.brand}
${part.price.toFixed(2)}
View Details
); })}
)} {/* Pagination controls */} {filteredParts.length > 0 && totalPages > 1 && (
Page{" "} {currentPage} {" "} of{" "} {totalPages}
)}
); }