full app is using live data now,
This commit is contained in:
@@ -5,13 +5,15 @@ 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: number;
|
||||
uuid: string;
|
||||
slug: string;
|
||||
id: string; // backend UUID string
|
||||
name: string;
|
||||
brandName: string;
|
||||
brand: string;
|
||||
platform: string;
|
||||
partRole: string;
|
||||
price: number | null;
|
||||
@@ -31,11 +33,16 @@ export default function PartDetailPage() {
|
||||
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;
|
||||
|
||||
@@ -47,7 +54,7 @@ export default function PartDetailPage() {
|
||||
setError(null);
|
||||
|
||||
// For now, pull the full AR-15 list and find by UUID.
|
||||
// We can optimize with a dedicated /api/products/gunbuilder/{uuid} later.
|
||||
// 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 });
|
||||
|
||||
@@ -56,7 +63,7 @@ export default function PartDetailPage() {
|
||||
}
|
||||
|
||||
const data: GunbuilderProductFromApi[] = await res.json();
|
||||
const found = data.find((p) => p.uuid === partId) ?? null;
|
||||
const found = data.find((p) => p.id === partId) ?? null;
|
||||
|
||||
if (!found) {
|
||||
setError("Product not found");
|
||||
@@ -76,6 +83,48 @@ export default function PartDetailPage() {
|
||||
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">
|
||||
@@ -148,13 +197,14 @@ export default function PartDetailPage() {
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
|
||||
{product.brandName}
|
||||
{product.brand}
|
||||
</div>
|
||||
<h1 className="text-2xl md:text-3xl font-semibold tracking-tight">
|
||||
{product.name}
|
||||
</h1>
|
||||
<p className="text-xs text-zinc-500">
|
||||
Platform: <span className="text-zinc-300">{product.platform}</span>
|
||||
Platform:{" "}
|
||||
<span className="text-zinc-300">{product.platform}</span>
|
||||
</p>
|
||||
<p className="text-xs text-zinc-500">
|
||||
Role: <span className="text-zinc-300">{product.partRole}</span>
|
||||
@@ -168,11 +218,11 @@ export default function PartDetailPage() {
|
||||
Price
|
||||
</p>
|
||||
<p className="text-2xl font-semibold text-amber-300">
|
||||
{product.price && product.price > 0
|
||||
? `$${product.price.toFixed(2)}`
|
||||
{bestPrice && bestPrice > 0
|
||||
? `$${bestPrice.toFixed(2)}`
|
||||
: "—"}
|
||||
</p>
|
||||
{!product.price && (
|
||||
{!bestPrice && (
|
||||
<p className="mt-1 text-xs text-zinc-500">
|
||||
Pricing not available yet. Live pricing will appear once
|
||||
offers are imported.
|
||||
@@ -180,9 +230,41 @@ export default function PartDetailPage() {
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Link
|
||||
href={`/gunbuilder?select=${categoryId}:${product.uuid}`}
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user