From e69205e6f86d9618c7f2842de8bc147fd47a18d2 Mon Sep 17 00:00:00 2001 From: Sean Date: Tue, 30 Dec 2025 13:53:52 -0500 Subject: [PATCH] cleaning up api calls and the builder page --- app/(app)/(builder)/builder/page.tsx | 169 ++-- .../__DELETE[partRole]/[productSlug]/page.tsx | 431 --------- .../_old_prod_details/data.ts | 116 --- .../_old_prod_details/page.tsx | 846 ------------------ .../parts/__DELETE[partRole]/page.tsx | 14 - components/parts/PartsBrowseClient.tsx | 62 +- 6 files changed, 82 insertions(+), 1556 deletions(-) delete mode 100644 app/(app)/(builder)/parts/__DELETE[partRole]/[productSlug]/page.tsx delete mode 100644 app/(app)/(builder)/parts/__DELETE[partRole]/_old_prod_details/data.ts delete mode 100644 app/(app)/(builder)/parts/__DELETE[partRole]/_old_prod_details/page.tsx delete mode 100644 app/(app)/(builder)/parts/__DELETE[partRole]/page.tsx diff --git a/app/(app)/(builder)/builder/page.tsx b/app/(app)/(builder)/builder/page.tsx index 03c4178..377524b 100644 --- a/app/(app)/(builder)/builder/page.tsx +++ b/app/(app)/(builder)/builder/page.tsx @@ -158,7 +158,7 @@ export default function GunbuilderPage() { // Parts data state // ----------------------------- const [parts, setParts] = useState([]); - const [loading, setLoading] = useState(true); + const [loading, setLoading] = useState(false); const [error, setError] = useState(null); // ----------------------------- @@ -379,104 +379,85 @@ export default function GunbuilderPage() { }; // ----------------------------- - // Fetch products (scoped + universal merge) + // Hydrate selected parts (only) from backend // ----------------------------- + const lastHydrateKeyRef = useRef(""); + useEffect(() => { const controller = new AbortController(); - - async function fetchProducts() { - try { - setLoading(true); + + async function hydrateSelected() { + const ids = (Object.values(build).filter(Boolean) as string[]) + .map(String) + .sort(); + + const key = ids.join(","); + if (key === lastHydrateKeyRef.current) return; + lastHydrateKeyRef.current = key; + + if (ids.length === 0) { + setParts([]); setError(null); - - const size = 2000; // temporary "big bucket" until we optimize - const scopedUrl = `${API_BASE_URL}/api/v1/products?platform=${encodeURIComponent( - platform - )}&page=0&size=${size}`; - const universalUrl = `${API_BASE_URL}/api/v1/products?page=0&size=${size}`; - - const [scopedRes, universalRes] = await Promise.all([ - fetch(scopedUrl, { signal: controller.signal }), - fetch(universalUrl, { signal: controller.signal }).catch( - () => null as any - ), - ]); - - if (!scopedRes || !scopedRes.ok) { - const status = scopedRes?.status ?? ""; - throw new Error(`Failed to load products (${status})`); - } - - const scopedJson = await scopedRes.json(); - const scopedData: GunbuilderProductFromApi[] = - unwrapPage(scopedJson); - - let universalData: GunbuilderProductFromApi[] = []; - if (universalRes && universalRes.ok) { - const universalJson = await universalRes.json(); - universalData = unwrapPage(universalJson); - } - const normalize = (data: GunbuilderProductFromApi[]): Part[] => - data - .map((p): Part | null => { - const normalizedRole = (p.partRole ?? "") - .trim() - .toLowerCase() - .replace(/_/g, "-"); - - const categoryId = resolveCategoryId(normalizedRole); - if (!categoryId) return null; - - // Only accept universal fetch results for "universal" categories - if ( - data === universalData && - !UNIVERSAL_CATEGORIES.has(categoryId) - ) { - return null; - } - - const buyUrl = p.buyUrl ?? undefined; - - return { - id: String(p.id), - categoryId, - name: p.name, - brand: p.brand, - price: p.price ?? 0, - imageUrl: - (p as any).imageUrl ?? (p as any).mainImageUrl ?? undefined, - affiliateUrl: buyUrl, - url: buyUrl, - notes: undefined, - }; - }) - .filter((p): p is Part => p !== null); - - const scopedParts = normalize(scopedData); - const universalParts = normalize(universalData); - - // Merge (avoid duplicates across calls) - const seen = new Set(); - const merged: Part[] = []; - for (const p of [...scopedParts, ...universalParts]) { - const key = `${p.categoryId}:${p.id}`; - if (seen.has(key)) continue; - seen.add(key); - merged.push(p); - } - - setParts(merged); + setLoading(false); + return; + } + + setLoading(true); + setError(null); + + try { + const res = await fetch( + `${API_BASE_URL}/api/v1/catalog/products/by-ids`, + { + method: "POST", + signal: controller.signal, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ids }), + } + ); + + if (!res.ok) throw new Error(`Failed to hydrate parts (${res.status})`); + + const data: GunbuilderProductFromApi[] = await res.json(); + + const hydrated: Part[] = data + .map((p): Part | null => { + const normalizedRole = (p.partRole ?? "") + .trim() + .toLowerCase() + .replace(/_/g, "-"); + + const categoryId = resolveCategoryId(normalizedRole); + if (!categoryId) return null; + + const buyUrl = p.buyUrl ?? undefined; + + return { + id: String(p.id), + categoryId, + name: p.name, + brand: p.brand, + price: p.price ?? 0, + imageUrl: p.imageUrl ?? undefined, + affiliateUrl: buyUrl, + url: buyUrl, + notes: undefined, + }; + }) + .filter((p): p is Part => p !== null); + + setParts(hydrated); } catch (err: any) { if (err?.name === "AbortError") return; - setError(err?.message ?? "Failed to load products"); + setError(err?.message ?? "Failed to hydrate selected parts"); } finally { setLoading(false); } } - - fetchProducts(); + + hydrateSelected(); return () => controller.abort(); - }, [platform]); + }, [build]); // ----------------------------- // Persist build to localStorage @@ -1367,11 +1348,13 @@ export default function GunbuilderPage() { - ) : hasParts ? ( + ) : ( Choose - ) : ( - - — - )} diff --git a/app/(app)/(builder)/parts/__DELETE[partRole]/[productSlug]/page.tsx b/app/(app)/(builder)/parts/__DELETE[partRole]/[productSlug]/page.tsx deleted file mode 100644 index 76cbdef..0000000 --- a/app/(app)/(builder)/parts/__DELETE[partRole]/[productSlug]/page.tsx +++ /dev/null @@ -1,431 +0,0 @@ -"use client"; - -import { useEffect, useMemo, useState } from "react"; -import Link from "next/link"; -import { useParams, useRouter, useSearchParams } from "next/navigation"; -import type { CategoryId } from "@/types/builderSlots"; -import { - PART_ROLE_TO_CATEGORY, - normalizePartRole, -} from "@/lib/catalogMappings"; - -type GunbuilderProductFromApi = { - id: number; - name: string; - brand: string; - platform: string; - partRole: string; - price: number | null; - imageUrl: string | null; - buyUrl: string | null; - inStock?: boolean | null; -}; - -type BuildState = Partial>; - -const API_BASE_URL = - process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080"; - -const STORAGE_KEY = "gunbuilder-build-state"; - -const PLATFORMS = ["AR-15", "AR-10", "AR-9"] as const; - -function extractNumericId(productSlug: string): number | null { - if (!productSlug) return null; - const first = productSlug.split("-")[0]; - const n = Number(first); - return Number.isFinite(n) && n > 0 ? n : null; -} - -function formatPrice(price: number | null | undefined): string { - if (price == null) return "—"; - return `$${price.toFixed(2)}`; -} - -export default function ProductDetailsPage() { - const params = useParams(); - const router = useRouter(); - const searchParams = useSearchParams(); - - // NEW ROUTE PARAMS: - const platformParam = String(params.platform ?? ""); - const partRoleParam = String(params.partRole ?? ""); - const productSlug = String(params.productSlug ?? ""); - - // Platform comes from path segment (fallback to ?platform just in case) - const platform = - platformParam || - (searchParams.get("platform") as string) || - "AR-15"; - - const normalizedRole = useMemo( - () => normalizePartRole(partRoleParam), - [partRoleParam] - ); - - const categoryId = useMemo(() => { - return PART_ROLE_TO_CATEGORY[partRoleParam] ?? PART_ROLE_TO_CATEGORY[normalizedRole] ?? null; - }, [partRoleParam, normalizedRole]); - - const numericId = useMemo( - () => extractNumericId(productSlug), - [productSlug] - ); - - // Read-only build state (builder page owns writes) - const [build] = useState(() => { - if (typeof window === "undefined") return {}; - try { - const stored = window.localStorage.getItem(STORAGE_KEY); - return stored ? (JSON.parse(stored) as BuildState) : {}; - } catch { - return {}; - } - }); - - const selectedPartIdForCategory = categoryId ? build?.[categoryId] : undefined; - const isSelected = - !!categoryId && - selectedPartIdForCategory === (numericId ? String(numericId) : ""); - - const [product, setProduct] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - // If platform segment is missing/empty, normalize into the new route (not /builder) - useEffect(() => { - if (platformParam) return; - - const qp = new URLSearchParams(searchParams.toString()); - qp.set("platform", platform || "AR-15"); - - router.replace( - `/parts/p/${encodeURIComponent(platform || "AR-15")}/${encodeURIComponent( - partRoleParam - )}/${encodeURIComponent(productSlug)}?${qp.toString()}` - ); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [platformParam, platform, partRoleParam, productSlug, router, searchParams]); - - // Fetch product - useEffect(() => { - if (!numericId) { - setError("Invalid product id."); - setLoading(false); - return; - } - - const controller = new AbortController(); - - async function fetchProduct() { - try { - setLoading(true); - setError(null); - - const url = `${API_BASE_URL}/api/v1/products/${numericId}`; - const res = await fetch(url, { signal: controller.signal }); - - if (!res.ok) { - throw new Error(`Failed to load product (${res.status})`); - } - - const data: GunbuilderProductFromApi = await res.json(); - setProduct(data); - } catch (err: any) { - if (err?.name === "AbortError") return; - setError(err?.message ?? "Failed to load product"); - } finally { - setLoading(false); - } - } - - fetchProduct(); - - return () => controller.abort(); - }, [numericId]); - - const handleTogglePart = () => { - if (!numericId) return; - - // If mapping is missing, don’t send a bogus builder action - if (!categoryId) { - alert( - `No CategoryId mapping found for partRole "${partRoleParam}". Add it in catalogMappings.` - ); - return; - } - - const qp = new URLSearchParams(); - qp.set("platform", platform || "AR-15"); - - if (isSelected) { - qp.set("remove", String(categoryId)); - router.push(`/builder?${qp.toString()}`); - return; - } - - qp.set("select", `${categoryId}:${numericId}`); - router.push(`/builder?${qp.toString()}`); - }; - - if (!categoryId) { - return ( -
-
-
-

- Unknown Part Role -

-

- No mapping found for {partRoleParam}. - Add it to catalogMappings. -

- - Back to Parts List - -
-
-
- ); - } - - return ( -
-
- {/* Breadcrumbs */} -
-
- - The Builder - - / - - Parts - - / - Details -
- -
-
-

- BATTL BUILDERS -

-

- Product Details -

-

- Live product data pulled from your Ballistic backend. Add it to your build - or jump back to compare options. -

-
- - {/* Platform switch (updates NEW route) */} -
- - -
-
-
- - {/* Body */} -
- {loading ? ( -

Loading product…

- ) : error ? ( -

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

- ) : !product ? ( -

Product not found.

- ) : ( -
- {/* Left: image */} -
-
- {product.imageUrl ? ( - // eslint-disable-next-line @next/next/no-img-element - {product.name} - ) : ( -
- No image -
- )} -
- -
- - Stock - - - {product.inStock === false ? "Out of stock" : "In stock"} - -
- -
- - Back to List - - -
-
- - {/* Right: details */} -
-
-
-
-

- {product.brand} -

-

- {product.name} -

- -
- - Platform:{" "} - - {product.platform || platform} - - - - Role:{" "} - - {product.partRole || partRoleParam} - - - - ID:{" "} - - {product.id} - - -
-
- -
-
- Current price -
-
- {formatPrice(product.price)} -
-
-
- -
- - - {product.buyUrl ? ( - - Buy from Merchant → - - ) : ( - - No buy link available - - )} -
- -
-

- Notes -

-

- This details page is intentionally “thin” for now — once we wire in - offers, price history, and richer product fields, this section becomes - the spec + comparison hub. -

-
-
- -
- - Tip: use “Back to List” to compare multiple parts quickly. - - - Platform comes from the URL segment:{" "} - /parts/p/{platform}/... - -
-
-
- )} -
-
-
- ); -} \ No newline at end of file diff --git a/app/(app)/(builder)/parts/__DELETE[partRole]/_old_prod_details/data.ts b/app/(app)/(builder)/parts/__DELETE[partRole]/_old_prod_details/data.ts deleted file mode 100644 index bb2f5f1..0000000 --- a/app/(app)/(builder)/parts/__DELETE[partRole]/_old_prod_details/data.ts +++ /dev/null @@ -1,116 +0,0 @@ -// app/builder/[categoryId]/[partId]/data.ts - -// Simulated data functions - Replace these with API calls when ready -// Example: export async function getPricingHistory(partId: string) { ... } - -const API_BASE_URL = - process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080"; - -export interface PriceHistoryPoint { - date: string; // ISO date string - price: number; -} - -export interface RetailerOffer { - id: string; - retailerName: string; - price: number; - originalPrice?: number; - inStock: boolean; - affiliateUrl: string; - lastUpdated: string; // ISO date string -} - -/** - * Get pricing history for a part - * TODO: Replace with API call: GET /api/parts/{partId}/pricing-history - * - * For now, we keep this simulated. - */ -export function getPricingHistory( - partId: string, - basePrice: number, -): PriceHistoryPoint[] { - // Generate 30 days of price history with some variation - const days = 30; - const history: PriceHistoryPoint[] = []; - const today = new Date(); - - for (let i = days - 1; i >= 0; i--) { - const date = new Date(today); - date.setDate(date.getDate() - i); - - // Simulate price fluctuations (±15% variation) - const variation = (Math.random() - 0.5) * 0.3; // -15% to +15% - const price = basePrice * (1 + variation); - - history.push({ - date: date.toISOString().split("T")[0], - price: Math.round(price * 100) / 100, - }); - } - - return history; -} - -/** - * Get retailer offers for a part - * Real implementation using Ballistic backend: - * GET /api/v1/products/{productId}/offers - */ -export async function getRetailerOffers( - productId: string, -): Promise { - const res = await fetch( - `${API_BASE_URL}/api/v1/products/${productId}/offers`, - { - // This will be called from the client detail page, - // so we *don't* use cache here. - cache: "no-store", - }, - ); - - if (!res.ok) { - throw new Error( - `Failed to load retailer offers (${res.status}) for product ${productId}`, - ); - } - - // Expected backend payload (example): - // [ - // { - // "id": "uuid-or-int", - // "merchantName": "Aero Precision", - // "price": 189.99, - // "originalPrice": 210.0, - // "inStock": true, - // "buyUrl": "https://classic.avantlink.com/click.php?...", - // "lastUpdated": "2025-11-30T15:22:00Z" - // }, - // ... - // ] - const data: Array<{ - id: string | number; - merchantName: string; - price: number; - originalPrice?: number | null; - inStock: boolean; - buyUrl: string; - lastUpdated: string; - }> = await res.json(); - - const offers: RetailerOffer[] = data - .map((o) => ({ - id: String(o.id), - retailerName: o.merchantName, - price: o.price, - originalPrice: o.originalPrice ?? undefined, - inStock: o.inStock, - affiliateUrl: o.buyUrl, - lastUpdated: o.lastUpdated, - })) - // Sort by lowest price first - .sort((a, b) => a.price - b.price); - - return offers; -} \ No newline at end of file diff --git a/app/(app)/(builder)/parts/__DELETE[partRole]/_old_prod_details/page.tsx b/app/(app)/(builder)/parts/__DELETE[partRole]/_old_prod_details/page.tsx deleted file mode 100644 index 8dc5d38..0000000 --- a/app/(app)/(builder)/parts/__DELETE[partRole]/_old_prod_details/page.tsx +++ /dev/null @@ -1,846 +0,0 @@ -"use client"; - -import { useEffect, useMemo, useState } from "react"; -import Link from "next/link"; -import { useParams, useRouter, useSearchParams } from "next/navigation"; -import { CATEGORIES } from "@/data/gunbuilderParts"; -import type { CategoryId, Part } from "@/types/builderSlots"; -import { CATEGORY_TO_PART_ROLES } from "@/data/partRoleMappings"; - -type ViewMode = "card" | "list"; - -type GunbuilderProductFromApi = { - id: number; - name: string; - brand: string; - platform: string; - partRole: string; - price: number | null; - imageUrl: string | null; - buyUrl: string | null; - inStock?: boolean | null; -}; - -type UiPart = Part & { - // local-only stock flag for filtering - inStock?: boolean; -}; - -const PLATFORMS = ["AR-15", "AR-10", "AR-9"]; - -const API_BASE_URL = - process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080"; - -type BuildState = Partial>; // categoryId -> partId - -const STORAGE_KEY = "gunbuilder-build-state"; - -// sort options -type SortOption = "relevance" | "price-asc" | "price-desc" | "brand-asc"; - -// support for url id-slug -const slugify = (str: string) => - str - .toLowerCase() - .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(); - - 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 (read-only), hydrated from localStorage - // NOTE: The main /builder page is the single source of truth for writing to STORAGE_KEY. - const [build] = useState(() => { - if (typeof window === "undefined") return {}; - try { - const stored = window.localStorage.getItem(STORAGE_KEY); - return stored ? (JSON.parse(stored) as BuildState) : {}; - } catch { - return {}; - } - }); - - const initialPlatform = (searchParams.get("platform") as string) || "AR-15"; - const [platform, setPlatform] = useState(initialPlatform); - - // If a user lands here without ?platform=..., normalize the URL so downstream navigation keeps platform. - useEffect(() => { - const qpPlatform = searchParams.get("platform"); - if (qpPlatform) return; - - const qp = new URLSearchParams(searchParams.toString()); - qp.set("platform", platform || "AR-15"); - router.replace(`/builder/${categoryId}?${qp.toString()}`); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [categoryId, router, searchParams]); - - 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 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/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: String(p.id), // normalize to string for BuildState - categoryId, - name: p.name, - brand: p.brand, - price: p.price ?? 0, - imageUrl: p.imageUrl ?? undefined, // match backend field name - 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, platform, partRoles]); - - // handler to toggle Add / Remove for this category - const handleTogglePart = (categoryId: CategoryId, partId: string) => { - const isSelected = build[categoryId] === partId; - - const qp = new URLSearchParams(); - qp.set("platform", platform || "AR-15"); - - if (isSelected) { - // Tell the main builder to remove this category selection - qp.set("remove", String(categoryId)); - router.push(`/builder?${qp.toString()}`); - return; - } - - // Tell the main builder to add/replace this category selection - qp.set("select", `${categoryId}:${partId}`); - router.push(`/builder?${qp.toString()}`); - }; - - // 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, priceBounds, priceRange, inStockOnly]); - - 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, platform]); - - useEffect(() => { - const nextPlatform = (searchParams.get("platform") as string) || "AR-15"; - setPlatform(nextPlatform); - }, [searchParams]); - - // Keep selection highlight in sync if the build is changed elsewhere and user comes back. - useEffect(() => { - if (typeof window === "undefined") return; - const onStorage = (e: StorageEvent) => { - if (e.key !== STORAGE_KEY) return; - // Force a re-render by reading current storage (since build state is read-only) - // This keeps the selected row pinned/highlighted when navigating back. - // eslint-disable-next-line @typescript-eslint/no-unused-expressions - window.localStorage.getItem(STORAGE_KEY); - }; - window.addEventListener("storage", onStorage); - return () => window.removeEventListener("storage", onStorage); - }, []); - - 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 Builder - -
-
-

