"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 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; }; const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080"; const CATEGORY_TO_PART_ROLES: Record = { upper: ["upper-receiver"], barrel: ["barrel"], handguard: ["handguard"], chargingHandle: ["charging-handle"], buffer: ["buffer-kit"], lowerParts: ["lower-parts-kit"], sights: ["sight"], lower: ["lower-receiver"], optic: ["optic"], stock: ["stock"], }; // 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("all"); const [sortBy, setSortBy] = useState("relevance"); const [searchQuery, setSearchQuery] = useState(""); // 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); 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: Part[] = 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, })); 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]); // 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("/gunbuilder"); } }; // 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], ); // filtered + sorted parts with selected part pinned to top const filteredParts = useMemo(() => { let result = [...parts]; if (brandFilter !== "all") { result = result.filter((p) => p.brand === brandFilter); } 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]); if (!category) { return (

Category Not Found

Return to Gunbuilder
); } 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 */}
{/* Filter + sort toolbar */} {!loading && !error && parts.length > 0 && (
Showing{" "} {filteredParts.length} {" "} of{" "} {parts.length} {" "} parts
setSearchQuery(e.target.value)} placeholder={`Search ${category.name.toLowerCase()}...`} className="rounded-md border border-zinc-700 bg-zinc-900/70 text-xs text-zinc-200 px-2 py-1 pr-6 focus:outline-none focus:ring-1 focus:ring-amber-400" /> {searchQuery && ( )}
)} {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" ? (
{filteredParts.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
{filteredParts.map((part) => { const isSelected = build[categoryId] === part.id; return (
{part.name}
{part.notes && (

{part.notes}

)}
{part.brand}
${part.price.toFixed(2)}
View Details
); })}
)}
); }