lots of fixes. cant remember them all
This commit is contained in:
@@ -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, don’t 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">
|
||||
We’ll 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} />;
|
||||
}
|
||||
Reference in New Issue
Block a user