436 lines
16 KiB
TypeScript
436 lines
16 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useMemo, useState } from "react";
|
||
import Link from "next/link";
|
||
import { useParams } from "next/navigation";
|
||
import { CATEGORIES } from "@/data/gunbuilderParts";
|
||
import type { CategoryId } from "@/types/gunbuilder";
|
||
import {
|
||
getRetailerOffers,
|
||
type RetailerOffer,
|
||
} from "./data";
|
||
|
||
type GunbuilderProductFromApi = {
|
||
id: string; // backend UUID string
|
||
name: string;
|
||
brand: string;
|
||
platform: string;
|
||
partRole: string;
|
||
price: number | null;
|
||
mainImageUrl: string | null;
|
||
buyUrl: string | null;
|
||
};
|
||
|
||
const API_BASE_URL =
|
||
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
|
||
|
||
export default function PartDetailPage() {
|
||
const params = useParams();
|
||
const categoryId = params.categoryId as CategoryId;
|
||
const partId = params.partId as string; // this is the UUID we passed in the link
|
||
|
||
const [product, setProduct] = useState<GunbuilderProductFromApi | null>(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
const [offers, setOffers] = useState<RetailerOffer[]>([]);
|
||
const [offersLoading, setOffersLoading] = useState(true);
|
||
const [offersError, setOffersError] = useState<string | null>(null);
|
||
|
||
const category = useMemo(
|
||
() => CATEGORIES.find((c) => c.id === categoryId),
|
||
[categoryId],
|
||
);
|
||
|
||
// 1) Load product (same as before)
|
||
useEffect(() => {
|
||
if (!partId) return;
|
||
|
||
const controller = new AbortController();
|
||
|
||
async function fetchProduct() {
|
||
try {
|
||
setLoading(true);
|
||
setError(null);
|
||
|
||
// For now, pull the full AR-15 list and find by UUID.
|
||
// We can optimize with a dedicated /api/products/{uuid} later.
|
||
const url = `${API_BASE_URL}/api/products/gunbuilder?platform=AR-15`;
|
||
const res = await fetch(url, { signal: controller.signal });
|
||
|
||
if (!res.ok) {
|
||
throw new Error(`Failed to load product (${res.status})`);
|
||
}
|
||
|
||
const data: GunbuilderProductFromApi[] = await res.json();
|
||
const found = data.find((p) => p.id === partId) ?? null;
|
||
|
||
if (!found) {
|
||
setError("Product not found");
|
||
}
|
||
|
||
setProduct(found);
|
||
} catch (err: any) {
|
||
if (err.name === "AbortError") return;
|
||
setError(err.message ?? "Failed to load product");
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}
|
||
|
||
fetchProduct();
|
||
|
||
return () => controller.abort();
|
||
}, [partId]);
|
||
|
||
// 2) Load offers for this product from Ballistic backend
|
||
useEffect(() => {
|
||
if (!partId) return;
|
||
|
||
let cancelled = false;
|
||
|
||
async function fetchOffers() {
|
||
try {
|
||
setOffersLoading(true);
|
||
setOffersError(null);
|
||
|
||
const data = await getRetailerOffers(partId);
|
||
|
||
if (!cancelled) {
|
||
setOffers(data);
|
||
}
|
||
} catch (err: any) {
|
||
if (!cancelled) {
|
||
setOffersError(err.message ?? "Failed to load retailer offers");
|
||
}
|
||
} finally {
|
||
if (!cancelled) {
|
||
setOffersLoading(false);
|
||
}
|
||
}
|
||
}
|
||
|
||
fetchOffers();
|
||
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [partId]);
|
||
|
||
// Best price: prefer live offers, fall back to summary product.price
|
||
const bestPrice = useMemo(() => {
|
||
if (offers.length > 0) {
|
||
return offers[0].price;
|
||
}
|
||
return product?.price ?? null;
|
||
}, [offers, product]);
|
||
|
||
if (!category) {
|
||
return (
|
||
<main className="min-h-screen bg-black text-zinc-50">
|
||
<div className="mx-auto max-w-6xl px-4 py-6 lg:py-10">
|
||
<h1 className="text-2xl font-semibold mb-4">Category Not Found</h1>
|
||
<Link
|
||
href="/gunbuilder"
|
||
className="text-amber-300 hover:text-amber-200 underline"
|
||
>
|
||
Return to Gunbuilder
|
||
</Link>
|
||
</div>
|
||
</main>
|
||
);
|
||
}
|
||
|
||
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">
|
||
{/* Breadcrumbs */}
|
||
<nav className="mb-4 text-xs text-zinc-500">
|
||
<Link
|
||
href="/gunbuilder"
|
||
className="hover:text-zinc-300 transition-colors"
|
||
>
|
||
Gunbuilder
|
||
</Link>
|
||
<span className="mx-1">/</span>
|
||
<Link
|
||
href={`/gunbuilder/${categoryId}`}
|
||
className="hover:text-zinc-300 transition-colors"
|
||
>
|
||
{category.name}
|
||
</Link>
|
||
{product && (
|
||
<>
|
||
<span className="mx-1">/</span>
|
||
<span className="text-zinc-300">{product.name}</span>
|
||
</>
|
||
)}
|
||
</nav>
|
||
|
||
{loading ? (
|
||
<p className="text-sm text-zinc-500">Loading product…</p>
|
||
) : error || !product ? (
|
||
<div>
|
||
<h1 className="text-xl font-semibold mb-2">Product Unavailable</h1>
|
||
<p className="text-sm text-zinc-500 mb-4">
|
||
{error ?? "We couldn’t find this product."}
|
||
</p>
|
||
<Link
|
||
href={`/gunbuilder/${categoryId}`}
|
||
className="text-amber-300 hover:text-amber-200 underline text-sm"
|
||
>
|
||
Back to {category.name} parts
|
||
</Link>
|
||
</div>
|
||
) : (
|
||
<>
|
||
<div className="grid gap-6 md:grid-cols-[minmax(0,2fr)_minmax(0,1.3fr)] items-start">
|
||
{/* Left: image + meta */}
|
||
<div className="space-y-4">
|
||
{product.mainImageUrl && (
|
||
<div className="overflow-hidden rounded-lg border border-zinc-800 bg-zinc-950">
|
||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||
<img
|
||
src={product.mainImageUrl}
|
||
alt={product.name}
|
||
className="w-full object-contain max-h-80 bg-zinc-950"
|
||
/>
|
||
</div>
|
||
)}
|
||
<div className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
|
||
{product.brand}
|
||
</div>
|
||
<h1 className="text-2xl md:text-3xl font-semibold tracking-tight">
|
||
{product.name}
|
||
</h1>
|
||
|
||
<div className="grid grid-cols-2 gap-3 text-xs">
|
||
<p className="text-zinc-500">
|
||
Platform:{" "}
|
||
<span className="text-zinc-300">{product.platform}</span>
|
||
</p>
|
||
<p className="text-zinc-500">
|
||
Role:{" "}
|
||
<span className="text-zinc-300">{product.partRole}</span>
|
||
</p>
|
||
<p className="text-zinc-500">
|
||
Category:{" "}
|
||
<span className="text-zinc-300">{category.name}</span>
|
||
</p>
|
||
<p className="text-zinc-500">
|
||
Offers:{" "}
|
||
<span className="text-zinc-300">
|
||
{offers.length > 0
|
||
? `${offers.length} live offer${
|
||
offers.length === 1 ? "" : "s"
|
||
}`
|
||
: "No live offers yet"}
|
||
</span>
|
||
</p>
|
||
</div>
|
||
|
||
{/* Placeholder content until long-form fields are wired up */}
|
||
<section className="mt-4 space-y-3 text-sm text-zinc-300">
|
||
<h2 className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
|
||
Product Overview
|
||
</h2>
|
||
<p className="text-zinc-400">
|
||
This is placeholder copy for the product overview. Once
|
||
the Ballistic backend exposes richer metadata (short
|
||
description, feature bullets, etc.), we'll swap this
|
||
text out and surface the real content here.
|
||
</p>
|
||
<p className="text-zinc-500 text-xs">
|
||
Use this section to highlight what makes this part worth
|
||
a slot in your build: materials, intended use
|
||
(duty/range/competition), and any standout features.
|
||
</p>
|
||
</section>
|
||
|
||
<section className="mt-4 rounded-lg border border-zinc-800 bg-zinc-950/70 p-3">
|
||
<h2 className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500 mb-2">
|
||
Quick Specs (Placeholder)
|
||
</h2>
|
||
<dl className="grid grid-cols-2 gap-x-4 gap-y-2 text-xs text-zinc-400">
|
||
<div>
|
||
<dt className="text-zinc-500">Configuration</dt>
|
||
<dd className="text-zinc-200">
|
||
TBD (Stripped / Complete / Kit)
|
||
</dd>
|
||
</div>
|
||
<div>
|
||
<dt className="text-zinc-500">Caliber</dt>
|
||
<dd className="text-zinc-200">TBD from feed</dd>
|
||
</div>
|
||
<div>
|
||
<dt className="text-zinc-500">Finish</dt>
|
||
<dd className="text-zinc-200">TBD from merchant data</dd>
|
||
</div>
|
||
<div>
|
||
<dt className="text-zinc-500">Weight</dt>
|
||
<dd className="text-zinc-200">TBD (oz)</dd>
|
||
</div>
|
||
</dl>
|
||
<p className="mt-2 text-[11px] text-zinc-500">
|
||
Specs are placeholders for now. As we normalize more
|
||
structured attributes in the importer, this block will
|
||
auto-populate per product.
|
||
</p>
|
||
</section>
|
||
</div>
|
||
|
||
{/* Right: pricing + actions */}
|
||
<aside className="rounded-lg border border-zinc-800 bg-zinc-950/70 p-4 space-y-4">
|
||
<div>
|
||
<p className="text-xs uppercase tracking-[0.2em] text-zinc-500 mb-1">
|
||
Price
|
||
</p>
|
||
<p className="text-2xl font-semibold text-amber-300">
|
||
{bestPrice && bestPrice > 0
|
||
? `$${bestPrice.toFixed(2)}`
|
||
: "—"}
|
||
</p>
|
||
{!bestPrice && (
|
||
<p className="mt-1 text-xs text-zinc-500">
|
||
Pricing not available yet. Live pricing will appear once
|
||
offers are imported.
|
||
</p>
|
||
)}
|
||
{offers.length > 0 && (
|
||
<p className="mt-1 text-[11px] text-zinc-500">
|
||
Showing the best available price across all live
|
||
offers for this part.
|
||
</p>
|
||
)}
|
||
{offers.length > 1 && (
|
||
<p className="mt-1 text-[11px] text-zinc-500">
|
||
Price range across retailers:{" "}
|
||
<span className="text-zinc-300">
|
||
${Math.min(...offers.map(o => o.price)).toFixed(2)} – ${Math.max(...offers.map(o => o.price)).toFixed(2)}
|
||
</span>
|
||
</p>
|
||
)}
|
||
</div>
|
||
|
||
{/* Offers list */}
|
||
<div className="space-y-2">
|
||
{offersLoading && (
|
||
<p className="text-xs text-zinc-500">Loading offers…</p>
|
||
)}
|
||
{offersError && (
|
||
<p className="text-xs text-red-400">{offersError}</p>
|
||
)}
|
||
{!offersLoading && !offersError && offers.length > 0 && (
|
||
<div className="space-y-1">
|
||
{offers.map((offer) => (
|
||
<a
|
||
key={offer.id}
|
||
href={offer.affiliateUrl}
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
className="flex items-center justify-between rounded-md border border-zinc-700 bg-zinc-900 px-3 py-1.5 text-xs hover:bg-zinc-800 transition-colors"
|
||
>
|
||
<span className="text-zinc-200">
|
||
{offer.retailerName}
|
||
</span>
|
||
<span className="font-semibold text-amber-300">
|
||
${offer.price.toFixed(2)}
|
||
</span>
|
||
</a>
|
||
))}
|
||
</div>
|
||
)}
|
||
{!offersLoading &&
|
||
!offersError &&
|
||
offers.length === 0 && (
|
||
<p className="text-xs text-zinc-500">
|
||
No live retailer offers yet. As feeds are imported,
|
||
merchants and pricing will show up here.
|
||
</p>
|
||
)}
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<Link
|
||
href={`/gunbuilder?select=${categoryId}:${product.id}`}
|
||
className="block w-full rounded-md bg-amber-400 text-black text-sm font-semibold text-center py-2.5 hover:bg-amber-300 transition-colors"
|
||
>
|
||
Add to Build
|
||
</Link>
|
||
|
||
{product.buyUrl ? (
|
||
<a
|
||
href={product.buyUrl}
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
className="block w-full rounded-md border border-zinc-700 bg-zinc-900 text-xs font-medium text-zinc-200 text-center py-2 hover:bg-zinc-800 transition-colors"
|
||
>
|
||
View on Merchant Site
|
||
</a>
|
||
) : (
|
||
<button
|
||
type="button"
|
||
disabled
|
||
className="block w-full rounded-md border border-zinc-800 bg-zinc-900/60 text-xs font-medium text-zinc-500 text-center py-2 cursor-not-allowed"
|
||
>
|
||
External link coming soon
|
||
</button>
|
||
)}
|
||
</div>
|
||
</aside>
|
||
</div>
|
||
|
||
{/* Lower content: builder-focused helper copy (placeholder) */}
|
||
<section className="mt-10 grid gap-6 md:grid-cols-3 text-sm md:grid-cols-4">
|
||
<div className="space-y-2">
|
||
<h2 className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
|
||
Why We Like This Part
|
||
</h2>
|
||
<p className="text-zinc-400">
|
||
Drop in a short blurb here about what makes this part
|
||
worth picking over similar options — think reliability,
|
||
track record, and value for the money.
|
||
</p>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<h2 className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
|
||
Best For
|
||
</h2>
|
||
<p className="text-zinc-400">
|
||
Use this block to call out ideal use cases:{" "}
|
||
<span className="text-zinc-200">
|
||
duty rifle, home defense, range toy, competition, night
|
||
work
|
||
</span>
|
||
, etc.
|
||
</p>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<h2 className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
|
||
Builder Notes
|
||
</h2>
|
||
<p className="text-zinc-400">
|
||
Add any compatibility quirks or install tips here once
|
||
the compatibility engine is wired up — gas system length,
|
||
buffer recommendations, known fitment notes, and more.
|
||
</p>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<h2 className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
|
||
Compatibility
|
||
</h2>
|
||
<div className="inline-flex items-center gap-2 rounded-md border border-amber-400/30 bg-amber-400/10 px-2 py-1 text-[11px] text-amber-300">
|
||
<span className="inline-block h-2 w-2 rounded-full bg-amber-300 animate-pulse"></span>
|
||
Compatibility engine coming online soon
|
||
</div>
|
||
<p className="text-zinc-500 text-xs">
|
||
Fitment checks, caliber validation, buffer/gas system logic, and platform rules will appear here once the engine is active.
|
||
</p>
|
||
</div>
|
||
</section>
|
||
</>
|
||
)}
|
||
</div>
|
||
</main>
|
||
);
|
||
} |