/** * PartsBrowseClient * ----------------------------------------------------------------------------- * Browse shell for parts listing pages. * Owns: * - fetching parts from backend * - filter + sort + pagination state * - layout + list/card views */ "use client"; import Link from "next/link"; import { useEffect, useMemo, useState } from "react"; import { usePathname, 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 { normalizePlatformKey } from "@/lib/platforms"; import type { UiPart } from "@/types/uiPart"; import type { BuilderSlotKey, Category } from "@/types/builderSlots"; import { PART_ROLE_TO_CATEGORY, normalizePartRole, } from "@/lib/catalogMappings"; type PageResponse = { content: T[]; totalElements: number; totalPages: number; number: number; // 0-based size: number; }; function unwrapContent(json: any): { items: T[]; page?: PageResponse } { if (!json) return { items: [] }; if (Array.isArray(json)) return { items: json }; if (Array.isArray(json.content)) return { items: json.content, page: json as PageResponse }; return { items: [] }; } 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; imageUrl: string | null; buyUrl: string | null; inStock?: boolean | null; }; 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(); // ✅ Builder-mode = /parts/p/... (detail routes) const isBuilderMode = pathname.startsWith("/parts/p/"); // ✅ Respect prop override first, else read query, else fallback const effectivePlatform = normalizePlatformKey( props.platform ?? searchParams.get("platform") ?? "AR15" ); 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 [caliberFilter, setCaliberFilter] = 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); // UI 1-based const [serverTotalPages, setServerTotalPages] = useState(1); const [serverTotalElements, setServerTotalElements] = useState(0); // ---------------------------- // Fetch parts // ---------------------------- useEffect(() => { if (!partRole) return; const controller = new AbortController(); async function fetchParts() { try { setLoading(true); setError(null); const search = new URLSearchParams(); search.set("platform", normalizePlatformKey(effectivePlatform)); search.append("partRole", partRole); // server paging (0-based) search.set("page", String(currentPage - 1)); search.set("size", String(PAGE_SIZE)); // optional server search + sort (backend can ignore for now) if (searchQuery.trim()) search.set("q", searchQuery.trim()); if (brandFilter.length) brandFilter.forEach((b) => search.append("brand", b)); // server search caliber if (caliberFilter.length) caliberFilter.forEach((c) => search.append("caliber", c)); // sort mapping (Spring-style: sort=field,dir) switch (sortBy) { case "price-asc": search.set("sort", "price,asc"); break; case "price-desc": search.set("sort", "price,desc"); break; case "brand-asc": search.set("sort", "brand,asc"); break; case "relevance": default: // fallback (or whatever you consider "relevance") search.set("sort", "updatedAt,desc"); break; } const res = await fetch( `${API_BASE_URL}/api/v1/catalog/options?${search.toString()}`, { signal: controller.signal, } ); if (!res.ok) throw new Error(`Failed to load products (${res.status})`); const json = await res.json(); const { items, page } = unwrapContent(json); setParts( items.map((p) => ({ id: normalizeId(p.id), name: p.name, brand: p.brand, platform: p.platform, partRole: p.partRole, price: p.price, imageUrl: p.imageUrl ?? undefined, buyUrl: p.buyUrl ?? undefined, inStock: p.inStock ?? undefined, caliber: (p as any).caliber ?? null, })) ); if (page) { setServerTotalPages(page.totalPages ?? 1); setServerTotalElements(page.totalElements ?? items.length); } else { // backward compat setServerTotalPages(1); setServerTotalElements(items.length); } } catch (err: any) { if (err?.name === "AbortError") return; setError(err?.message ?? "Failed to load products"); } finally { setLoading(false); } } fetchParts(); return () => controller.abort(); }, [ partRole, effectivePlatform, currentPage, searchQuery, sortBy, brandFilter, caliberFilter, ]); // Reset pagination on filters useEffect(() => { setCurrentPage(1); }, [ partRole, effectivePlatform, brandFilter, caliberFilter, sortBy, searchQuery, priceRange, inStockOnly, ]); // Reset scroll on route change useEffect(() => { window.scrollTo({ top: 0, behavior: "auto" }); }, [pathname]); // ---------------------------- // Derived values // ---------------------------- const availableBrands = useMemo( () => Array.from(new Set(parts.map((p) => p.brand))) .filter(Boolean) .sort((a, b) => a.localeCompare(b)), [parts] ); const availableCalibers = useMemo( () => Array.from(new Set(parts.map((p: any) => p.caliber))) .filter(Boolean) .sort((a, b) => String(a).localeCompare(String(b))), [parts] ); const priceBounds = useMemo(() => { const prices = parts .map((p) => p.price) .filter((v): v is number => typeof v === "number"); if (!prices.length) return { min: null, max: null }; return { min: Math.min(...prices), max: Math.max(...prices) }; }, [parts]); // Keep priceRange clamped to bounds when bounds change useEffect(() => { const minBound = priceBounds.min; const maxBound = priceBounds.max; if (minBound == null || maxBound == null) return; setPriceRange((prev) => { const nextMin = prev.min == null ? minBound : Math.max(minBound, prev.min); const nextMax = prev.max == null ? maxBound : Math.min(maxBound, prev.max); // Ensure min never exceeds max (in case user typed weird values) return { min: Math.min(nextMin, nextMax), max: Math.max(nextMin, nextMax), }; }); }, [priceBounds.min, priceBounds.max]); const filteredParts = useMemo(() => parts, [parts]); const totalPages = Math.max(1, serverTotalPages); const paginatedParts = filteredParts; // filteredParts should become just parts now (see below) const visibleRange = { start: serverTotalElements ? (currentPage - 1) * PAGE_SIZE + 1 : 0, end: Math.min(currentPage * PAGE_SIZE, serverTotalElements), }; const headingTitle = props.title ?? `${partRole.replaceAll("-", " ")} parts`; const headingSubtitle = props.subtitle ?? "Browse available parts."; // ---------------------------- // Add → Builder handoff // ---------------------------- const handleAddToBuild = (p: UiPart) => { // Always normalize first; our mappings may alias multiple roles to one canonical builder category const roleKey = normalizePartRole(partRole); const categoryId: BuilderSlotKey | null = PART_ROLE_TO_CATEGORY[roleKey] ?? PART_ROLE_TO_CATEGORY[partRole] ?? null; if (!categoryId) { alert( `No Category mapping found for role "${partRole}" (normalized: "${roleKey}"). Add it to catalogMappings.` ); return; } const qp = new URLSearchParams(); qp.set("platform", normalizePlatformKey(effectivePlatform)); qp.set("select", `${categoryId}:${p.id}`); router.push(`/builder?${qp.toString()}`); }; return (

Battl Builders

{headingTitle}{" "} {effectivePlatform}

{headingSubtitle}

{/* {!isBuilderMode && (
*/} Start New Build → {/*
)} */}
{isBuilderMode && (
← Back to Build {/* Optional: view toggle in builder-mode too */} {parts.length > 0 && (
)}
)}
{parts.length > 0 && ( )}
{!loading && !error && ( )} {loading ? (

Loading…

) : error ? (

{error}

) : serverTotalElements === 0 ? (

No parts found for this role yet.

) : ( buildDetailHref(effectivePlatform, partRole, p) } onAddToBuild={handleAddToBuild} addLabel="Add to Build" /> )} {totalPages > 1 && ( setCurrentPage((p) => Math.max(1, p - 1))} onNext={() => setCurrentPage((p) => Math.min(totalPages, p + 1)) } /> )}
); }