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,589 @@
"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 OfferFromApi = {
merchantName?: string | null;
price?: number | null;
buyUrl?: string | null;
inStock?: boolean | null;
lastUpdated?: string | null;
};
type GunbuilderProductFromApi = {
id: number;
name: string;
brand: string;
platform: string;
partRole: string;
price: number | null;
// image fields can vary depending on endpoint/version
imageUrl?: string | null;
mainImageUrl?: string | null;
// single best-link (legacy)
buyUrl?: string | null;
// stock info
inStock?: boolean | null;
// richer details (optional)
shortDescription?: string | null;
description?: string | null;
// offers (optional)
offers?: OfferFromApi[] | 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)}`;
}
function sortOffers(offers: OfferFromApi[]): OfferFromApi[] {
return [...offers].sort((a, b) => {
const ap = a.price ?? Number.POSITIVE_INFINITY;
const bp = b.price ?? Number.POSITIVE_INFINITY;
if (ap !== bp) return ap - bp;
// in-stock first
const aStock = a.inStock === false ? 1 : 0;
const bStock = b.inStock === false ? 1 : 0;
if (aStock !== bStock) return aStock - bStock;
// merchant name
return (a.merchantName ?? "").localeCompare(b.merchantName ?? "");
});
}
export default function ProductDetailsPage() {
const params = useParams();
const router = useRouter();
const searchParams = useSearchParams();
// 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, setBuild] = useState<BuildState>(() => {
if (typeof window === "undefined") return {};
try {
const stored = window.localStorage.getItem(STORAGE_KEY);
return stored ? (JSON.parse(stored) as BuildState) : {};
} catch {
return {};
}
});
// Keep build in sync if user changes build elsewhere
useEffect(() => {
if (typeof window === "undefined") return;
const onStorage = (e: StorageEvent) => {
if (e.key !== STORAGE_KEY) return;
try {
const stored = window.localStorage.getItem(STORAGE_KEY);
setBuild(stored ? (JSON.parse(stored) as BuildState) : {});
} catch {
// ignore
}
};
window.addEventListener("storage", onStorage);
return () => window.removeEventListener("storage", onStorage);
}, []);
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
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()}`);
};
const imageUrl = product?.imageUrl ?? product?.mainImageUrl ?? null;
// Offers normalization:
// - If product.offers exists, use it.
// - Otherwise, fall back to a single “offer” from product.buyUrl/product.price.
const offers = useMemo(() => {
if (!product) return [];
const raw = (product.offers ?? []).filter(Boolean) as OfferFromApi[];
if (raw.length > 0) return sortOffers(raw);
if (product.buyUrl) {
return sortOffers([
{
merchantName: "Merchant",
price: product.price,
buyUrl: product.buyUrl,
inStock: product.inStock ?? true,
},
]);
}
return [];
}, [product]);
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">
Offers, pricing placeholders, and builder actions all on the canonical
/parts/p route.
</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 + core actions */}
<div className="space-y-3">
<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">
{imageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={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" : "Add"}
</button>
</div>
</div>
{/* Pricing history placeholder */}
<div className="rounded-md border border-zinc-800 bg-zinc-950/80 p-3">
<div className="flex items-center justify-between">
<h3 className="text-xs font-semibold uppercase tracking-[0.16em] text-zinc-500">
Price History
</h3>
<span className="text-[10px] text-zinc-600">placeholder</span>
</div>
<div className="mt-2 h-28 rounded border border-zinc-800 bg-zinc-950 flex items-center justify-center text-xs text-zinc-600">
Pricing graph will go here
</div>
<p className="mt-2 text-[11px] text-zinc-500">
Well wire this to price snapshots once the offer model is stable.
</p>
</div>
</div>
{/* Right: details + offers */}
<div className="min-w-0 space-y-4">
{/* Product meta */}
<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>
{(product.shortDescription || product.description) && (
<div className="mt-4 border-t border-zinc-800 pt-4">
{product.shortDescription ? (
<p className="text-sm text-zinc-300">
{product.shortDescription}
</p>
) : null}
{product.description ? (
<p className="mt-2 whitespace-pre-wrap text-sm text-zinc-400">
{product.description}
</p>
) : null}
</div>
)}
</div>
{/* Offers */}
<div className="rounded-md border border-zinc-800 bg-zinc-950/80 p-4">
<div className="flex items-center justify-between">
<h3 className="text-xs font-semibold uppercase tracking-[0.16em] text-zinc-500">
Merchant Offers
</h3>
<span className="text-[10px] text-zinc-600">
{offers.length ? `${offers.length} offers` : "no offers"}
</span>
</div>
{offers.length ? (
<div className="mt-3 overflow-x-auto rounded border border-zinc-800">
<table className="min-w-full text-sm">
<thead className="bg-zinc-900/60">
<tr className="text-xs uppercase tracking-[0.14em] text-zinc-500">
<th className="px-3 py-2 text-left">Merchant</th>
<th className="px-3 py-2 text-left">Stock</th>
<th className="px-3 py-2 text-right">Price</th>
<th className="px-3 py-2 text-right">Action</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-800">
{offers.map((o, idx) => (
<tr key={idx} className="hover:bg-zinc-900/40">
<td className="px-3 py-2 text-zinc-200">
{o.merchantName ?? "Merchant"}
</td>
<td className="px-3 py-2 text-xs">
{o.inStock === false ? (
<span className="text-red-300">Out of stock</span>
) : (
<span className="text-emerald-300">In stock</span>
)}
</td>
<td className="px-3 py-2 text-right font-semibold text-amber-300">
{o.price != null ? `$${o.price.toFixed(2)}` : "—"}
</td>
<td className="px-3 py-2 text-right">
{o.buyUrl ? (
<a
href={o.buyUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center justify-center rounded-md bg-amber-400 px-3 py-1.5 text-xs font-semibold text-black hover:bg-amber-300"
>
Buy
</a>
) : (
<span className="text-xs text-zinc-600"></span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<p className="mt-3 text-sm text-zinc-500">
No offers attached yet. Once offers are available, this becomes the
where to buy table.
</p>
)}
{/* Keep a single "best" buy CTA if buyUrl exists */}
{product.buyUrl ? (
<div className="mt-3 flex justify-end">
<a
href={product.buyUrl}
target="_blank"
rel="noreferrer"
className="rounded-md border border-zinc-700 bg-zinc-900/70 px-4 py-2 text-sm font-semibold text-zinc-200 hover:bg-zinc-800"
>
Buy from Merchant
</a>
</div>
) : null}
</div>
{/* Footer tips */}
<div className="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: Add this part, then go back and compare alternates.
</span>
<span className="rounded border border-zinc-800 bg-zinc-950/60 px-2 py-1">
Canonical route:{" "}
<span className="text-zinc-300">
/parts/p/{platform}/{partRoleParam}/{productSlug}
</span>
</span>
</div>
</div>
</div>
)}
</section>
</div>
</main>
);
}
@@ -0,0 +1,5 @@
import PartsBrowseClient from "@/components/parts/PartsBrowseClient";
export default function Page({ params }: { params: { platform: string; partRole: string } }) {
return <PartsBrowseClient partRole={params.partRole} platform={params.platform} />;
}