diff --git a/app/(app)/(builder)/builder/page.tsx b/app/(app)/(builder)/builder/page.tsx index 922dabd..c4c336c 100644 --- a/app/(app)/(builder)/builder/page.tsx +++ b/app/(app)/(builder)/builder/page.tsx @@ -395,82 +395,88 @@ const canonicalPlatform = normalizePlatformKey(platform); // ----------------------------- const lastHydrateKeyRef = useRef(""); - useEffect(() => { - const controller = new AbortController(); +useEffect(() => { + const controller = new AbortController(); - async function hydrateSelected() { - const ids = (Object.values(build).filter(Boolean) as string[]) - .map(String) - .sort(); + 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; + const key = ids.join(","); - if (ids.length === 0) { - setParts([]); - setError(null); - setLoading(false); - return; - } - - setLoading(true); + // If nothing selected, reset + if (ids.length === 0) { + lastHydrateKeyRef.current = ""; + setParts([]); 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(); - - // Map products by id for quick lookup - const byId = new Map(); - for (const p of data) byId.set(String(p.id), p); - - // Build the hydrated parts by iterating the BUILD STATE (slot -> id) - const hydrated: Part[] = Object.entries(build) - .filter(([, id]) => Boolean(id)) - .map(([slot, id]) => { - const p = byId.get(String(id)); - if (!p) return null; - - const buyUrl = p.buyUrl ?? undefined; - - return { - id: String(p.id), - categoryId: slot as any, // slot is the source of truth - name: p.name, - brand: p.brand, - price: p.price ?? 0, - imageUrl: p.imageUrl ?? undefined, - affiliateUrl: buyUrl, - url: buyUrl, - notes: undefined, - } as Part; - }) - .filter((x): x is Part => x !== null); - - setParts(hydrated); - } catch (err: any) { - if (err?.name === "AbortError") return; - setError(err?.message ?? "Failed to hydrate selected parts"); - } finally { - setLoading(false); - } + setLoading(false); + return; } - hydrateSelected(); - return () => controller.abort(); - }, [build]); + // Only skip if we *successfully* hydrated this key previously + if (key === lastHydrateKeyRef.current) 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 byId = new Map(); + for (const p of data) byId.set(String(p.id), p); + + const hydrated: Part[] = Object.entries(build) + .filter(([, id]) => Boolean(id)) + .map(([slot, id]) => { + const p = byId.get(String(id)); + if (!p) return null; + + const buyUrl = p.buyUrl ?? undefined; + + return { + id: String(p.id), + categoryId: slot as any, + name: p.name, + brand: p.brand, + price: p.price ?? 0, + imageUrl: p.imageUrl ?? undefined, + affiliateUrl: buyUrl, + url: buyUrl, + } as Part; + }) + .filter((x): x is Part => x !== null); + + setParts(hydrated); + + // ✅ Mark key as hydrated ONLY after success + lastHydrateKeyRef.current = key; + } catch (err: any) { + if (err?.name === "AbortError") { + // ✅ Do NOT lock the key on abort; allow retry + return; + } + setError(err?.message ?? "Failed to hydrate selected parts"); + } finally { + setLoading(false); + } + } + + hydrateSelected(); + return () => controller.abort(); +}, [build]); // ----------------------------- // Persist build to localStorage diff --git a/components/parts/PartsBrowseClient.tsx b/components/parts/PartsBrowseClient.tsx index b8370a1..1796ac6 100644 --- a/components/parts/PartsBrowseClient.tsx +++ b/components/parts/PartsBrowseClient.tsx @@ -20,7 +20,7 @@ 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 { Category } from "@/types/builderSlots"; import { PART_ROLE_TO_CATEGORY, @@ -58,18 +58,6 @@ type GunbuilderProductFromApi = { inStock?: boolean | null; }; -type UiPart = { - id: string; - name: string; - brand: string; - platform: string; - partRole: string; - price: number; - imageUrl?: string; - buyUrl?: string; - inStock?: boolean; -}; - const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080"; @@ -115,7 +103,7 @@ export default function PartsBrowseClient(props: { const effectivePlatform = normalizePlatformKey( props.platform ?? searchParams.get("platform") ?? "AR15" ); - + const partRole = props.partRole; const [viewMode, setViewMode] = useState("list"); @@ -201,10 +189,11 @@ export default function PartsBrowseClient(props: { brand: p.brand, platform: p.platform, partRole: p.partRole, - price: p.price ?? 0, + price: p.price, imageUrl: p.imageUrl ?? undefined, buyUrl: p.buyUrl ?? undefined, - inStock: p.inStock ?? true, + inStock: p.inStock ?? undefined, + caliber: (p as any).caliber ?? null, })) ); @@ -264,12 +253,11 @@ export default function PartsBrowseClient(props: { ); const priceBounds = useMemo(() => { - if (!parts.length) - return { min: null as number | null, max: null as number | null }; - return { - min: Math.min(...parts.map((p) => p.price)), - max: Math.max(...parts.map((p) => p.price)), - }; + 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 @@ -355,15 +343,15 @@ export default function PartsBrowseClient(props: { mode="browse" /> */} - - Start New Build → - - {/* + + Start New Build → + + {/* )} */} diff --git a/components/parts/PartsGrid.tsx b/components/parts/PartsGrid.tsx index 8f8791f..be17387 100644 --- a/components/parts/PartsGrid.tsx +++ b/components/parts/PartsGrid.tsx @@ -2,20 +2,7 @@ import Link from "next/link"; import { Plus } from "lucide-react"; - -type UiPart = { - id: string; - name: string; - brand: string; - caliber?: string; - platform: string; - partRole: string; - price: number; - imageUrl?: string; - buyUrl?: string; // Used for debugging - buyShortUrl?: string; - inStock?: boolean; -}; +import type { UiPart } from "@/types/uiPart"; export default function PartsGrid(props: { viewMode: "card" | "list"; @@ -28,63 +15,80 @@ export default function PartsGrid(props: { const { viewMode, parts, buildDetailHref, onAddToBuild } = props; const addLabel = props.addLabel ?? "Add to Build"; + // ✅ Only show caliber column if we actually have caliber data + const showCaliber = parts.some((p) => !!p.caliber); + + // ✅ Single source of truth for grid columns (prevents header/row drift) + // Columns: Image | Part | Brand | (Caliber?) | Price | Actions + const gridCols = showCaliber + ? "md:grid-cols-[44px_minmax(0,1fr)_160px_120px_110px_120px]" // +Caliber + : "md:grid-cols-[44px_minmax(0,1fr)_160px_110px_120px]"; // no Caliber"md:grid-cols-[44px_minmax(0,1fr)_160px_110px_120px]"; + + const formatPrice = (price?: number | null) => { + if (price == null) return "—"; + return `$${price.toFixed(2)}`; + }; + if (viewMode === "card") { return (
- {parts.map((part) => ( -
-
-
-
- {part.brand}{" "} - — {part.name} + {parts.map((part) => { + const buyHref = part.buyShortUrl ?? part.buyUrl; + return ( +
+
+
+
+ {part.brand}{" "} + — {part.name} +
+
+ +
+ {formatPrice(part.price)}
-
- ${part.price.toFixed(2)} +
+ + View Details + + + {onAddToBuild ? ( + + ) : buyHref ? ( + + Buy + + ) : ( + + )}
- -
- - View Details - - - {onAddToBuild ? ( - - ) : part.buyShortUrl ?? part.buyUrl ? ( - - Buy - - ) : ( - - )} -
-
- ))} + ); + })}
); } @@ -93,96 +97,115 @@ export default function PartsGrid(props: { return ( <> {/* Header (DESKTOP ONLY) */} -
- Image +
+ Image Part - Caliber + Brand + {showCaliber ? Caliber : null} Price Actions
{/* Rows */}
- {parts.map((part) => ( -
-
- {/* Image */} -
- {part.imageUrl ? ( - // eslint-disable-next-line @next/next/no-img-element - - ) : ( -
- )} -
- {/* Part */} -
- - {part.name} - - {/* Dont think we need Brand in the grid */} - {/*
- {part.brand} - {part.caliber ? ( - • {part.caliber} - ) : null} -
*/} -
- {/* Caliber (desktop) */} -
- {part.caliber ?? "—"} -
+ {parts.map((part) => { + const buyHref = part.buyShortUrl ?? part.buyUrl; - {/* Price */} -
- ${part.price.toFixed(2)} -
+ return ( +
+
+ {/* Image */} +
+ {part.imageUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + + ) : ( +
+ )} +
- {/* Actions */} -
- {onAddToBuild ? ( - - ) : part.buyShortUrl ?? part.buyUrl ? ( - - Buy - - ) : ( - - )} + {part.name} + +
+ + {/* Brand */} +
+ {part.brand || "—"} +
+ + {/* Caliber (optional) */} + {showCaliber ? ( +
+ {part.caliber ?? "—"} +
+ ) : null} + + {/* Price */} +
+ {formatPrice(part.price)} +
+ + {/* Actions */} +
+ {onAddToBuild ? ( + + ) : buyHref ? ( + + Buy + + ) : ( + + )} +
-
- ))} + ); + })}
); diff --git a/types/uiPart.ts b/types/uiPart.ts new file mode 100644 index 0000000..fab5de1 --- /dev/null +++ b/types/uiPart.ts @@ -0,0 +1,17 @@ +// /types/uiPart.ts +export type UiPart = { + id: string; + name: string; + brand: string; + caliber?: string | null; + platform: string; + partRole: string; + + // allow nulls from API (don’t coerce to 0) + price?: number | null; + imageUrl?: string; + buyUrl?: string; + buyShortUrl?: string; + + inStock?: boolean | null; + }; \ No newline at end of file