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 payload = JSON.stringify(build);
|
||||||
const encoded = window.btoa(payload);
|
const encoded = window.btoa(payload);
|
||||||
const origin = window.location?.origin ?? "";
|
const origin = window.location?.origin ?? "";
|
||||||
const url = `${origin}/builder/build?build=${encodeURIComponent(encoded)}`;
|
const url = `${origin}/builder/build?build=${encodeURIComponent(
|
||||||
|
encoded
|
||||||
|
)}`;
|
||||||
setShareUrl(url);
|
setShareUrl(url);
|
||||||
} catch {
|
} catch {
|
||||||
setShareUrl("");
|
setShareUrl("");
|
||||||
@@ -1024,7 +1026,9 @@ export default function GunbuilderPage() {
|
|||||||
}}
|
}}
|
||||||
disabled={selectedParts.length === 0}
|
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 ${
|
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
|
Clear Build
|
||||||
@@ -1212,8 +1216,9 @@ export default function GunbuilderPage() {
|
|||||||
const selectedPartId = build[category.id];
|
const selectedPartId = build[category.id];
|
||||||
|
|
||||||
const selectedPart = selectedPartId
|
const selectedPart = selectedPartId
|
||||||
? categoryParts.find((p) => p.id === selectedPartId) ??
|
? categoryParts.find(
|
||||||
parts.find((p) => p.id === selectedPartId)
|
(p) => p.id === selectedPartId
|
||||||
|
) ?? parts.find((p) => p.id === selectedPartId)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const hasParts = categoryParts.length > 0;
|
const hasParts = categoryParts.length > 0;
|
||||||
@@ -1232,7 +1237,24 @@ export default function GunbuilderPage() {
|
|||||||
{selectedPart ? (
|
{selectedPart ? (
|
||||||
<>
|
<>
|
||||||
<div className="text-[0.7rem] text-zinc-500 line-clamp-1">
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Overlap chips help explain conflicts / dependencies */}
|
{/* Overlap chips help explain conflicts / dependencies */}
|
||||||
@@ -1317,7 +1339,9 @@ export default function GunbuilderPage() {
|
|||||||
</>
|
</>
|
||||||
) : hasParts ? (
|
) : hasParts ? (
|
||||||
<Link
|
<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"
|
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
|
<svg
|
||||||
|
|||||||
@@ -5,19 +5,18 @@ import Link from "next/link";
|
|||||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||||
|
|
||||||
import type { CategoryId } from "@/types/gunbuilder";
|
import type { CategoryId } from "@/types/gunbuilder";
|
||||||
import {
|
import { PART_ROLE_TO_CATEGORY, normalizePartRole } from "@/lib/catalogMappings";
|
||||||
PART_ROLE_TO_CATEGORY,
|
|
||||||
normalizePartRole,
|
|
||||||
} from "@/lib/catalogMappings";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* API Shapes
|
* API Shapes
|
||||||
* - OfferFromApi: what /api/v1/products/:id returns in offers[]
|
* - OfferFromApi: what /api/v1/products/:id/offers returns
|
||||||
* - GunbuilderProductFromApi: the product detail contract (v1)
|
* - GunbuilderProductFromApi: product detail contract (v1)
|
||||||
*/
|
*/
|
||||||
type OfferFromApi = {
|
type OfferFromApi = {
|
||||||
|
id?: string | number;
|
||||||
merchantName?: string | null;
|
merchantName?: string | null;
|
||||||
price?: number | null;
|
price?: number | null;
|
||||||
|
originalPrice?: number | null;
|
||||||
buyUrl?: string | null;
|
buyUrl?: string | null;
|
||||||
inStock?: boolean | null;
|
inStock?: boolean | null;
|
||||||
lastUpdated?: string | null;
|
lastUpdated?: string | null;
|
||||||
@@ -43,7 +42,7 @@ type GunbuilderProductFromApi = {
|
|||||||
shortDescription?: string | null;
|
shortDescription?: string | null;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
|
|
||||||
// New: offers[] from the API
|
// (May be empty depending on endpoint; we now fetch offers separately)
|
||||||
offers?: OfferFromApi[] | null;
|
offers?: OfferFromApi[] | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -72,11 +71,44 @@ function formatPrice(price: number | null | undefined): string {
|
|||||||
return `$${price.toFixed(2)}`;
|
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:
|
* Sort offers:
|
||||||
* 1) lowest price first
|
* 1) lowest price first
|
||||||
* 2) in-stock before out-of-stock
|
* 2) in-stock before out-of-stock
|
||||||
* 3) merchant name tiebreaker
|
* 3) merchant label tiebreaker
|
||||||
*/
|
*/
|
||||||
function sortOffers(offers: OfferFromApi[]): OfferFromApi[] {
|
function sortOffers(offers: OfferFromApi[]): OfferFromApi[] {
|
||||||
return [...offers].sort((a, b) => {
|
return [...offers].sort((a, b) => {
|
||||||
@@ -89,7 +121,7 @@ function sortOffers(offers: OfferFromApi[]): OfferFromApi[] {
|
|||||||
const bStock = b.inStock === false ? 1 : 0;
|
const bStock = b.inStock === false ? 1 : 0;
|
||||||
if (aStock !== bStock) return aStock - bStock;
|
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);
|
return () => window.removeEventListener("storage", onStorage);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const selectedPartIdForCategory = categoryId
|
const selectedPartIdForCategory = categoryId ? build?.[categoryId] : undefined;
|
||||||
? build?.[categoryId]
|
|
||||||
: undefined;
|
|
||||||
const isSelected =
|
const isSelected =
|
||||||
!!categoryId &&
|
!!categoryId &&
|
||||||
selectedPartIdForCategory === (numericId ? String(numericId) : "");
|
selectedPartIdForCategory === (numericId ? String(numericId) : "");
|
||||||
@@ -165,6 +195,13 @@ export default function ProductDetailsPage() {
|
|||||||
const [loading, setLoading] = useState<boolean>(true);
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
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:
|
* Route normalization:
|
||||||
* If platform segment is missing/empty, rewrite to canonical /parts/p route.
|
* If platform segment is missing/empty, rewrite to canonical /parts/p route.
|
||||||
@@ -181,14 +218,7 @@ export default function ProductDetailsPage() {
|
|||||||
)}/${encodeURIComponent(productSlug)}?${qp.toString()}`
|
)}/${encodeURIComponent(productSlug)}?${qp.toString()}`
|
||||||
);
|
);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// 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:
|
* Fetch product details from API:
|
||||||
@@ -229,6 +259,41 @@ export default function ProductDetailsPage() {
|
|||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}, [numericId]);
|
}, [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:
|
* Builder selection toggle:
|
||||||
* - If selected -> remove from build
|
* - If selected -> remove from build
|
||||||
@@ -261,19 +326,16 @@ export default function ProductDetailsPage() {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Offers normalization:
|
* Offers normalization:
|
||||||
* - If product.offers exists, use & sort it (real offers).
|
* - Prefer offers endpoint data
|
||||||
* - Otherwise, fallback to a single offer derived from product.buyUrl/product.price (legacy).
|
* - Fallback to legacy single-offer derived from product if needed
|
||||||
*/
|
*/
|
||||||
const offers = useMemo(() => {
|
const offers = useMemo(() => {
|
||||||
if (!product) return [];
|
if (offersFromApi.length > 0) return sortOffers(offersFromApi);
|
||||||
|
|
||||||
const raw = (product.offers ?? []).filter(Boolean) as OfferFromApi[];
|
if (product?.buyUrl) {
|
||||||
if (raw.length > 0) return sortOffers(raw);
|
|
||||||
|
|
||||||
if (product.buyUrl) {
|
|
||||||
return sortOffers([
|
return sortOffers([
|
||||||
{
|
{
|
||||||
merchantName: product.merchantName ?? "Merchant",
|
merchantName: product.merchantName ?? "Retailer",
|
||||||
price: product.price,
|
price: product.price,
|
||||||
buyUrl: product.buyUrl,
|
buyUrl: product.buyUrl,
|
||||||
inStock: product.inStock ?? true,
|
inStock: product.inStock ?? true,
|
||||||
@@ -282,21 +344,30 @@ export default function ProductDetailsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return [];
|
return [];
|
||||||
}, [product]);
|
}, [offersFromApi, product]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Best offer (for the "Buy from ..." CTA)
|
* Best offer (sorted offers[0])
|
||||||
* IMPORTANT: this must be defined AFTER offers is computed
|
|
||||||
* (do NOT reference `offers` inside the offers useMemo).
|
|
||||||
*/
|
*/
|
||||||
const bestOffer = offers[0] ?? null;
|
const bestOffer = offers[0] ?? null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper: identify the best offer row
|
* Helper: identify the best offer row
|
||||||
* We compare by buyUrl (stable + unique enough for MVP)
|
|
||||||
*/
|
*/
|
||||||
const isBestOffer = (offer: OfferFromApi) =>
|
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) {
|
if (!categoryId) {
|
||||||
return (
|
return (
|
||||||
@@ -366,35 +437,6 @@ export default function ProductDetailsPage() {
|
|||||||
canonical /parts/p route.
|
canonical /parts/p route.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -414,18 +456,25 @@ export default function ProductDetailsPage() {
|
|||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-6 md:grid-cols-[280px_1fr]">
|
<div className="grid gap-6 md:grid-cols-[280px_1fr]">
|
||||||
{/* Left: image + core actions */}
|
{/* Left */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="rounded-md border border-zinc-800 bg-zinc-950/80 p-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">
|
<div className="aspect-square w-full overflow-hidden rounded-md border border-zinc-800 bg-black">
|
||||||
{imageUrl ? (
|
{imageUrl ? (
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
<button
|
||||||
<img
|
type="button"
|
||||||
src={imageUrl}
|
onClick={() => setIsImageOpen(true)}
|
||||||
alt={product.name}
|
className="h-full w-full"
|
||||||
className="h-full w-full object-contain p-2"
|
title="Click to enlarge"
|
||||||
loading="lazy"
|
>
|
||||||
/>
|
{/* 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">
|
<div className="flex h-full w-full items-center justify-center text-xs text-zinc-600">
|
||||||
No image
|
No image
|
||||||
@@ -439,12 +488,12 @@ export default function ProductDetailsPage() {
|
|||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
className={`text-xs font-semibold ${
|
className={`text-xs font-semibold ${
|
||||||
product.inStock === false
|
stockValue === false
|
||||||
? "text-red-300"
|
? "text-red-300"
|
||||||
: "text-emerald-300"
|
: "text-emerald-300"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{product.inStock === false ? "Out of stock" : "In stock"}
|
{stockValue === false ? "Out of stock" : "In stock"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -467,20 +516,18 @@ export default function ProductDetailsPage() {
|
|||||||
: "bg-amber-400 text-black hover:bg-amber-300"
|
: "bg-amber-400 text-black hover:bg-amber-300"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{isSelected ? "Remove" : "Add"}
|
{isSelected ? "Remove" : "Add to Build"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Pricing history placeholder */}
|
{/* Price history placeholder */}
|
||||||
<div className="rounded-md border border-zinc-800 bg-zinc-950/80 p-3">
|
<div className="rounded-md border border-zinc-800 bg-zinc-950/80 p-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="text-xs font-semibold uppercase tracking-[0.16em] text-zinc-500">
|
<h3 className="text-xs font-semibold uppercase tracking-[0.16em] text-zinc-500">
|
||||||
Price History
|
Price History
|
||||||
</h3>
|
</h3>
|
||||||
<span className="text-[10px] text-zinc-600">
|
<span className="text-[10px] text-zinc-600">placeholder</span>
|
||||||
placeholder
|
|
||||||
</span>
|
|
||||||
</div>
|
</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">
|
<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
|
Pricing graph will go here
|
||||||
@@ -492,7 +539,7 @@ export default function ProductDetailsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right: details + offers */}
|
{/* Right */}
|
||||||
<div className="min-w-0 space-y-4">
|
<div className="min-w-0 space-y-4">
|
||||||
{/* Product meta */}
|
{/* Product meta */}
|
||||||
<div className="rounded-md border border-zinc-800 bg-zinc-950/80 p-4">
|
<div className="rounded-md border border-zinc-800 bg-zinc-950/80 p-4">
|
||||||
@@ -532,7 +579,7 @@ export default function ProductDetailsPage() {
|
|||||||
Current price
|
Current price
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 text-2xl font-semibold text-amber-300">
|
<div className="mt-1 text-2xl font-semibold text-amber-300">
|
||||||
{formatPrice(product.price)}
|
{formatPrice(currentPrice)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -560,7 +607,11 @@ export default function ProductDetailsPage() {
|
|||||||
Merchant Offers
|
Merchant Offers
|
||||||
</h3>
|
</h3>
|
||||||
<span className="text-[10px] text-zinc-600">
|
<span className="text-[10px] text-zinc-600">
|
||||||
{offers.length ? `${offers.length} offers` : "no offers"}
|
{offersLoading
|
||||||
|
? "loading…"
|
||||||
|
: offers.length
|
||||||
|
? `${offers.length} offers`
|
||||||
|
: "no offers"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -578,16 +629,15 @@ export default function ProductDetailsPage() {
|
|||||||
<tbody className="divide-y divide-zinc-800">
|
<tbody className="divide-y divide-zinc-800">
|
||||||
{offers.map((o, idx) => (
|
{offers.map((o, idx) => (
|
||||||
<tr
|
<tr
|
||||||
key={idx}
|
key={String(o.id ?? idx)}
|
||||||
className={`hover:bg-zinc-900/40 ${
|
className={`hover:bg-zinc-900/40 ${
|
||||||
isBestOffer(o)
|
isBestOffer(o)
|
||||||
? "bg-amber-400/10 border-l-2 border-amber-400"
|
? "bg-amber-400/10 border-l-2 border-amber-400"
|
||||||
: ""
|
: ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{" "}
|
|
||||||
<td className="px-3 py-2 text-zinc-200 flex items-center">
|
<td className="px-3 py-2 text-zinc-200 flex items-center">
|
||||||
<span>{o.merchantName ?? "Merchant"}</span>
|
<span>{merchantLabel(o)}</span>
|
||||||
|
|
||||||
{isBestOffer(o) && (
|
{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">
|
<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>
|
||||||
<td className="px-3 py-2 text-right font-semibold text-amber-300">
|
<td className="px-3 py-2 text-right font-semibold text-amber-300">
|
||||||
{o.price != null
|
{o.price != null ? `$${o.price.toFixed(2)}` : "—"}
|
||||||
? `$${o.price.toFixed(2)}`
|
|
||||||
: "—"}
|
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 text-right">
|
<td className="px-3 py-2 text-right">
|
||||||
{o.buyUrl ? (
|
{o.buyUrl ? (
|
||||||
@@ -622,9 +670,7 @@ export default function ProductDetailsPage() {
|
|||||||
Buy
|
Buy
|
||||||
</a>
|
</a>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-xs text-zinc-600">
|
<span className="text-xs text-zinc-600">—</span>
|
||||||
—
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -639,7 +685,7 @@ export default function ProductDetailsPage() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Best Offer CTA (uses sorted offers[0]) */}
|
{/* Best Offer CTA */}
|
||||||
{bestOffer?.buyUrl ? (
|
{bestOffer?.buyUrl ? (
|
||||||
<div className="mt-3 flex justify-end">
|
<div className="mt-3 flex justify-end">
|
||||||
<a
|
<a
|
||||||
@@ -648,7 +694,7 @@ export default function ProductDetailsPage() {
|
|||||||
rel="noreferrer"
|
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"
|
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>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -656,20 +702,48 @@ export default function ProductDetailsPage() {
|
|||||||
|
|
||||||
{/* Footer tips */}
|
{/* Footer tips */}
|
||||||
<div className="flex flex-wrap gap-2 text-xs text-zinc-500">
|
<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.
|
Tip: Add this part, then go back and compare alternates.
|
||||||
</span>
|
</span> */}
|
||||||
<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">
|
||||||
Canonical route:{" "}
|
Canonical route:{" "}
|
||||||
<span className="text-zinc-300">
|
<span className="text-zinc-300">
|
||||||
/parts/p/{platform}/{partRoleParam}/{productSlug}
|
/parts/p/{platform}/{partRoleParam}/{productSlug}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span> */}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</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>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user