"use client"; import Link from "next/link"; import { usePathname } from "next/navigation"; /** * 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 pathname = usePathname(); 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/v1/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, ]); useEffect(() => { // Reset scroll position whenever we land on or change parts routes window.scrollTo({ top: 0, left: 0, behavior: "auto" }); }, [pathname]); 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 && (
← Back to Build {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)) } /> )}
); }