Files
shadow-gunbuilder-ai-proto/app/gunbuilder/[categoryId]/[partId]/page.tsx
T

216 lines
7.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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";
type GunbuilderProductFromApi = {
id: number;
uuid: string;
slug: string;
name: string;
brandName: 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 category = useMemo(
() => CATEGORIES.find((c) => c.id === categoryId),
[categoryId],
);
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/gunbuilder/{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.uuid === 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]);
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 couldnt 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.brandName}
</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>
</p>
<p className="text-xs text-zinc-500">
Role: <span className="text-zinc-300">{product.partRole}</span>
</p>
</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">
{product.price && product.price > 0
? `$${product.price.toFixed(2)}`
: "—"}
</p>
{!product.price && (
<p className="mt-1 text-xs text-zinc-500">
Pricing not available yet. Live pricing will appear once
offers are imported.
</p>
)}
</div>
<div className="space-y-2">
<Link
href={`/gunbuilder?select=${categoryId}:${product.uuid}`}
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>
)}
</div>
</main>
);
}