205 lines
6.9 KiB
TypeScript
205 lines
6.9 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useMemo, useState } from "react";
|
|
import Link from "next/link";
|
|
import PlatformSwitcher from "./PlatformSwitcher";
|
|
|
|
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
|
|
|
|
type ProductDetailDto = {
|
|
id: string;
|
|
name: string;
|
|
brand: string;
|
|
platform: string;
|
|
partRole: string;
|
|
categoryKey: string | null;
|
|
price: number | null;
|
|
buyUrl: string | null;
|
|
imageUrl: string | null;
|
|
};
|
|
|
|
function parseIdFromProductSlug(productSlug: string): string | null {
|
|
// Expected: "1217-some-slug"
|
|
const m = /^(\d+)(?:-|$)/.exec(productSlug ?? "");
|
|
return m?.[1] ?? null;
|
|
}
|
|
|
|
/**
|
|
* Attempts to fetch a product detail.
|
|
* If you don't have a dedicated endpoint yet, it falls back to list + filter by id.
|
|
*/
|
|
async function fetchProductDetail(params: {
|
|
platform: string;
|
|
partRole: string;
|
|
productSlug: string;
|
|
}): Promise<ProductDetailDto> {
|
|
const { platform, partRole, productSlug } = params;
|
|
const id = parseIdFromProductSlug(productSlug);
|
|
|
|
if (!id) throw new Error(`Invalid product URL slug: '${productSlug}' (could not parse id)`);
|
|
|
|
// ---- Preferred (if you add it): GET /api/v1/products/{id}
|
|
// If it 404s, we fall back.
|
|
try {
|
|
const res = await fetch(`${API_BASE_URL}/api/v1/products/${encodeURIComponent(id)}`, {
|
|
headers: { Accept: "application/json" },
|
|
});
|
|
|
|
if (res.ok) {
|
|
return await res.json();
|
|
}
|
|
} catch {
|
|
// ignore and fall back
|
|
}
|
|
|
|
// ---- Fallback: use list endpoint + find by id (works right now with your current API)
|
|
const listRes = await fetch(
|
|
`${API_BASE_URL}/api/v1/products?platform=${encodeURIComponent(platform)}&partRoles=${encodeURIComponent(partRole)}`,
|
|
{ headers: { Accept: "application/json" } }
|
|
);
|
|
|
|
if (!listRes.ok) {
|
|
const txt = await listRes.text().catch(() => "");
|
|
throw new Error(`Failed to load product (fallback) (${listRes.status}): ${txt}`);
|
|
}
|
|
|
|
const list: ProductDetailDto[] = await listRes.json();
|
|
const found = list.find((p) => String(p.id) === String(id));
|
|
|
|
if (!found) {
|
|
throw new Error(`Product id=${id} not found in ${platform}/${partRole} list`);
|
|
}
|
|
|
|
return found;
|
|
}
|
|
|
|
export default function ProductDetailPageClient(props: {
|
|
platform: string;
|
|
partRole: string;
|
|
productSlug: string;
|
|
}) {
|
|
const { platform, partRole, productSlug } = props;
|
|
|
|
const [p, setP] = useState<ProductDetailDto | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const id = useMemo(() => parseIdFromProductSlug(productSlug), [productSlug]);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
|
|
async function load() {
|
|
try {
|
|
setLoading(true);
|
|
setError(null);
|
|
|
|
const data = await fetchProductDetail({ platform, partRole, productSlug });
|
|
|
|
if (!cancelled) setP(data);
|
|
} catch (e: any) {
|
|
if (!cancelled) setError(e?.message ?? "Failed to load product");
|
|
} finally {
|
|
if (!cancelled) setLoading(false);
|
|
}
|
|
}
|
|
|
|
load();
|
|
return () => { cancelled = true; };
|
|
}, [platform, partRole, productSlug]);
|
|
|
|
return (
|
|
<main className="min-h-screen bg-black text-zinc-50">
|
|
<div className="mx-auto max-w-5xl px-4 py-6 lg:py-10">
|
|
<header className="mb-6 flex flex-col gap-3">
|
|
<div className="flex items-center justify-between gap-4">
|
|
<div>
|
|
<p className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
|
|
Battl Builders
|
|
</p>
|
|
<h1 className="mt-1 text-2xl md:text-3xl font-semibold tracking-tight">
|
|
Part: <span className="text-amber-300">{partRole}</span>
|
|
</h1>
|
|
</div>
|
|
|
|
{/* temp disabling while I rework the routing. */}
|
|
{/* <PlatformSwitcher currentPlatform={platform} partRole={partRole} /> */}
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2 text-xs text-zinc-500">
|
|
<Link className="hover:text-amber-200" href={`/parts/p/${encodeURIComponent(platform)}/${encodeURIComponent(partRole)}`}>
|
|
← Back to list
|
|
</Link>
|
|
<span>•</span>
|
|
<span>id: {id ?? "—"}</span>
|
|
</div>
|
|
</header>
|
|
|
|
{loading && <p className="text-sm text-zinc-500">Loading product…</p>}
|
|
|
|
{error && (
|
|
<div className="rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2 text-sm text-red-200">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{!loading && !error && p && (
|
|
<section className="rounded-lg border border-zinc-800 bg-zinc-950/70 p-4 md:p-6">
|
|
<div className="flex flex-col gap-6 md:flex-row">
|
|
<div className="w-full md:w-72">
|
|
<div className="aspect-square overflow-hidden rounded-lg border border-zinc-800 bg-black/30">
|
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
{p.imageUrl ? (
|
|
<img src={p.imageUrl} alt={p.name} className="h-full w-full object-cover" />
|
|
) : (
|
|
<div className="h-full w-full grid place-items-center text-sm text-zinc-600">
|
|
No image
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex-1">
|
|
<p className="text-xs uppercase tracking-[0.14em] text-zinc-500">
|
|
{p.brand} • {p.platform}
|
|
</p>
|
|
<h2 className="mt-2 text-xl md:text-2xl font-semibold text-zinc-100">
|
|
{p.name}
|
|
</h2>
|
|
|
|
<div className="mt-4 grid grid-cols-1 gap-3 text-sm">
|
|
<div className="rounded-md border border-zinc-800 bg-black/30 p-3">
|
|
<p className="text-xs uppercase tracking-[0.14em] text-zinc-500">Category</p>
|
|
<p className="mt-1 text-zinc-200">{p.categoryKey ?? "—"}</p>
|
|
</div>
|
|
|
|
<div className="rounded-md border border-zinc-800 bg-black/30 p-3">
|
|
<p className="text-xs uppercase tracking-[0.14em] text-zinc-500">Best price</p>
|
|
<p className="mt-1 text-zinc-200">
|
|
{typeof p.price === "number" ? `$${p.price.toFixed(2)}` : "—"}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-5 flex items-center gap-3">
|
|
{p.buyUrl ? (
|
|
<a
|
|
href={p.buyUrl}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="rounded-md bg-amber-400 px-4 py-2 text-sm font-semibold text-black hover:bg-amber-300"
|
|
>
|
|
Buy / View offer
|
|
</a>
|
|
) : (
|
|
<span className="text-sm text-zinc-500">No buy link</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
)}
|
|
</div>
|
|
</main>
|
|
);
|
|
} |