cleaned up product details page. added a link on builder to the selected product details page
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user