Files
shadow-gunbuilder-ai-proto/components/parts/ProductDetailPageClient.tsx
T

204 lines
6.8 KiB
TypeScript

"use client";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { ProductImage } from './ProductImage';
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/catalog/products/${encodeURIComponent(id)}`, {
headers: { Accept: "application/json" },
credentials: 'include',
});
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/catalog/products?platform=${encodeURIComponent(platform)}&partRoles=${encodeURIComponent(partRole)}`,
{ headers: { Accept: "application/json" }, credentials: 'include' }
);
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">
<ProductImage
src={p.imageUrl}
alt={p.name}
className="h-full w-full object-cover"
/>
</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>
);
}