lots of fixes. cant remember them all

This commit is contained in:
2025-12-18 09:42:25 -05:00
parent 607939c468
commit 4c2d767d09
28 changed files with 3342 additions and 996 deletions
@@ -0,0 +1,431 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useParams, useRouter, useSearchParams } from "next/navigation";
import type { CategoryId } from "@/types/gunbuilder";
import {
PART_ROLE_TO_CATEGORY,
normalizePartRole,
} from "@/lib/catalogMappings";
type GunbuilderProductFromApi = {
id: number;
name: string;
brand: string;
platform: string;
partRole: string;
price: number | null;
imageUrl: string | null;
buyUrl: string | null;
inStock?: boolean | null;
};
type BuildState = Partial<Record<CategoryId, string>>;
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
const STORAGE_KEY = "gunbuilder-build-state";
const PLATFORMS = ["AR-15", "AR-10", "AR-9"] as const;
function extractNumericId(productSlug: string): number | null {
if (!productSlug) return null;
const first = productSlug.split("-")[0];
const n = Number(first);
return Number.isFinite(n) && n > 0 ? n : null;
}
function formatPrice(price: number | null | undefined): string {
if (price == null) return "—";
return `$${price.toFixed(2)}`;
}
export default function ProductDetailsPage() {
const params = useParams();
const router = useRouter();
const searchParams = useSearchParams();
// NEW ROUTE PARAMS:
const platformParam = String(params.platform ?? "");
const partRoleParam = String(params.partRole ?? "");
const productSlug = String(params.productSlug ?? "");
// Platform comes from path segment (fallback to ?platform just in case)
const platform =
platformParam ||
(searchParams.get("platform") as string) ||
"AR-15";
const normalizedRole = useMemo(
() => normalizePartRole(partRoleParam),
[partRoleParam]
);
const categoryId = useMemo(() => {
return PART_ROLE_TO_CATEGORY[partRoleParam] ?? PART_ROLE_TO_CATEGORY[normalizedRole] ?? null;
}, [partRoleParam, normalizedRole]);
const numericId = useMemo(
() => extractNumericId(productSlug),
[productSlug]
);
// Read-only build state (builder page owns writes)
const [build] = useState<BuildState>(() => {
if (typeof window === "undefined") return {};
try {
const stored = window.localStorage.getItem(STORAGE_KEY);
return stored ? (JSON.parse(stored) as BuildState) : {};
} catch {
return {};
}
});
const selectedPartIdForCategory = categoryId ? build?.[categoryId] : undefined;
const isSelected =
!!categoryId &&
selectedPartIdForCategory === (numericId ? String(numericId) : "");
const [product, setProduct] = useState<GunbuilderProductFromApi | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
// If platform segment is missing/empty, normalize into the new route (not /builder)
useEffect(() => {
if (platformParam) return;
const qp = new URLSearchParams(searchParams.toString());
qp.set("platform", platform || "AR-15");
router.replace(
`/parts/p/${encodeURIComponent(platform || "AR-15")}/${encodeURIComponent(
partRoleParam
)}/${encodeURIComponent(productSlug)}?${qp.toString()}`
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [platformParam, platform, partRoleParam, productSlug, router, searchParams]);
// Fetch product
useEffect(() => {
if (!numericId) {
setError("Invalid product id.");
setLoading(false);
return;
}
const controller = new AbortController();
async function fetchProduct() {
try {
setLoading(true);
setError(null);
const url = `${API_BASE_URL}/api/products/${numericId}`;
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();
setProduct(data);
} catch (err: any) {
if (err?.name === "AbortError") return;
setError(err?.message ?? "Failed to load product");
} finally {
setLoading(false);
}
}
fetchProduct();
return () => controller.abort();
}, [numericId]);
const handleTogglePart = () => {
if (!numericId) return;
// If mapping is missing, dont send a bogus builder action
if (!categoryId) {
alert(
`No CategoryId mapping found for partRole "${partRoleParam}". Add it in catalogMappings.`
);
return;
}
const qp = new URLSearchParams();
qp.set("platform", platform || "AR-15");
if (isSelected) {
qp.set("remove", String(categoryId));
router.push(`/builder?${qp.toString()}`);
return;
}
qp.set("select", `${categoryId}:${numericId}`);
router.push(`/builder?${qp.toString()}`);
};
if (!categoryId) {
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">
Unknown Part Role
</h1>
<p className="text-sm text-zinc-400 mb-6">
No mapping found for <span className="text-zinc-200">{partRoleParam}</span>.
Add it to <span className="text-zinc-200">catalogMappings</span>.
</p>
<Link
href={`/parts/p/${encodeURIComponent(platform)}/${encodeURIComponent(
partRoleParam
)}`}
className="text-amber-300 hover:text-amber-200 underline"
>
Back to Parts List
</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">
{/* Breadcrumbs */}
<header className="mb-6">
<div className="flex flex-wrap items-center gap-2 text-xs text-zinc-500">
<Link
href={{ pathname: "/builder", query: platform ? { platform } : {} }}
className="hover:text-zinc-300"
>
The Armory
</Link>
<span className="text-zinc-700">/</span>
<Link
href={`/parts/p/${encodeURIComponent(platform)}/${encodeURIComponent(
partRoleParam
)}`}
className="hover:text-zinc-300"
>
Parts
</Link>
<span className="text-zinc-700">/</span>
<span className="text-zinc-300">Details</span>
</div>
<div className="mt-4 flex items-start 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">
Product <span className="text-amber-300">Details</span>
</h1>
<p className="mt-2 text-sm text-zinc-400 max-w-2xl">
Live product data pulled from your Ballistic backend. Add it to your build
or jump back to compare options.
</p>
</div>
{/* Platform switch (updates NEW route) */}
<div className="flex items-center gap-2">
<label htmlFor="platform-select" className="text-xs text-zinc-500">
Platform
</label>
<select
id="platform-select"
value={platform}
onChange={(e) => {
const next = e.target.value;
router.replace(
`/parts/p/${encodeURIComponent(next)}/${encodeURIComponent(
partRoleParam
)}/${encodeURIComponent(productSlug)}`
);
}}
className="rounded-md border border-zinc-700 bg-zinc-900/70 px-2 py-1 text-xs text-zinc-200 focus:outline-none focus:ring-1 focus:ring-amber-400"
>
{PLATFORMS.map((p) => (
<option key={p} value={p}>
{p}
</option>
))}
</select>
</div>
</div>
</header>
{/* Body */}
<section className="rounded-lg border border-zinc-800 bg-zinc-950/60 p-4 md:p-6">
{loading ? (
<p className="py-10 text-center text-sm text-zinc-500">Loading product</p>
) : error ? (
<p className="py-10 text-center text-sm text-red-400">
{error} check that the Ballistic API is running.
</p>
) : !product ? (
<p className="py-10 text-center text-sm text-zinc-500">Product not found.</p>
) : (
<div className="grid gap-6 md:grid-cols-[280px_1fr]">
{/* Left: image */}
<div className="rounded-md border border-zinc-800 bg-zinc-950/80 p-3">
<div className="aspect-square w-full overflow-hidden rounded-md border border-zinc-800 bg-black">
{product.imageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={product.imageUrl}
alt={product.name}
className="h-full w-full object-contain p-2"
loading="lazy"
/>
) : (
<div className="flex h-full w-full items-center justify-center text-xs text-zinc-600">
No image
</div>
)}
</div>
<div className="mt-3 flex items-center justify-between">
<span className="text-[10px] uppercase tracking-[0.16em] text-zinc-500">
Stock
</span>
<span
className={`text-xs font-semibold ${
product.inStock === false ? "text-red-300" : "text-emerald-300"
}`}
>
{product.inStock === false ? "Out of stock" : "In stock"}
</span>
</div>
<div className="mt-3 flex gap-2">
<Link
href={`/parts/p/${encodeURIComponent(platform)}/${encodeURIComponent(
partRoleParam
)}`}
className="flex-1 rounded-md border border-zinc-700 bg-zinc-900/70 px-3 py-2 text-center text-xs font-medium text-zinc-300 transition-colors hover:border-zinc-600 hover:bg-zinc-800"
>
Back to List
</Link>
<button
type="button"
onClick={handleTogglePart}
className={`flex-1 rounded-md px-3 py-2 text-center text-xs font-semibold transition-colors ${
isSelected
? "border border-zinc-600 bg-zinc-800 text-zinc-100 hover:bg-zinc-700"
: "bg-amber-400 text-black hover:bg-amber-300"
}`}
>
{isSelected ? "Remove from Build" : "Add to Build"}
</button>
</div>
</div>
{/* Right: details */}
<div className="min-w-0">
<div className="rounded-md border border-zinc-800 bg-zinc-950/80 p-4">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div className="min-w-0">
<p className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
{product.brand}
</p>
<h2 className="mt-1 text-xl md:text-2xl font-semibold text-zinc-50">
{product.name}
</h2>
<div className="mt-2 flex flex-wrap gap-2 text-xs">
<span className="rounded border border-zinc-800 bg-zinc-900/60 px-2 py-1 text-zinc-300">
Platform:{" "}
<span className="font-semibold text-zinc-100">
{product.platform || platform}
</span>
</span>
<span className="rounded border border-zinc-800 bg-zinc-900/60 px-2 py-1 text-zinc-300">
Role:{" "}
<span className="font-semibold text-zinc-100">
{product.partRole || partRoleParam}
</span>
</span>
<span className="rounded border border-zinc-800 bg-zinc-900/60 px-2 py-1 text-zinc-300">
ID:{" "}
<span className="font-semibold text-zinc-100">
{product.id}
</span>
</span>
</div>
</div>
<div className="shrink-0 text-right">
<div className="text-[10px] uppercase tracking-[0.16em] text-zinc-500">
Current price
</div>
<div className="mt-1 text-2xl font-semibold text-amber-300">
{formatPrice(product.price)}
</div>
</div>
</div>
<div className="mt-4 flex flex-col gap-2 sm:flex-row">
<button
type="button"
onClick={handleTogglePart}
className={`rounded-md px-4 py-2 text-sm font-semibold transition-colors ${
isSelected
? "border border-zinc-600 bg-zinc-800 text-zinc-100 hover:bg-zinc-700"
: "bg-amber-400 text-black hover:bg-amber-300"
}`}
>
{isSelected ? "Remove from Build" : "Add to Build"}
</button>
{product.buyUrl ? (
<a
href={product.buyUrl}
target="_blank"
rel="noreferrer"
className="rounded-md border border-zinc-700 bg-zinc-900/70 px-4 py-2 text-center text-sm font-semibold text-zinc-200 hover:bg-zinc-800"
>
Buy from Merchant
</a>
) : (
<span className="rounded-md border border-zinc-800 bg-zinc-950 px-4 py-2 text-center text-sm text-zinc-500">
No buy link available
</span>
)}
</div>
<div className="mt-4 border-t border-zinc-800 pt-4">
<h3 className="text-xs font-semibold uppercase tracking-[0.16em] text-zinc-500">
Notes
</h3>
<p className="mt-2 text-sm text-zinc-400">
This details page is intentionally thin for now once we wire in
offers, price history, and richer product fields, this section becomes
the spec + comparison hub.
</p>
</div>
</div>
<div className="mt-4 flex flex-wrap gap-2 text-xs text-zinc-500">
<span className="rounded border border-zinc-800 bg-zinc-950/60 px-2 py-1">
Tip: use Back to List to compare multiple parts quickly.
</span>
<span className="rounded border border-zinc-800 bg-zinc-950/60 px-2 py-1">
Platform comes from the URL segment:{" "}
<span className="text-zinc-300">/parts/p/{platform}/...</span>
</span>
</div>
</div>
</div>
)}
</section>
</div>
</main>
);
}