Files
shadow-gunbuilder-ai-proto/app/gunbuilder/[categoryId]/page.tsx
T
2025-11-30 21:08:13 -05:00

288 lines
11 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, Part } from "@/types/gunbuilder";
type ViewMode = "card" | "list";
type GunbuilderProductFromApi = {
id: string; // backend UUID as string
name: string;
brand: string; // backend brand field
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";
/**
* Map from category id -> Ballistic partRole(s)
* Keep this in sync with whatever youre using on the main /gunbuilder page.
*/
const CATEGORY_TO_PART_ROLES: Record<CategoryId, string[]> = {
upper: ["upper-receiver"],
barrel: ["barrel"],
handguard: ["handguard"],
chargingHandle: ["charging-handle"],
buffer: ["buffer-kit"],
lowerParts: ["lower-parts-kit"],
sights: ["sight"],
};
export default function CategoryPage() {
const params = useParams();
const categoryId = params.categoryId as CategoryId;
const [viewMode, setViewMode] = useState<ViewMode>("list");
const [parts, setParts] = useState<Part[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const category = useMemo(
() => CATEGORIES.find((c) => c.id === categoryId),
[categoryId],
);
useEffect(() => {
if (!categoryId) return;
const controller = new AbortController();
async function fetchCategoryParts() {
try {
setLoading(true);
setError(null);
const roles = CATEGORY_TO_PART_ROLES[categoryId] ?? [];
const search = new URLSearchParams();
search.set("platform", "AR-15");
roles.forEach((r) => search.append("partRoles", r));
const url = `${API_BASE_URL}/api/products/gunbuilder${
roles.length ? `?${search.toString()}` : ""
}`;
const res = await fetch(url, { signal: controller.signal });
if (!res.ok) {
throw new Error(`Failed to load products (${res.status})`);
}
const data: GunbuilderProductFromApi[] = await res.json();
// 🔑 Normalize into your Part type using *id* and *brand*
const normalized: Part[] = data.map((p) => ({
id: p.id, // ✅ backend id (UUID string)
categoryId,
name: p.name,
brand: p.brand, // ✅ backend brand
price: p.price ?? 0,
imageUrl: p.mainImageUrl ?? undefined,
url: p.buyUrl ?? undefined,
notes: undefined,
}));
setParts(normalized);
} catch (err: any) {
if (err.name === "AbortError") return;
setError(err.message ?? "Failed to load products");
} finally {
setLoading(false);
}
}
fetchCategoryParts();
return () => controller.abort();
}, [categoryId]);
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">
<div className="text-center">
<h1 className="text-2xl font-semibold text-zinc-50 mb-4">
Category Not Found
</h1>
<Link
href="/gunbuilder"
className="text-amber-300 hover:text-amber-200 underline"
>
Return to Gunbuilder
</Link>
</div>
</div>
</main>
);
}
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">
{/* Header */}
<header className="mb-6">
<Link
href="/gunbuilder"
className="text-xs text-zinc-400 hover:text-zinc-300 mb-4 inline-block"
>
Back to Gunbuilder
</Link>
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
Shadow Standard
</p>
<h1 className="mt-1 text-2xl md:text-3xl font-semibold tracking-tight">
{category.name} <span className="text-amber-300">Parts</span>
</h1>
<p className="mt-2 text-sm text-zinc-400 max-w-xl">
Browse all available {category.name.toLowerCase()} options for
your build, pulled live from your Ballistic backend.
</p>
</div>
{!loading && !error && parts.length > 0 && (
<div className="flex gap-2 border border-zinc-800 rounded-md p-1 bg-zinc-950/60">
<button
type="button"
onClick={() => setViewMode("card")}
className={`px-3 py-1.5 text-xs font-medium rounded transition-colors ${
viewMode === "card"
? "bg-zinc-800 text-zinc-50"
: "text-zinc-400 hover:text-zinc-300"
}`}
aria-label="Card view"
>
Card
</button>
<button
type="button"
onClick={() => setViewMode("list")}
className={`px-3 py-1.5 text-xs font-medium rounded transition-colors ${
viewMode === "list"
? "bg-zinc-800 text-zinc-50"
: "text-zinc-400 hover:text-zinc-300"
}`}
aria-label="List view"
>
List
</button>
</div>
)}
</div>
</header>
{/* Parts Grid/List */}
<section className="rounded-lg border border-zinc-800 bg-zinc-950/60 p-3 md:p-4">
{loading ? (
<p className="text-sm text-zinc-500 text-center py-8">
Loading {category.name.toLowerCase()}
</p>
) : error ? (
<p className="text-sm text-red-400 text-center py-8">
{error} check that the Ballistic API is running.
</p>
) : parts.length === 0 ? (
<p className="text-sm text-zinc-500 text-center py-8">
No parts available for this category yet.
</p>
) : viewMode === "card" ? (
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
{parts.map((part) => (
<div
key={part.id}
className="group relative border border-zinc-700 rounded-md p-3 bg-zinc-900/50 hover:border-amber-400/60 hover:bg-amber-400/10 transition-all duration-200 flex flex-col"
>
<Link
href={`/gunbuilder?select=${categoryId}:${part.id}`}
className="flex-1"
>
<div className="flex justify-between items-center gap-2 mb-3">
<div className="flex-1">
<div className="text-sm font-semibold text-zinc-50">
{part.brand}{" "}
<span className="font-normal"> {part.name}</span>
</div>
{part.notes && (
<p className="mt-1 text-xs text-zinc-400 line-clamp-2">
{part.notes}
</p>
)}
</div>
<div className="text-sm font-semibold text-amber-300 whitespace-nowrap">
${part.price.toFixed(2)}
</div>
</div>
</Link>
<Link
href={`/gunbuilder/${categoryId}/${part.id}`}
className="w-full rounded-md border border-zinc-700 bg-zinc-800/50 px-3 py-2 text-xs font-medium text-zinc-300 hover:bg-zinc-700 hover:border-zinc-600 transition-colors text-center"
>
View Details
</Link>
{/* Hover Overlay */}
<div className="absolute inset-0 rounded-md bg-amber-400/10 opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none flex items-center justify-center">
<div className="text-sm font-semibold text-amber-200 tracking-wide">
Add to Build
</div>
</div>
</div>
))}
</div>
) : (
<div className="space-y-2">
{parts.map((part) => (
<div
key={part.id}
className="group relative border border-zinc-700 rounded-md p-3 bg-zinc-900/50 hover:border-amber-400/60 hover:bg-amber-400/10 transition-all duration-200"
>
<div className="flex items-center justify-between gap-4">
<div className="flex-1 min-w-0">
<Link
href={`/gunbuilder?select=${categoryId}:${part.id}`}
className="block"
>
<div className="text-sm font-semibold text-zinc-50">
{part.brand}{" "}
<span className="font-normal"> {part.name}</span>
</div>
{part.notes && (
<p className="mt-1 text-xs text-zinc-400 line-clamp-1">
{part.notes}
</p>
)}
</Link>
</div>
<div className="flex items-center gap-3">
<div className="text-sm font-semibold text-amber-300 whitespace-nowrap">
${part.price.toFixed(2)}
</div>
<Link
href={`/gunbuilder/${categoryId}/${part.id}`}
className="rounded-md border border-zinc-700 bg-zinc-800/50 px-3 py-1.5 text-xs font-medium text-zinc-300 hover:bg-zinc-700 hover:border-zinc-600 transition-colors whitespace-nowrap"
>
View Details
</Link>
</div>
</div>
{/* Hover Overlay */}
<div className="absolute inset-0 rounded-md bg-amber-400/10 opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none flex items-center justify-center">
<div className="text-sm font-semibold text-amber-200 tracking-wide">
Add to Build
</div>
</div>
</div>
))}
</div>
)}
</section>
</div>
</main>
);
}