diff --git a/app/(app)/(builder)/builder/page.tsx b/app/(app)/(builder)/builder/page.tsx index 84203cf..6288fe6 100644 --- a/app/(app)/(builder)/builder/page.tsx +++ b/app/(app)/(builder)/builder/page.tsx @@ -735,7 +735,9 @@ export default function GunbuilderPage() { const payload = JSON.stringify(build); const encoded = window.btoa(payload); const origin = window.location?.origin ?? ""; - const url = `${origin}/builder/build?build=${encodeURIComponent(encoded)}`; + const url = `${origin}/builder/build?build=${encodeURIComponent( + encoded + )}`; setShareUrl(url); } catch { setShareUrl(""); @@ -1024,7 +1026,9 @@ export default function GunbuilderPage() { }} disabled={selectedParts.length === 0} className={`w-full sm:w-auto rounded-md border border-zinc-700 bg-zinc-900/50 px-3 py-2 text-sm font-medium text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600 transition-colors ${ - selectedParts.length === 0 ? "opacity-40 cursor-not-allowed" : "" + selectedParts.length === 0 + ? "opacity-40 cursor-not-allowed" + : "" }`} > Clear Build @@ -1212,8 +1216,9 @@ export default function GunbuilderPage() { const selectedPartId = build[category.id]; const selectedPart = selectedPartId - ? categoryParts.find((p) => p.id === selectedPartId) ?? - parts.find((p) => p.id === selectedPartId) + ? categoryParts.find( + (p) => p.id === selectedPartId + ) ?? parts.find((p) => p.id === selectedPartId) : undefined; const hasParts = categoryParts.length > 0; @@ -1232,7 +1237,24 @@ export default function GunbuilderPage() { {selectedPart ? ( <>
- {selectedPart.name} + + {selectedPart.name} + {" "}
{/* Overlap chips help explain conflicts / dependencies */} @@ -1317,7 +1339,9 @@ export default function GunbuilderPage() { ) : hasParts ? ( ); -} \ No newline at end of file +} diff --git a/app/(app)/(builder)/parts/p/[platform]/[partRole]/[productSlug]/page.tsx b/app/(app)/(builder)/parts/p/[platform]/[partRole]/[productSlug]/page.tsx index c61afa5..6dd0fdd 100644 --- a/app/(app)/(builder)/parts/p/[platform]/[partRole]/[productSlug]/page.tsx +++ b/app/(app)/(builder)/parts/p/[platform]/[partRole]/[productSlug]/page.tsx @@ -5,19 +5,18 @@ import Link from "next/link"; import { useParams, useRouter, useSearchParams } from "next/navigation"; import type { CategoryId } from "@/types/gunbuilder"; -import { - PART_ROLE_TO_CATEGORY, - normalizePartRole, -} from "@/lib/catalogMappings"; +import { PART_ROLE_TO_CATEGORY, normalizePartRole } from "@/lib/catalogMappings"; /** * API Shapes - * - OfferFromApi: what /api/v1/products/:id returns in offers[] - * - GunbuilderProductFromApi: the product detail contract (v1) + * - OfferFromApi: what /api/v1/products/:id/offers returns + * - GunbuilderProductFromApi: product detail contract (v1) */ type OfferFromApi = { + id?: string | number; merchantName?: string | null; price?: number | null; + originalPrice?: number | null; buyUrl?: string | null; inStock?: boolean | null; lastUpdated?: string | null; @@ -43,7 +42,7 @@ type GunbuilderProductFromApi = { shortDescription?: string | null; description?: string | null; - // New: offers[] from the API + // (May be empty depending on endpoint; we now fetch offers separately) offers?: OfferFromApi[] | null; }; @@ -72,11 +71,44 @@ function formatPrice(price: number | null | undefined): string { return `$${price.toFixed(2)}`; } +/** + * Derive a friendly merchant label. + * Priority: + * 1) offer.merchantName (best) + * 2) hostname from offer.buyUrl (fallback) + * 3) "Retailer" + */ +function getHostFromUrl(url?: string | null): string | null { + if (!url) return null; + try { + const u = new URL(url); + return u.host.replace(/^www\./, ""); + } catch { + return null; + } +} + +function toTitleCase(s: string): string { + return s + .replace(/[-_]+/g, " ") + .replace(/\b\w/g, (c) => c.toUpperCase()); +} + +function merchantLabel(offer: OfferFromApi): string { + const name = offer.merchantName?.trim(); + if (name) return name; + + const host = getHostFromUrl(offer.buyUrl); + if (host) return toTitleCase(host.split(".")[0]); + + return "Retailer"; +} + /** * Sort offers: * 1) lowest price first * 2) in-stock before out-of-stock - * 3) merchant name tiebreaker + * 3) merchant label tiebreaker */ function sortOffers(offers: OfferFromApi[]): OfferFromApi[] { return [...offers].sort((a, b) => { @@ -89,7 +121,7 @@ function sortOffers(offers: OfferFromApi[]): OfferFromApi[] { const bStock = b.inStock === false ? 1 : 0; if (aStock !== bStock) return aStock - bStock; - return (a.merchantName ?? "").localeCompare(b.merchantName ?? ""); + return merchantLabel(a).localeCompare(merchantLabel(b)); }); } @@ -153,9 +185,7 @@ export default function ProductDetailsPage() { return () => window.removeEventListener("storage", onStorage); }, []); - const selectedPartIdForCategory = categoryId - ? build?.[categoryId] - : undefined; + const selectedPartIdForCategory = categoryId ? build?.[categoryId] : undefined; const isSelected = !!categoryId && selectedPartIdForCategory === (numericId ? String(numericId) : ""); @@ -165,6 +195,13 @@ export default function ProductDetailsPage() { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + // Offers fetch state (from /products/:id/offers) + const [offersFromApi, setOffersFromApi] = useState([]); + const [offersLoading, setOffersLoading] = useState(false); + + // Image modal state + const [isImageOpen, setIsImageOpen] = useState(false); + /** * Route normalization: * If platform segment is missing/empty, rewrite to canonical /parts/p route. @@ -181,14 +218,7 @@ export default function ProductDetailsPage() { )}/${encodeURIComponent(productSlug)}?${qp.toString()}` ); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ - platformParam, - platform, - partRoleParam, - productSlug, - router, - searchParams, - ]); + }, [platformParam, platform, partRoleParam, productSlug, router, searchParams]); /** * Fetch product details from API: @@ -229,6 +259,41 @@ export default function ProductDetailsPage() { return () => controller.abort(); }, [numericId]); + /** + * Fetch offers (rich offer data) + * GET /api/v1/products/:id/offers + */ + useEffect(() => { + if (!numericId) return; + + const controller = new AbortController(); + + async function fetchOffers() { + try { + setOffersLoading(true); + + const url = `${API_BASE_URL}/api/v1/products/${numericId}/offers`; + const res = await fetch(url, { signal: controller.signal }); + + if (!res.ok) { + throw new Error(`Failed to load offers (${res.status})`); + } + + const data: OfferFromApi[] = await res.json(); + setOffersFromApi(Array.isArray(data) ? data : []); + } catch (err: any) { + if (err?.name === "AbortError") return; + // offers failing shouldn't kill the page + setOffersFromApi([]); + } finally { + setOffersLoading(false); + } + } + + fetchOffers(); + return () => controller.abort(); + }, [numericId]); + /** * Builder selection toggle: * - If selected -> remove from build @@ -261,19 +326,16 @@ export default function ProductDetailsPage() { /** * Offers normalization: - * - If product.offers exists, use & sort it (real offers). - * - Otherwise, fallback to a single offer derived from product.buyUrl/product.price (legacy). + * - Prefer offers endpoint data + * - Fallback to legacy single-offer derived from product if needed */ const offers = useMemo(() => { - if (!product) return []; + if (offersFromApi.length > 0) return sortOffers(offersFromApi); - const raw = (product.offers ?? []).filter(Boolean) as OfferFromApi[]; - if (raw.length > 0) return sortOffers(raw); - - if (product.buyUrl) { + if (product?.buyUrl) { return sortOffers([ { - merchantName: product.merchantName ?? "Merchant", + merchantName: product.merchantName ?? "Retailer", price: product.price, buyUrl: product.buyUrl, inStock: product.inStock ?? true, @@ -282,21 +344,30 @@ export default function ProductDetailsPage() { } return []; - }, [product]); + }, [offersFromApi, product]); /** - * Best offer (for the "Buy from ..." CTA) - * IMPORTANT: this must be defined AFTER offers is computed - * (do NOT reference `offers` inside the offers useMemo). + * Best offer (sorted offers[0]) */ const bestOffer = offers[0] ?? null; /** * Helper: identify the best offer row - * We compare by buyUrl (stable + unique enough for MVP) */ const isBestOffer = (offer: OfferFromApi) => - !!bestOffer && offer.buyUrl && offer.buyUrl === bestOffer.buyUrl; + !!bestOffer && + offer.id != null && + bestOffer.id != null && + String(offer.id) === String(bestOffer.id); + + useEffect(() => { + console.log("OFFERS", offers); + console.log("BEST", bestOffer); + }, [offers, bestOffer]); + + // Use best offer for “truth” when available + const stockValue = bestOffer?.inStock ?? product?.inStock ?? null; + const currentPrice = bestOffer?.price ?? product?.price ?? null; if (!categoryId) { return ( @@ -366,35 +437,6 @@ export default function ProductDetailsPage() { canonical /parts/p route.

- - {/* Platform switch (updates NEW route) */} -
- - -
@@ -414,18 +456,25 @@ export default function ProductDetailsPage() {

) : (
- {/* Left: image + core actions */} + {/* Left */}
{imageUrl ? ( - // eslint-disable-next-line @next/next/no-img-element - {product.name} + ) : (
No image @@ -439,12 +488,12 @@ export default function ProductDetailsPage() { - {product.inStock === false ? "Out of stock" : "In stock"} + {stockValue === false ? "Out of stock" : "In stock"}
@@ -467,20 +516,18 @@ export default function ProductDetailsPage() { : "bg-amber-400 text-black hover:bg-amber-300" }`} > - {isSelected ? "Remove" : "Add"} + {isSelected ? "Remove" : "Add to Build"}
- {/* Pricing history placeholder */} + {/* Price history placeholder */}

Price History

- - placeholder - + placeholder
Pricing graph will go here @@ -492,7 +539,7 @@ export default function ProductDetailsPage() {
- {/* Right: details + offers */} + {/* Right */}
{/* Product meta */}
@@ -532,7 +579,7 @@ export default function ProductDetailsPage() { Current price
- {formatPrice(product.price)} + {formatPrice(currentPrice)}
@@ -560,7 +607,11 @@ export default function ProductDetailsPage() { Merchant Offers - {offers.length ? `${offers.length} offers` : "no offers"} + {offersLoading + ? "loading…" + : offers.length + ? `${offers.length} offers` + : "no offers"}
@@ -578,16 +629,15 @@ export default function ProductDetailsPage() { {offers.map((o, idx) => ( - {" "} - {o.merchantName ?? "Merchant"} + {merchantLabel(o)} {isBestOffer(o) && ( @@ -607,9 +657,7 @@ export default function ProductDetailsPage() { )} - {o.price != null - ? `$${o.price.toFixed(2)}` - : "—"} + {o.price != null ? `$${o.price.toFixed(2)}` : "—"} {o.buyUrl ? ( @@ -622,9 +670,7 @@ export default function ProductDetailsPage() { Buy ) : ( - - — - + )} @@ -639,7 +685,7 @@ export default function ProductDetailsPage() {

)} - {/* Best Offer CTA (uses sorted offers[0]) */} + {/* Best Offer CTA */} {bestOffer?.buyUrl ? (
- Buy from {bestOffer.merchantName ?? "Merchant"} → + Buy from {merchantLabel(bestOffer)} →
) : null} @@ -656,21 +702,49 @@ export default function ProductDetailsPage() { {/* Footer tips */}
- + {/* Tip: Add this part, then go back and compare alternates. - - + */} + {/* Canonical route:{" "} /parts/p/{platform}/{partRoleParam}/{productSlug} - + */}
)} + + {/* Image modal */} + {isImageOpen && imageUrl ? ( +
setIsImageOpen(false)} + > +
e.stopPropagation()} + > + + + {/* eslint-disable-next-line @next/next/no-img-element */} + {product?.name +
+
+ ) : null} ); -} +} \ No newline at end of file