cleaned up product details page. added a link on builder to the selected product details page

This commit is contained in:
2025-12-22 17:31:04 -05:00
parent 061d737c6c
commit c01aabb003
2 changed files with 202 additions and 104 deletions
+31 -7
View File
@@ -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 ? (
<>
<div className="text-[0.7rem] text-zinc-500 line-clamp-1">
{selectedPart.name}
<Link
href={`/parts/p/${encodeURIComponent(
platform
)}/${encodeURIComponent(
category.id
)}/${encodeURIComponent(
`${
selectedPart.id
}-${selectedPart.name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "")}`
)}`}
className="block text-[0.7rem] text-zinc-500 line-clamp-1 transition-colors hover:text-amber-300 hover:underline underline-offset-2"
title="View part details"
>
{selectedPart.name}
</Link>{" "}
</div>
{/* Overlap chips help explain conflicts / dependencies */}
@@ -1317,7 +1339,9 @@ export default function GunbuilderPage() {
</>
) : hasParts ? (
<Link
href={`/parts/p/${encodeURIComponent(platform)}/${encodeURIComponent(category.id)}`}
href={`/parts/p/${encodeURIComponent(
platform
)}/${encodeURIComponent(category.id)}`}
className="inline-flex items-center gap-1.5 whitespace-nowrap rounded-md border border-zinc-700 bg-zinc-900/70 px-3 py-1.5 text-xs font-medium text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600 transition-colors"
>
<svg
@@ -1357,4 +1381,4 @@ export default function GunbuilderPage() {
</div>
</main>
);
}
}
@@ -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<boolean>(true);
const [error, setError] = useState<string | null>(null);
// Offers fetch state (from /products/:id/offers)
const [offersFromApi, setOffersFromApi] = useState<OfferFromApi[]>([]);
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.
</p>
</div>
{/* Platform switch (updates NEW route) */}
<div className="flex items-center gap-2">
<label
htmlFor="platform-select"
className="text-xs text-zinc-500"
>
Platform
</label>
<select
id="platform-select"
value={platform}
onChange={(e) => {
const next = e.target.value;
router.replace(
`/parts/p/${encodeURIComponent(next)}/${encodeURIComponent(
partRoleParam
)}/${encodeURIComponent(productSlug)}`
);
}}
className="rounded-md border border-zinc-700 bg-zinc-900/70 px-2 py-1 text-xs text-zinc-200 focus:outline-none focus:ring-1 focus:ring-amber-400"
>
{PLATFORMS.map((p) => (
<option key={p} value={p}>
{p}
</option>
))}
</select>
</div>
</div>
</header>
@@ -414,18 +456,25 @@ export default function ProductDetailsPage() {
</p>
) : (
<div className="grid gap-6 md:grid-cols-[280px_1fr]">
{/* Left: image + core actions */}
{/* Left */}
<div className="space-y-3">
<div className="rounded-md border border-zinc-800 bg-zinc-950/80 p-3">
<div className="aspect-square w-full overflow-hidden rounded-md border border-zinc-800 bg-black">
{imageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={imageUrl}
alt={product.name}
className="h-full w-full object-contain p-2"
loading="lazy"
/>
<button
type="button"
onClick={() => setIsImageOpen(true)}
className="h-full w-full"
title="Click to enlarge"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={imageUrl}
alt={product.name}
className="h-full w-full object-contain p-2"
loading="lazy"
/>
</button>
) : (
<div className="flex h-full w-full items-center justify-center text-xs text-zinc-600">
No image
@@ -439,12 +488,12 @@ export default function ProductDetailsPage() {
</span>
<span
className={`text-xs font-semibold ${
product.inStock === false
stockValue === false
? "text-red-300"
: "text-emerald-300"
}`}
>
{product.inStock === false ? "Out of stock" : "In stock"}
{stockValue === false ? "Out of stock" : "In stock"}
</span>
</div>
@@ -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"}
</button>
</div>
</div>
{/* Pricing history placeholder */}
{/* Price history placeholder */}
<div className="rounded-md border border-zinc-800 bg-zinc-950/80 p-3">
<div className="flex items-center justify-between">
<h3 className="text-xs font-semibold uppercase tracking-[0.16em] text-zinc-500">
Price History
</h3>
<span className="text-[10px] text-zinc-600">
placeholder
</span>
<span className="text-[10px] text-zinc-600">placeholder</span>
</div>
<div className="mt-2 h-28 rounded border border-zinc-800 bg-zinc-950 flex items-center justify-center text-xs text-zinc-600">
Pricing graph will go here
@@ -492,7 +539,7 @@ export default function ProductDetailsPage() {
</div>
</div>
{/* Right: details + offers */}
{/* Right */}
<div className="min-w-0 space-y-4">
{/* Product meta */}
<div className="rounded-md border border-zinc-800 bg-zinc-950/80 p-4">
@@ -532,7 +579,7 @@ export default function ProductDetailsPage() {
Current price
</div>
<div className="mt-1 text-2xl font-semibold text-amber-300">
{formatPrice(product.price)}
{formatPrice(currentPrice)}
</div>
</div>
</div>
@@ -560,7 +607,11 @@ export default function ProductDetailsPage() {
Merchant Offers
</h3>
<span className="text-[10px] text-zinc-600">
{offers.length ? `${offers.length} offers` : "no offers"}
{offersLoading
? "loading…"
: offers.length
? `${offers.length} offers`
: "no offers"}
</span>
</div>
@@ -578,16 +629,15 @@ export default function ProductDetailsPage() {
<tbody className="divide-y divide-zinc-800">
{offers.map((o, idx) => (
<tr
key={idx}
key={String(o.id ?? idx)}
className={`hover:bg-zinc-900/40 ${
isBestOffer(o)
? "bg-amber-400/10 border-l-2 border-amber-400"
: ""
}`}
>
{" "}
<td className="px-3 py-2 text-zinc-200 flex items-center">
<span>{o.merchantName ?? "Merchant"}</span>
<span>{merchantLabel(o)}</span>
{isBestOffer(o) && (
<span className="ml-2 inline-flex items-center rounded bg-amber-400 px-2 py-0.5 text-[10px] font-semibold text-black">
@@ -607,9 +657,7 @@ export default function ProductDetailsPage() {
)}
</td>
<td className="px-3 py-2 text-right font-semibold text-amber-300">
{o.price != null
? `$${o.price.toFixed(2)}`
: "—"}
{o.price != null ? `$${o.price.toFixed(2)}` : "—"}
</td>
<td className="px-3 py-2 text-right">
{o.buyUrl ? (
@@ -622,9 +670,7 @@ export default function ProductDetailsPage() {
Buy
</a>
) : (
<span className="text-xs text-zinc-600">
</span>
<span className="text-xs text-zinc-600"></span>
)}
</td>
</tr>
@@ -639,7 +685,7 @@ export default function ProductDetailsPage() {
</p>
)}
{/* Best Offer CTA (uses sorted offers[0]) */}
{/* Best Offer CTA */}
{bestOffer?.buyUrl ? (
<div className="mt-3 flex justify-end">
<a
@@ -648,7 +694,7 @@ export default function ProductDetailsPage() {
rel="noreferrer"
className="rounded-md border border-zinc-700 bg-zinc-900/70 px-4 py-2 text-sm font-semibold text-zinc-200 hover:bg-zinc-800"
>
Buy from {bestOffer.merchantName ?? "Merchant"}
Buy from {merchantLabel(bestOffer)}
</a>
</div>
) : null}
@@ -656,21 +702,49 @@ export default function ProductDetailsPage() {
{/* Footer tips */}
<div className="flex flex-wrap gap-2 text-xs text-zinc-500">
<span className="rounded border border-zinc-800 bg-zinc-950/60 px-2 py-1">
{/* <span className="rounded border border-zinc-800 bg-zinc-950/60 px-2 py-1">
Tip: Add this part, then go back and compare alternates.
</span>
<span className="rounded border border-zinc-800 bg-zinc-950/60 px-2 py-1">
</span> */}
{/* <span className="rounded border border-zinc-800 bg-zinc-950/60 px-2 py-1">
Canonical route:{" "}
<span className="text-zinc-300">
/parts/p/{platform}/{partRoleParam}/{productSlug}
</span>
</span>
</span> */}
</div>
</div>
</div>
)}
</section>
{/* Image modal */}
{isImageOpen && imageUrl ? (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4"
onClick={() => setIsImageOpen(false)}
>
<div
className="relative max-h-[90vh] max-w-[90vw]"
onClick={(e) => e.stopPropagation()}
>
<button
type="button"
onClick={() => setIsImageOpen(false)}
className="absolute -top-3 -right-3 rounded-full border border-zinc-700 bg-zinc-900 px-3 py-2 text-xs font-semibold text-zinc-200 hover:bg-zinc-800"
>
Close
</button>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={imageUrl}
alt={product?.name ?? "Product image"}
className="max-h-[90vh] max-w-[90vw] rounded-lg border border-zinc-800 bg-black object-contain"
/>
</div>
</div>
) : null}
</div>
</main>
);
}
}