- 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} -
-
- - -
-
- )} -
-
-
-
-
- ); -} \ No newline at end of file diff --git a/app/(app)/(builder)/parts/__DELETE[partRole]/page.tsx b/app/(app)/(builder)/parts/__DELETE[partRole]/page.tsx deleted file mode 100644 index 768646b..0000000 --- a/app/(app)/(builder)/parts/__DELETE[partRole]/page.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { redirect } from "next/navigation"; - -export default async function PartsRoleRedirectPage({ - params, - searchParams, -}: { - params: { partRole: string }; - searchParams?: { platform?: string }; -}) { - const partRole = params.partRole; - const platform = searchParams?.platform ?? "AR-15"; - - redirect(`/parts/p/${encodeURIComponent(platform)}/${encodeURIComponent(partRole)}`); -} \ No newline at end of file diff --git a/components/parts/PartsBrowseClient.tsx b/components/parts/PartsBrowseClient.tsx index b740e84..33a5a60 100644 --- a/components/parts/PartsBrowseClient.tsx +++ b/components/parts/PartsBrowseClient.tsx @@ -52,7 +52,7 @@ type GunbuilderProductFromApi = { platform: string; partRole: string; price: number | null; - mainImageUrl: string | null; + imageUrl: string | null; buyUrl: string | null; inStock?: boolean | null; }; @@ -150,7 +150,7 @@ export default function PartsBrowseClient(props: { const search = new URLSearchParams(); search.set("platform", effectivePlatform); - search.append("partRoles", partRole); + search.append("partRole", partRole); // server paging (0-based) search.set("page", String(currentPage - 1)); @@ -162,22 +162,13 @@ export default function PartsBrowseClient(props: { // sort mapping 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: search.set("sort", "updatedAt,desc"); break; } - const res = await fetch(`${API_BASE_URL}/api/v1/products?${search.toString()}`, { + const res = await fetch(`${API_BASE_URL}/api/v1/catalog/options?${search.toString()}`, { signal: controller.signal, }); @@ -194,7 +185,7 @@ export default function PartsBrowseClient(props: { platform: p.platform, partRole: p.partRole, price: p.price ?? 0, - imageUrl: p.mainImageUrl ?? undefined, + imageUrl: p.imageUrl ?? undefined, buyUrl: p.buyUrl ?? undefined, inStock: p.inStock ?? true, })) @@ -277,44 +268,7 @@ export default function PartsBrowseClient(props: { }); }, [priceBounds.min, priceBounds.max]); - const filteredParts = useMemo(() => { - let result = [...parts]; - - if (priceRange.min != null) - result = result.filter((p) => p.price >= priceRange.min!); - if (priceRange.max != null) - result = result.filter((p) => p.price <= priceRange.max!); - - if (brandFilter.length) - result = result.filter((p) => brandFilter.includes(p.brand)); - - if (inStockOnly) result = result.filter((p) => p.inStock ?? true); - - const q = searchQuery.toLowerCase().trim(); - if (q) - result = result.filter( - (p) => - p.name.toLowerCase().includes(q) || p.brand.toLowerCase().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, inStockOnly]); - + const filteredParts = useMemo(() => parts, [parts]); const totalPages = Math.max(1, serverTotalPages); const paginatedParts = filteredParts; // filteredParts should become just parts now (see below) @@ -338,7 +292,7 @@ export default function PartsBrowseClient(props: { if (!categoryId) { alert( - `No CategoryId mapping found for role "${partRole}". Add it to catalogMappings.` + `No Category mapping found for role "${partRole}". Add it to catalogMappings.` ); return; } @@ -453,7 +407,7 @@ export default function PartsBrowseClient(props: { {!loading && !error && ( ) : error ? (

{error}

- ) : filteredParts.length === 0 ? ( + ) : serverTotalElements === 0 ? (

No parts found for this role yet.