a metric shit ton. fixed a lot of part_role issues. removed almost all hardcoded categories and used db roles.
This commit is contained in:
@@ -1,438 +0,0 @@
|
|||||||
"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";
|
|
||||||
import {
|
|
||||||
getRetailerOffers,
|
|
||||||
type RetailerOffer,
|
|
||||||
} from "./data";
|
|
||||||
|
|
||||||
type GunbuilderProductFromApi = {
|
|
||||||
id: string; // backend UUID string
|
|
||||||
name: string;
|
|
||||||
brand: string;
|
|
||||||
platform: string;
|
|
||||||
partRole: string;
|
|
||||||
price: number | null;
|
|
||||||
imageUrl: 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;
|
|
||||||
|
|
||||||
// Support URLs like /builder/lower/25969-m5-complete-lower-receiver
|
|
||||||
const rawPartParam = params.partId as string;
|
|
||||||
const partId = rawPartParam.split("-")[0]; // "25969-m5-..." -> "25969"
|
|
||||||
|
|
||||||
const [product, setProduct] = useState<GunbuilderProductFromApi | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const [offers, setOffers] = useState<RetailerOffer[]>([]);
|
|
||||||
const [offersLoading, setOffersLoading] = useState(true);
|
|
||||||
const [offersError, setOffersError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const category = useMemo(
|
|
||||||
() => CATEGORIES.find((c) => c.id === categoryId),
|
|
||||||
[categoryId],
|
|
||||||
);
|
|
||||||
|
|
||||||
// 1) Load product (same as before)
|
|
||||||
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/{uuid} later.
|
|
||||||
const url = `${API_BASE_URL}/api/products/gunbuilder/products/${partId}`;
|
|
||||||
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();
|
|
||||||
|
|
||||||
if (!data) {
|
|
||||||
setError("Product not found");
|
|
||||||
}
|
|
||||||
|
|
||||||
setProduct(data);
|
|
||||||
} catch (err: any) {
|
|
||||||
if (err.name === "AbortError") return;
|
|
||||||
setError(err.message ?? "Failed to load product");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchProduct();
|
|
||||||
|
|
||||||
return () => controller.abort();
|
|
||||||
}, [partId]);
|
|
||||||
|
|
||||||
// 2) Load offers for this product from Ballistic backend
|
|
||||||
useEffect(() => {
|
|
||||||
if (!partId) return;
|
|
||||||
|
|
||||||
let cancelled = false;
|
|
||||||
|
|
||||||
async function fetchOffers() {
|
|
||||||
try {
|
|
||||||
setOffersLoading(true);
|
|
||||||
setOffersError(null);
|
|
||||||
|
|
||||||
const data = await getRetailerOffers(partId);
|
|
||||||
|
|
||||||
if (!cancelled) {
|
|
||||||
setOffers(data);
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
|
||||||
if (!cancelled) {
|
|
||||||
setOffersError(err.message ?? "Failed to load retailer offers");
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (!cancelled) {
|
|
||||||
setOffersLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchOffers();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [partId]);
|
|
||||||
|
|
||||||
// Best price: prefer live offers, fall back to summary product.price
|
|
||||||
const bestPrice = useMemo(() => {
|
|
||||||
if (offers.length > 0) {
|
|
||||||
return offers[0].price;
|
|
||||||
}
|
|
||||||
return product?.price ?? null;
|
|
||||||
}, [offers, product]);
|
|
||||||
|
|
||||||
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="/builder"
|
|
||||||
className="text-amber-300 hover:text-amber-200 underline"
|
|
||||||
>
|
|
||||||
Return to Builder
|
|
||||||
</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="/builder"
|
|
||||||
className="hover:text-zinc-300 transition-colors"
|
|
||||||
>
|
|
||||||
Builder
|
|
||||||
</Link>
|
|
||||||
<span className="mx-1">/</span>
|
|
||||||
<Link
|
|
||||||
href={`/builder/${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 couldn’t find this product."}
|
|
||||||
</p>
|
|
||||||
<Link
|
|
||||||
href={`/builder/${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.imageUrl && (
|
|
||||||
<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.imageUrl}
|
|
||||||
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.brand}
|
|
||||||
</div>
|
|
||||||
<h1 className="text-2xl md:text-3xl font-semibold tracking-tight">
|
|
||||||
{product.name}
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3 text-xs">
|
|
||||||
<p className="text-zinc-500">
|
|
||||||
Platform:{" "}
|
|
||||||
<span className="text-zinc-300">{product.platform}</span>
|
|
||||||
</p>
|
|
||||||
<p className="text-zinc-500">
|
|
||||||
Role:{" "}
|
|
||||||
<span className="text-zinc-300">{product.partRole}</span>
|
|
||||||
</p>
|
|
||||||
<p className="text-zinc-500">
|
|
||||||
Category:{" "}
|
|
||||||
<span className="text-zinc-300">{category.name}</span>
|
|
||||||
</p>
|
|
||||||
<p className="text-zinc-500">
|
|
||||||
Offers:{" "}
|
|
||||||
<span className="text-zinc-300">
|
|
||||||
{offers.length > 0
|
|
||||||
? `${offers.length} live offer${
|
|
||||||
offers.length === 1 ? "" : "s"
|
|
||||||
}`
|
|
||||||
: "No live offers yet"}
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Placeholder content until long-form fields are wired up */}
|
|
||||||
<section className="mt-4 space-y-3 text-sm text-zinc-300">
|
|
||||||
<h2 className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
|
|
||||||
Product Overview
|
|
||||||
</h2>
|
|
||||||
<p className="text-zinc-400">
|
|
||||||
This is placeholder copy for the product overview. Once
|
|
||||||
the Ballistic backend exposes richer metadata (short
|
|
||||||
description, feature bullets, etc.), we'll swap this
|
|
||||||
text out and surface the real content here.
|
|
||||||
</p>
|
|
||||||
<p className="text-zinc-500 text-xs">
|
|
||||||
Use this section to highlight what makes this part worth
|
|
||||||
a slot in your build: materials, intended use
|
|
||||||
(duty/range/competition), and any standout features.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="mt-4 rounded-lg border border-zinc-800 bg-zinc-950/70 p-3">
|
|
||||||
<h2 className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500 mb-2">
|
|
||||||
Quick Specs (Placeholder)
|
|
||||||
</h2>
|
|
||||||
<dl className="grid grid-cols-2 gap-x-4 gap-y-2 text-xs text-zinc-400">
|
|
||||||
<div>
|
|
||||||
<dt className="text-zinc-500">Configuration</dt>
|
|
||||||
<dd className="text-zinc-200">
|
|
||||||
TBD (Stripped / Complete / Kit)
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt className="text-zinc-500">Caliber</dt>
|
|
||||||
<dd className="text-zinc-200">TBD from feed</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt className="text-zinc-500">Finish</dt>
|
|
||||||
<dd className="text-zinc-200">TBD from merchant data</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt className="text-zinc-500">Weight</dt>
|
|
||||||
<dd className="text-zinc-200">TBD (oz)</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
<p className="mt-2 text-[11px] text-zinc-500">
|
|
||||||
Specs are placeholders for now. As we normalize more
|
|
||||||
structured attributes in the importer, this block will
|
|
||||||
auto-populate per product.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
</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">
|
|
||||||
{bestPrice && bestPrice > 0
|
|
||||||
? `$${bestPrice.toFixed(2)}`
|
|
||||||
: "—"}
|
|
||||||
</p>
|
|
||||||
{!bestPrice && (
|
|
||||||
<p className="mt-1 text-xs text-zinc-500">
|
|
||||||
Pricing not available yet. Live pricing will appear once
|
|
||||||
offers are imported.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{offers.length > 0 && (
|
|
||||||
<p className="mt-1 text-[11px] text-zinc-500">
|
|
||||||
Showing the best available price across all live
|
|
||||||
offers for this part.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{offers.length > 1 && (
|
|
||||||
<p className="mt-1 text-[11px] text-zinc-500">
|
|
||||||
Price range across retailers:{" "}
|
|
||||||
<span className="text-zinc-300">
|
|
||||||
${Math.min(...offers.map(o => o.price)).toFixed(2)} – ${Math.max(...offers.map(o => o.price)).toFixed(2)}
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Offers list */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
{offersLoading && (
|
|
||||||
<p className="text-xs text-zinc-500">Loading offers…</p>
|
|
||||||
)}
|
|
||||||
{offersError && (
|
|
||||||
<p className="text-xs text-red-400">{offersError}</p>
|
|
||||||
)}
|
|
||||||
{!offersLoading && !offersError && offers.length > 0 && (
|
|
||||||
<div className="space-y-1">
|
|
||||||
{offers.map((offer) => (
|
|
||||||
<a
|
|
||||||
key={offer.id}
|
|
||||||
href={offer.affiliateUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="flex items-center justify-between rounded-md border border-zinc-700 bg-zinc-900 px-3 py-1.5 text-xs hover:bg-zinc-800 transition-colors"
|
|
||||||
>
|
|
||||||
<span className="text-zinc-200">
|
|
||||||
{offer.retailerName}
|
|
||||||
</span>
|
|
||||||
<span className="font-semibold text-amber-300">
|
|
||||||
${offer.price.toFixed(2)}
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{!offersLoading &&
|
|
||||||
!offersError &&
|
|
||||||
offers.length === 0 && (
|
|
||||||
<p className="text-xs text-zinc-500">
|
|
||||||
No live retailer offers yet. As feeds are imported,
|
|
||||||
merchants and pricing will show up here.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Link
|
|
||||||
href={`/builder?select=${categoryId}:${product.id}`}
|
|
||||||
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>
|
|
||||||
|
|
||||||
{/* Lower content: builder-focused helper copy (placeholder) */}
|
|
||||||
<section className="mt-10 grid gap-6 md:grid-cols-3 text-sm md:grid-cols-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<h2 className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
|
|
||||||
Why We Like This Part
|
|
||||||
</h2>
|
|
||||||
<p className="text-zinc-400">
|
|
||||||
Drop in a short blurb here about what makes this part
|
|
||||||
worth picking over similar options — think reliability,
|
|
||||||
track record, and value for the money.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<h2 className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
|
|
||||||
Best For
|
|
||||||
</h2>
|
|
||||||
<p className="text-zinc-400">
|
|
||||||
Use this block to call out ideal use cases:{" "}
|
|
||||||
<span className="text-zinc-200">
|
|
||||||
duty rifle, home defense, range toy, competition, night
|
|
||||||
work
|
|
||||||
</span>
|
|
||||||
, etc.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<h2 className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
|
|
||||||
Builder Notes
|
|
||||||
</h2>
|
|
||||||
<p className="text-zinc-400">
|
|
||||||
Add any compatibility quirks or install tips here once
|
|
||||||
the compatibility engine is wired up — gas system length,
|
|
||||||
buffer recommendations, known fitment notes, and more.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<h2 className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
|
|
||||||
Compatibility
|
|
||||||
</h2>
|
|
||||||
<div className="inline-flex items-center gap-2 rounded-md border border-amber-400/30 bg-amber-400/10 px-2 py-1 text-[11px] text-amber-300">
|
|
||||||
<span className="inline-block h-2 w-2 rounded-full bg-amber-300 animate-pulse"></span>
|
|
||||||
Compatibility engine coming online soon
|
|
||||||
</div>
|
|
||||||
<p className="text-zinc-500 text-xs">
|
|
||||||
Fitment checks, caliber validation, buffer/gas system logic, and platform rules will appear here once the engine is active.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,26 +1,6 @@
|
|||||||
// app/(builder)/layout.tsx
|
// app/(builder)/builder/layout.tsx
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
import { AuthProvider } from "@/context/AuthContext";
|
|
||||||
import { BuilderNav } from "@/components/BuilderNav";
|
|
||||||
|
|
||||||
export const metadata = {
|
|
||||||
title: {
|
|
||||||
default: "Battl Builder",
|
|
||||||
template: "%s | Battl Builder",
|
|
||||||
},
|
|
||||||
description:
|
|
||||||
"Battl Builder — the smarter, faster, data‑driven way to build your next firearm.",
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function BuilderLayout({ children }: { children: ReactNode }) {
|
export default function BuilderLayout({ children }: { children: ReactNode }) {
|
||||||
return (
|
return <>{children}</>;
|
||||||
<div className="min-h-screen bg-neutral-950 text-zinc-50">
|
}
|
||||||
<AuthProvider>
|
|
||||||
<BuilderNav />
|
|
||||||
<main className="min-h-screen">{children}</main>
|
|
||||||
</AuthProvider>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
+389
-173
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo, useState, useEffect } from "react";
|
import { useMemo, useState, useEffect, useCallback, useRef } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useSearchParams, useRouter } from "next/navigation";
|
import { useSearchParams, useRouter } from "next/navigation";
|
||||||
import { CATEGORIES } from "@/data/gunbuilderParts";
|
import { CATEGORIES } from "@/data/gunbuilderParts";
|
||||||
@@ -16,7 +16,7 @@ type GunbuilderProductFromApi = {
|
|||||||
platform: string;
|
platform: string;
|
||||||
partRole: string;
|
partRole: string;
|
||||||
price: number | null;
|
price: number | null;
|
||||||
mainImageUrl: string | null;
|
imageUrl: string | null;
|
||||||
buyUrl: string | null;
|
buyUrl: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -56,7 +56,7 @@ const CATEGORY_GROUPS: {
|
|||||||
description:
|
description:
|
||||||
"Everything from the serialized lower to small parts, fire control, and core controls.",
|
"Everything from the serialized lower to small parts, fire control, and core controls.",
|
||||||
categoryIds: [
|
categoryIds: [
|
||||||
"lower",
|
"lower-receiver",
|
||||||
"complete-lower",
|
"complete-lower",
|
||||||
"lower-parts",
|
"lower-parts",
|
||||||
"trigger",
|
"trigger",
|
||||||
@@ -72,7 +72,7 @@ const CATEGORY_GROUPS: {
|
|||||||
description:
|
description:
|
||||||
"Barrel, upper, gas system, and the parts that keep the rifle cycling.",
|
"Barrel, upper, gas system, and the parts that keep the rifle cycling.",
|
||||||
categoryIds: [
|
categoryIds: [
|
||||||
"upper",
|
"upper-receiver",
|
||||||
"complete-upper",
|
"complete-upper",
|
||||||
"bcg",
|
"bcg",
|
||||||
"barrel",
|
"barrel",
|
||||||
@@ -103,12 +103,41 @@ const CATEGORY_GROUPS: {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// ===== Build rules: complete assemblies make sub-parts unnecessary =====
|
||||||
|
const COMPLETE_UPPER_CATEGORY: CategoryId = "complete-upper";
|
||||||
|
const COMPLETE_LOWER_CATEGORY: CategoryId = "complete-lower";
|
||||||
|
|
||||||
|
// If a complete upper is selected, these categories are considered "included".
|
||||||
|
const UPPER_INCLUDED_CATEGORIES = new Set<CategoryId>([
|
||||||
|
"upper-receiver",
|
||||||
|
"bcg",
|
||||||
|
"barrel",
|
||||||
|
"gas-block",
|
||||||
|
"gas-tube",
|
||||||
|
"muzzle-device",
|
||||||
|
"suppressor",
|
||||||
|
"handguard",
|
||||||
|
"charging-handle",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const LOWER_INCLUDED_CATEGORIES = new Set<CategoryId>([
|
||||||
|
"lower-receiver",
|
||||||
|
"lower-parts",
|
||||||
|
"trigger",
|
||||||
|
"grip",
|
||||||
|
"safety",
|
||||||
|
"buffer",
|
||||||
|
"stock",
|
||||||
|
]);
|
||||||
|
|
||||||
export default function GunbuilderPage() {
|
export default function GunbuilderPage() {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const [parts, setParts] = useState<Part[]>([]);
|
const [parts, setParts] = useState<Part[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const isValidPlatform = (
|
const isValidPlatform = (
|
||||||
value: string | null
|
value: string | null
|
||||||
): value is (typeof PLATFORMS)[number] =>
|
): value is (typeof PLATFORMS)[number] =>
|
||||||
@@ -119,6 +148,7 @@ export default function GunbuilderPage() {
|
|||||||
const initial = new URLSearchParams(window.location.search).get("platform");
|
const initial = new URLSearchParams(window.location.search).get("platform");
|
||||||
return isValidPlatform(initial) ? initial : "AR-15";
|
return isValidPlatform(initial) ? initial : "AR-15";
|
||||||
});
|
});
|
||||||
|
|
||||||
const [build, setBuild] = useState<BuildState>(() => {
|
const [build, setBuild] = useState<BuildState>(() => {
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
const stored = localStorage.getItem(STORAGE_KEY);
|
const stored = localStorage.getItem(STORAGE_KEY);
|
||||||
@@ -132,9 +162,245 @@ export default function GunbuilderPage() {
|
|||||||
}
|
}
|
||||||
return {};
|
return {};
|
||||||
});
|
});
|
||||||
|
|
||||||
const [shareStatus, setShareStatus] = useState<string | null>(null);
|
const [shareStatus, setShareStatus] = useState<string | null>(null);
|
||||||
const [shareUrl, setShareUrl] = useState<string>("");
|
const [shareUrl, setShareUrl] = useState<string>("");
|
||||||
|
|
||||||
|
// Guards so “platform change clears build” does NOT run on initial hydration / URL sync.
|
||||||
|
const didHydrateRef = useRef(false);
|
||||||
|
const lastPlatformRef = useRef(platform);
|
||||||
|
|
||||||
|
// ✅ Guard to prevent infinite loops when processing ?select / ?remove
|
||||||
|
const processedActionKeyRef = useRef<string>("");
|
||||||
|
|
||||||
|
// ---------- Derived ----------
|
||||||
|
const partsByCategory: Record<CategoryId, Part[]> = useMemo(() => {
|
||||||
|
const grouped = {} as Record<CategoryId, Part[]>;
|
||||||
|
for (const category of CATEGORIES) {
|
||||||
|
grouped[category.id] = parts.filter((p) => p.categoryId === category.id);
|
||||||
|
}
|
||||||
|
return grouped;
|
||||||
|
}, [parts]);
|
||||||
|
|
||||||
|
const selectedParts: Part[] = useMemo(() => {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const resolved = Object.values(build)
|
||||||
|
.map((partId) => (partId ? parts.find((p) => p.id === partId) : undefined))
|
||||||
|
.filter(Boolean) as Part[];
|
||||||
|
|
||||||
|
return resolved.filter((p) => {
|
||||||
|
if (seen.has(p.id)) return false;
|
||||||
|
seen.add(p.id);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [build, parts]);
|
||||||
|
|
||||||
|
// Role -> Category resolver
|
||||||
|
// NOTE: defensive while backend roles/mappings evolve
|
||||||
|
const resolveCategoryId = (normalizedRole: string): CategoryId | null => {
|
||||||
|
if (normalizedRole === "upper-receiver") return "upper-receiver";
|
||||||
|
if (normalizedRole.includes("complete-upper")) return "complete-upper";
|
||||||
|
|
||||||
|
if (normalizedRole === "lower-receiver") return "lower-receiver";
|
||||||
|
if (normalizedRole.includes("complete-lower")) return "complete-lower";
|
||||||
|
|
||||||
|
if (normalizedRole === "upper") return "upper-receiver";
|
||||||
|
if (normalizedRole === "lower") return "lower-receiver";
|
||||||
|
|
||||||
|
if (normalizedRole.includes("charging")) return "charging-handle";
|
||||||
|
|
||||||
|
if (
|
||||||
|
normalizedRole.includes("handguard") ||
|
||||||
|
(normalizedRole.includes("rail") &&
|
||||||
|
!normalizedRole.includes("rail-accessory"))
|
||||||
|
)
|
||||||
|
return "handguard";
|
||||||
|
|
||||||
|
if (
|
||||||
|
normalizedRole.includes("bcg") ||
|
||||||
|
normalizedRole.includes("bolt-carrier")
|
||||||
|
)
|
||||||
|
return "bcg";
|
||||||
|
|
||||||
|
if (normalizedRole.includes("barrel")) return "barrel";
|
||||||
|
|
||||||
|
if (
|
||||||
|
normalizedRole.includes("gas-block") ||
|
||||||
|
normalizedRole.includes("gasblock")
|
||||||
|
)
|
||||||
|
return "gas-block";
|
||||||
|
|
||||||
|
if (
|
||||||
|
normalizedRole.includes("gas-tube") ||
|
||||||
|
normalizedRole.includes("gastube")
|
||||||
|
)
|
||||||
|
return "gas-tube";
|
||||||
|
|
||||||
|
if (
|
||||||
|
normalizedRole.includes("muzzle") ||
|
||||||
|
normalizedRole.includes("flash") ||
|
||||||
|
normalizedRole.includes("brake") ||
|
||||||
|
normalizedRole.includes("comp")
|
||||||
|
)
|
||||||
|
return "muzzle-device";
|
||||||
|
|
||||||
|
if (normalizedRole.includes("suppress")) return "suppressor";
|
||||||
|
|
||||||
|
if (
|
||||||
|
normalizedRole.includes("lower-parts") ||
|
||||||
|
normalizedRole.includes("lpk")
|
||||||
|
)
|
||||||
|
return "lower-parts";
|
||||||
|
|
||||||
|
if (normalizedRole.includes("trigger")) return "trigger";
|
||||||
|
if (normalizedRole.includes("grip")) return "grip";
|
||||||
|
if (normalizedRole.includes("safety")) return "safety";
|
||||||
|
if (normalizedRole.includes("buffer")) return "buffer";
|
||||||
|
if (normalizedRole.includes("stock")) return "stock";
|
||||||
|
|
||||||
|
if (normalizedRole.includes("optic") || normalizedRole.includes("scope"))
|
||||||
|
return "optic";
|
||||||
|
if (normalizedRole.includes("sight")) return "sights";
|
||||||
|
|
||||||
|
if (normalizedRole.includes("mag")) return "magazine";
|
||||||
|
if (
|
||||||
|
normalizedRole.includes("weapon-light") ||
|
||||||
|
(normalizedRole.includes("light") && !normalizedRole.includes("flight"))
|
||||||
|
)
|
||||||
|
return "weapon-light";
|
||||||
|
|
||||||
|
if (
|
||||||
|
normalizedRole.includes("foregrip") ||
|
||||||
|
normalizedRole.includes("grip-vertical")
|
||||||
|
)
|
||||||
|
return "foregrip";
|
||||||
|
|
||||||
|
if (normalizedRole.includes("bipod")) return "bipod";
|
||||||
|
if (normalizedRole.includes("sling")) return "sling";
|
||||||
|
|
||||||
|
if (
|
||||||
|
normalizedRole.includes("rail-accessory") ||
|
||||||
|
normalizedRole.includes("rail-attachment")
|
||||||
|
)
|
||||||
|
return "rail-accessory";
|
||||||
|
|
||||||
|
if (normalizedRole.includes("tool")) return "tools";
|
||||||
|
|
||||||
|
return (PART_ROLE_TO_CATEGORY as any)[normalizedRole] ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedByCategory: Record<CategoryId, unknown> = useMemo(
|
||||||
|
() =>
|
||||||
|
Object.fromEntries(
|
||||||
|
Object.entries(build).map(([categoryId, partId]) => [
|
||||||
|
categoryId as CategoryId,
|
||||||
|
partId ? true : false,
|
||||||
|
])
|
||||||
|
) as Record<CategoryId, unknown>,
|
||||||
|
[build]
|
||||||
|
);
|
||||||
|
|
||||||
|
const totalPrice = useMemo(
|
||||||
|
() => selectedParts.reduce((sum, p) => sum + p.price, 0),
|
||||||
|
[selectedParts]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---------- Selection logic ----------
|
||||||
|
const handleSelectPart = useCallback(
|
||||||
|
(
|
||||||
|
categoryId: CategoryId,
|
||||||
|
partId: string,
|
||||||
|
opts?: { confirm?: boolean }
|
||||||
|
) => {
|
||||||
|
const shouldConfirm = opts?.confirm !== false;
|
||||||
|
|
||||||
|
if (shouldConfirm && typeof window !== "undefined") {
|
||||||
|
if (categoryId === COMPLETE_UPPER_CATEGORY) {
|
||||||
|
const toClear = Array.from(UPPER_INCLUDED_CATEGORIES).filter(
|
||||||
|
(cid) => !!build[cid]
|
||||||
|
);
|
||||||
|
if (toClear.length > 0) {
|
||||||
|
const labels = toClear
|
||||||
|
.map((cid) => CATEGORIES.find((c) => c.id === cid)?.name ?? cid)
|
||||||
|
.join(", ");
|
||||||
|
|
||||||
|
const ok = window.confirm(
|
||||||
|
`Selecting a Complete Upper will remove your currently selected upper parts:\n\n${labels}\n\nContinue?`
|
||||||
|
);
|
||||||
|
if (!ok) return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (categoryId === COMPLETE_LOWER_CATEGORY) {
|
||||||
|
const toClear = Array.from(LOWER_INCLUDED_CATEGORIES).filter(
|
||||||
|
(cid) => !!build[cid]
|
||||||
|
);
|
||||||
|
if (toClear.length > 0) {
|
||||||
|
const labels = toClear
|
||||||
|
.map((cid) => CATEGORIES.find((c) => c.id === cid)?.name ?? cid)
|
||||||
|
.join(", ");
|
||||||
|
|
||||||
|
const ok = window.confirm(
|
||||||
|
`Selecting a Complete Lower will remove your currently selected lower parts:\n\n${labels}\n\nContinue?`
|
||||||
|
);
|
||||||
|
if (!ok) return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
UPPER_INCLUDED_CATEGORIES.has(categoryId) &&
|
||||||
|
!!build[COMPLETE_UPPER_CATEGORY]
|
||||||
|
) {
|
||||||
|
const ok = window.confirm(
|
||||||
|
`Selecting this part will remove your selected Complete Upper. Continue?`
|
||||||
|
);
|
||||||
|
if (!ok) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
LOWER_INCLUDED_CATEGORIES.has(categoryId) &&
|
||||||
|
!!build[COMPLETE_LOWER_CATEGORY]
|
||||||
|
) {
|
||||||
|
const ok = window.confirm(
|
||||||
|
`Selecting this part will remove your selected Complete Lower. Continue?`
|
||||||
|
);
|
||||||
|
if (!ok) return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setBuild((prev) => {
|
||||||
|
const updated: BuildState = {
|
||||||
|
...prev,
|
||||||
|
[categoryId]: partId,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (categoryId === COMPLETE_UPPER_CATEGORY) {
|
||||||
|
for (const cid of UPPER_INCLUDED_CATEGORIES) {
|
||||||
|
delete updated[cid];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (UPPER_INCLUDED_CATEGORIES.has(categoryId)) {
|
||||||
|
delete updated[COMPLETE_UPPER_CATEGORY];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (categoryId === COMPLETE_LOWER_CATEGORY) {
|
||||||
|
for (const cid of LOWER_INCLUDED_CATEGORIES) {
|
||||||
|
delete updated[cid];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (LOWER_INCLUDED_CATEGORIES.has(categoryId)) {
|
||||||
|
delete updated[COMPLETE_LOWER_CATEGORY];
|
||||||
|
}
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[build]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---------- Fetch products ----------
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
|
|
||||||
@@ -143,14 +409,10 @@ export default function GunbuilderPage() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
// 1) Platform-scoped products
|
|
||||||
const scopedUrl = `${API_BASE_URL}/api/products?platform=${encodeURIComponent(
|
const scopedUrl = `${API_BASE_URL}/api/products?platform=${encodeURIComponent(
|
||||||
platform
|
platform
|
||||||
)}`;
|
)}`;
|
||||||
|
|
||||||
// 2) Universal products (no platform filter)
|
|
||||||
// NOTE: This assumes your backend supports /api/products with no platform param.
|
|
||||||
// If it doesn't yet, this call will fail quietly and you'll still see scoped results.
|
|
||||||
const universalUrl = `${API_BASE_URL}/api/products`;
|
const universalUrl = `${API_BASE_URL}/api/products`;
|
||||||
|
|
||||||
const [scopedRes, universalRes] = await Promise.all([
|
const [scopedRes, universalRes] = await Promise.all([
|
||||||
@@ -172,7 +434,6 @@ export default function GunbuilderPage() {
|
|||||||
universalData = await universalRes.json();
|
universalData = await universalRes.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Normalize both lists
|
|
||||||
const normalize = (data: GunbuilderProductFromApi[]): Part[] =>
|
const normalize = (data: GunbuilderProductFromApi[]): Part[] =>
|
||||||
data
|
data
|
||||||
.map((p): Part | null => {
|
.map((p): Part | null => {
|
||||||
@@ -181,11 +442,9 @@ export default function GunbuilderPage() {
|
|||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.replace(/_/g, "-");
|
.replace(/_/g, "-");
|
||||||
|
|
||||||
const categoryId = PART_ROLE_TO_CATEGORY[normalizedRole];
|
const categoryId = resolveCategoryId(normalizedRole);
|
||||||
if (!categoryId) return null;
|
if (!categoryId) return null;
|
||||||
|
|
||||||
// Only keep truly-universal categories from the universal feed
|
|
||||||
// so we don't accidentally mix platforms for platform-scoped parts.
|
|
||||||
if (
|
if (
|
||||||
data === universalData &&
|
data === universalData &&
|
||||||
!UNIVERSAL_CATEGORIES.has(categoryId)
|
!UNIVERSAL_CATEGORIES.has(categoryId)
|
||||||
@@ -194,13 +453,15 @@ export default function GunbuilderPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const buyUrl = p.buyUrl ?? undefined;
|
const buyUrl = p.buyUrl ?? undefined;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: String(p.id),
|
id: String(p.id),
|
||||||
categoryId,
|
categoryId,
|
||||||
name: p.name,
|
name: p.name,
|
||||||
brand: p.brand,
|
brand: p.brand,
|
||||||
price: p.price ?? 0,
|
price: p.price ?? 0,
|
||||||
imageUrl: p.mainImageUrl ?? undefined,
|
imageUrl:
|
||||||
|
((p as any).imageUrl ?? (p as any).mainImageUrl) ?? undefined,
|
||||||
affiliateUrl: buyUrl,
|
affiliateUrl: buyUrl,
|
||||||
url: buyUrl,
|
url: buyUrl,
|
||||||
notes: undefined,
|
notes: undefined,
|
||||||
@@ -211,7 +472,6 @@ export default function GunbuilderPage() {
|
|||||||
const scopedParts = normalize(scopedData);
|
const scopedParts = normalize(scopedData);
|
||||||
const universalParts = normalize(universalData);
|
const universalParts = normalize(universalData);
|
||||||
|
|
||||||
// Merge + de-dupe by (categoryId + id)
|
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const merged: Part[] = [];
|
const merged: Part[] = [];
|
||||||
for (const p of [...scopedParts, ...universalParts]) {
|
for (const p of [...scopedParts, ...universalParts]) {
|
||||||
@@ -231,16 +491,34 @@ export default function GunbuilderPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fetchProducts();
|
fetchProducts();
|
||||||
|
|
||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}, [platform]);
|
}, [platform]);
|
||||||
|
|
||||||
|
// ✅ Persist build state whenever it changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// When the platform changes, clear ONLY platform-scoped selections.
|
if (typeof window === "undefined") return;
|
||||||
// Keep universal accessories (optic/light/sling/etc) so the user doesn't lose them.
|
try {
|
||||||
setBuild((prev) => {
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(build));
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}, [build]);
|
||||||
|
|
||||||
|
// When the platform changes, clear ONLY platform-scoped selections.
|
||||||
|
useEffect(() => {
|
||||||
|
const prev = lastPlatformRef.current;
|
||||||
|
lastPlatformRef.current = platform;
|
||||||
|
|
||||||
|
if (!didHydrateRef.current) {
|
||||||
|
didHydrateRef.current = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prev === platform) return;
|
||||||
|
|
||||||
|
setBuild((prevBuild) => {
|
||||||
const next: BuildState = {};
|
const next: BuildState = {};
|
||||||
for (const [categoryId, partId] of Object.entries(prev)) {
|
for (const [categoryId, partId] of Object.entries(prevBuild)) {
|
||||||
const cid = categoryId as CategoryId;
|
const cid = categoryId as CategoryId;
|
||||||
if (UNIVERSAL_CATEGORIES.has(cid)) {
|
if (UNIVERSAL_CATEGORIES.has(cid)) {
|
||||||
next[cid] = partId;
|
next[cid] = partId;
|
||||||
@@ -250,36 +528,64 @@ export default function GunbuilderPage() {
|
|||||||
});
|
});
|
||||||
}, [platform]);
|
}, [platform]);
|
||||||
|
|
||||||
// Handle URL query parameter for part selection (?select=upper:165)
|
// Handle URL query parameters:
|
||||||
|
// - ?select=categoryId:partId
|
||||||
|
// - ?remove=categoryId
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const selectParam = searchParams.get("select");
|
const selectParam = searchParams.get("select");
|
||||||
|
const removeParam = searchParams.get("remove");
|
||||||
|
const qpPlatform = searchParams.get("platform");
|
||||||
|
|
||||||
|
// Build a unique key for this action so we only apply it once.
|
||||||
|
const actionKey = `${selectParam ?? ""}|${removeParam ?? ""}|${
|
||||||
|
qpPlatform ?? ""
|
||||||
|
}`;
|
||||||
|
|
||||||
|
// If no action, clear the ref and do nothing.
|
||||||
|
if (!selectParam && !removeParam) {
|
||||||
|
processedActionKeyRef.current = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent infinite loops: if we already processed this exact actionKey, stop.
|
||||||
|
if (processedActionKeyRef.current === actionKey) return;
|
||||||
|
processedActionKeyRef.current = actionKey;
|
||||||
|
|
||||||
|
// Apply actions
|
||||||
if (selectParam) {
|
if (selectParam) {
|
||||||
const [categoryId, partId] = selectParam.split(":");
|
const [categoryIdRaw, partId] = selectParam.split(":");
|
||||||
if (categoryId && partId && CATEGORIES.some((c) => c.id === categoryId)) {
|
const requestedCategoryId = categoryIdRaw as CategoryId;
|
||||||
setBuild((prev) => {
|
|
||||||
const updated = {
|
|
||||||
...prev,
|
|
||||||
[categoryId as CategoryId]: partId,
|
|
||||||
};
|
|
||||||
if (typeof window !== "undefined") {
|
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
|
|
||||||
}
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
|
|
||||||
const qp = searchParams.get("platform");
|
if (requestedCategoryId && partId) {
|
||||||
const nextPlatform = isValidPlatform(qp) ? qp : platform;
|
handleSelectPart(requestedCategoryId, partId, { confirm: false });
|
||||||
|
|
||||||
router.replace(
|
|
||||||
`/builder?platform=${encodeURIComponent(nextPlatform)}`,
|
|
||||||
{
|
|
||||||
scroll: false,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [searchParams, router]);
|
|
||||||
|
|
||||||
|
if (removeParam) {
|
||||||
|
if (CATEGORIES.some((c) => c.id === removeParam)) {
|
||||||
|
setBuild((prev) => {
|
||||||
|
const next: BuildState = { ...prev };
|
||||||
|
delete next[removeParam as CategoryId];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextPlatform = isValidPlatform(qpPlatform) ? qpPlatform : platform;
|
||||||
|
|
||||||
|
// Clean URL (remove select/remove) but ONLY if needed.
|
||||||
|
const nextUrl = `/builder?platform=${encodeURIComponent(nextPlatform)}`;
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
const current = `${window.location.pathname}${window.location.search}`;
|
||||||
|
if (current !== nextUrl) {
|
||||||
|
router.replace(nextUrl, { scroll: false });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
router.replace(nextUrl, { scroll: false });
|
||||||
|
}
|
||||||
|
}, [searchParams, router, platform, handleSelectPart]);
|
||||||
|
|
||||||
|
// Keep platform in sync w/ URL
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const qp = searchParams.get("platform");
|
const qp = searchParams.get("platform");
|
||||||
if (isValidPlatform(qp) && qp !== platform) {
|
if (isValidPlatform(qp) && qp !== platform) {
|
||||||
@@ -287,49 +593,10 @@ export default function GunbuilderPage() {
|
|||||||
}
|
}
|
||||||
}, [searchParams, platform]);
|
}, [searchParams, platform]);
|
||||||
|
|
||||||
// Persist build state to localStorage whenever it changes
|
// Build share URL whenever build changes
|
||||||
useEffect(() => {
|
|
||||||
if (typeof window !== "undefined") {
|
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(build));
|
|
||||||
}
|
|
||||||
}, [build]);
|
|
||||||
|
|
||||||
const partsByCategory: Record<CategoryId, Part[]> = useMemo(() => {
|
|
||||||
const grouped = {} as Record<CategoryId, Part[]>;
|
|
||||||
for (const category of CATEGORIES) {
|
|
||||||
grouped[category.id] = parts.filter((p) => p.categoryId === category.id);
|
|
||||||
}
|
|
||||||
return grouped;
|
|
||||||
}, [parts]);
|
|
||||||
|
|
||||||
const selectedParts: Part[] = useMemo(() => {
|
|
||||||
return Object.entries(build)
|
|
||||||
.map(([categoryId, partId]) =>
|
|
||||||
parts.find((p) => p.id === partId && p.categoryId === categoryId)
|
|
||||||
)
|
|
||||||
.filter(Boolean) as Part[];
|
|
||||||
}, [build, parts]);
|
|
||||||
|
|
||||||
const selectedByCategory: Record<CategoryId, unknown> = useMemo(
|
|
||||||
() =>
|
|
||||||
Object.fromEntries(
|
|
||||||
Object.entries(build).map(([categoryId, partId]) => [
|
|
||||||
categoryId as CategoryId,
|
|
||||||
partId ? true : false,
|
|
||||||
])
|
|
||||||
) as Record<CategoryId, unknown>,
|
|
||||||
[build]
|
|
||||||
);
|
|
||||||
|
|
||||||
const totalPrice = useMemo(
|
|
||||||
() => selectedParts.reduce((sum, p) => sum + p.price, 0),
|
|
||||||
[selectedParts]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
|
|
||||||
// If no parts selected, clear the share URL
|
|
||||||
if (Object.keys(build).length === 0) {
|
if (Object.keys(build).length === 0) {
|
||||||
setShareUrl("");
|
setShareUrl("");
|
||||||
return;
|
return;
|
||||||
@@ -339,28 +606,13 @@ export default function GunbuilderPage() {
|
|||||||
const payload = JSON.stringify(build);
|
const payload = JSON.stringify(build);
|
||||||
const encoded = window.btoa(payload);
|
const encoded = window.btoa(payload);
|
||||||
const origin = window.location?.origin ?? "";
|
const origin = window.location?.origin ?? "";
|
||||||
const url = `${origin}/builder/build?build=${encodeURIComponent(
|
const url = `${origin}/builder/build?build=${encodeURIComponent(encoded)}`;
|
||||||
encoded
|
|
||||||
)}`;
|
|
||||||
setShareUrl(url);
|
setShareUrl(url);
|
||||||
} catch {
|
} catch {
|
||||||
setShareUrl("");
|
setShareUrl("");
|
||||||
}
|
}
|
||||||
}, [build]);
|
}, [build]);
|
||||||
|
|
||||||
const handleSelectPart = (categoryId: CategoryId, partId: string) => {
|
|
||||||
setBuild((prev) => {
|
|
||||||
const updated = {
|
|
||||||
...prev,
|
|
||||||
[categoryId]: partId,
|
|
||||||
};
|
|
||||||
if (typeof window !== "undefined") {
|
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
|
|
||||||
}
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Use group ordering for the summary as well
|
// Use group ordering for the summary as well
|
||||||
const summaryCategoryOrder: CategoryId[] = useMemo(
|
const summaryCategoryOrder: CategoryId[] = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -443,13 +695,11 @@ export default function GunbuilderPage() {
|
|||||||
});
|
});
|
||||||
setShareStatus("Share dialog opened.");
|
setShareStatus("Share dialog opened.");
|
||||||
} catch {
|
} catch {
|
||||||
// User canceled or share failed; keep it quiet but let them know
|
|
||||||
setShareStatus(
|
setShareStatus(
|
||||||
"Share canceled or unavailable. You can copy the link instead."
|
"Share canceled or unavailable. You can copy the link instead."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Fallback to copying the link
|
|
||||||
await handleCopyLink();
|
await handleCopyLink();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -482,10 +732,10 @@ export default function GunbuilderPage() {
|
|||||||
const next = e.target.value as (typeof PLATFORMS)[number];
|
const next = e.target.value as (typeof PLATFORMS)[number];
|
||||||
setPlatform(next);
|
setPlatform(next);
|
||||||
|
|
||||||
// Keep URL in sync so navigation + refresh preserve platform
|
|
||||||
const qp = new URLSearchParams(searchParams.toString());
|
const qp = new URLSearchParams(searchParams.toString());
|
||||||
qp.set("platform", next);
|
qp.set("platform", next);
|
||||||
qp.delete("select");
|
qp.delete("select");
|
||||||
|
qp.delete("remove");
|
||||||
|
|
||||||
router.replace(`/builder?${qp.toString()}`, {
|
router.replace(`/builder?${qp.toString()}`, {
|
||||||
scroll: false,
|
scroll: false,
|
||||||
@@ -509,7 +759,7 @@ export default function GunbuilderPage() {
|
|||||||
|
|
||||||
{/* Build summary panel */}
|
{/* Build summary panel */}
|
||||||
<section className="mb-6 rounded-lg border border-zinc-800 bg-zinc-950/80 p-3 md:p-4 flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
<section className="mb-6 rounded-lg border border-zinc-800 bg-zinc-950/80 p-3 md:p-4 flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||||
{/* Left: title + totals + primary actions */}
|
{/* Left */}
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<h2 className="text-xs font-semibold uppercase tracking-[0.16em] text-amber-300">
|
<h2 className="text-xs font-semibold uppercase tracking-[0.16em] text-amber-300">
|
||||||
My Build Breakdown
|
My Build Breakdown
|
||||||
@@ -524,10 +774,12 @@ export default function GunbuilderPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Primary actions now live under the total */}
|
|
||||||
<div className="mt-2 flex flex-col items-stretch gap-2 sm:flex-row sm:flex-wrap">
|
<div className="mt-2 flex flex-col items-stretch gap-2 sm:flex-row sm:flex-wrap">
|
||||||
<Link
|
<Link
|
||||||
href="/builder/build"
|
href={{
|
||||||
|
pathname: "/builder/build",
|
||||||
|
query: platform ? { platform } : {},
|
||||||
|
}}
|
||||||
className={`w-full sm:w-auto rounded-md border border-amber-400/60 bg-amber-400/10 px-3 py-2 text-sm font-medium text-amber-200 hover:bg-amber-400/15 text-center transition-colors ${
|
className={`w-full sm:w-auto rounded-md border border-amber-400/60 bg-amber-400/10 px-3 py-2 text-sm font-medium text-amber-200 hover:bg-amber-400/15 text-center transition-colors ${
|
||||||
selectedParts.length === 0
|
selectedParts.length === 0
|
||||||
? "opacity-40 cursor-not-allowed pointer-events-none"
|
? "opacity-40 cursor-not-allowed pointer-events-none"
|
||||||
@@ -536,6 +788,7 @@ export default function GunbuilderPage() {
|
|||||||
>
|
>
|
||||||
Build Summary
|
Build Summary
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleShare}
|
onClick={handleShare}
|
||||||
@@ -548,6 +801,7 @@ export default function GunbuilderPage() {
|
|||||||
>
|
>
|
||||||
Copy Build Summary
|
Copy Build Summary
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -558,9 +812,7 @@ export default function GunbuilderPage() {
|
|||||||
}}
|
}}
|
||||||
disabled={selectedParts.length === 0}
|
disabled={selectedParts.length === 0}
|
||||||
className={`w-full sm:w-auto rounded-md border border-zinc-700 bg-zinc-900/50 px-3 py-2 text-sm font-medium text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600 transition-colors ${
|
className={`w-full sm:w-auto rounded-md border border-zinc-700 bg-zinc-900/50 px-3 py-2 text-sm font-medium text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600 transition-colors ${
|
||||||
selectedParts.length === 0
|
selectedParts.length === 0 ? "opacity-40 cursor-not-allowed" : ""
|
||||||
? "opacity-40 cursor-not-allowed"
|
|
||||||
: ""
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Clear Build
|
Clear Build
|
||||||
@@ -568,7 +820,7 @@ export default function GunbuilderPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right: Share Your Build */}
|
{/* Right */}
|
||||||
<div className="flex flex-col gap-3 md:items-end md:text-right md:flex-1">
|
<div className="flex flex-col gap-3 md:items-end md:text-right md:flex-1">
|
||||||
{selectedParts.length > 0 && shareUrl && (
|
{selectedParts.length > 0 && shareUrl && (
|
||||||
<div className="w-full md:w-auto">
|
<div className="w-full md:w-auto">
|
||||||
@@ -576,8 +828,7 @@ export default function GunbuilderPage() {
|
|||||||
Share Your Build
|
Share Your Build
|
||||||
</h3>
|
</h3>
|
||||||
<p className="mt-1 text-[0.7rem] text-zinc-500">
|
<p className="mt-1 text-[0.7rem] text-zinc-500">
|
||||||
Share this link to let others view your build or bookmark it
|
Share this link to let others view your build or bookmark it to come back later.
|
||||||
to come back later.
|
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-2 flex flex-col gap-2 md:flex-row md:items-center">
|
<div className="mt-2 flex flex-col gap-2 md:flex-row md:items-center">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
@@ -652,9 +903,8 @@ export default function GunbuilderPage() {
|
|||||||
{/* Layout */}
|
{/* Layout */}
|
||||||
<section className="rounded-lg border border-zinc-800 bg-zinc-950/60 p-3 md:p-4">
|
<section className="rounded-lg border border-zinc-800 bg-zinc-950/60 p-3 md:p-4">
|
||||||
<p className="mb-4 text-xs text-zinc-500">
|
<p className="mb-4 text-xs text-zinc-500">
|
||||||
Work top-down through the major sections. Each row shows your
|
Work top-down through the major sections. Each row shows your current pick for that part type,
|
||||||
current pick for that part type, with price and a direct buy link—or
|
with price and a direct buy link—or a quick way to choose a part if you haven't picked one yet.
|
||||||
a quick way to choose a part if you haven't picked one yet.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{loading && (
|
{loading && (
|
||||||
@@ -662,8 +912,7 @@ export default function GunbuilderPage() {
|
|||||||
)}
|
)}
|
||||||
{error && !loading && (
|
{error && !loading && (
|
||||||
<p className="text-sm text-red-400">
|
<p className="text-sm text-red-400">
|
||||||
{error} — check that the Ballistic API is running and CORS is
|
{error} — check that the Ballistic API is running and CORS is configured.
|
||||||
configured.
|
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -674,9 +923,7 @@ export default function GunbuilderPage() {
|
|||||||
group.categoryIds.includes(c.id as CategoryId)
|
group.categoryIds.includes(c.id as CategoryId)
|
||||||
);
|
);
|
||||||
|
|
||||||
if (groupCategories.length === 0) {
|
if (groupCategories.length === 0) return null;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={group.id} className="space-y-3">
|
<div key={group.id} className="space-y-3">
|
||||||
@@ -717,14 +964,17 @@ export default function GunbuilderPage() {
|
|||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|
||||||
<tbody>
|
<tbody>
|
||||||
{groupCategories.map((category) => {
|
{groupCategories.map((category) => {
|
||||||
const categoryParts =
|
const categoryParts = partsByCategory[category.id] ?? [];
|
||||||
partsByCategory[category.id] ?? [];
|
|
||||||
const selectedPartId = build[category.id];
|
const selectedPartId = build[category.id];
|
||||||
const selectedPart = categoryParts.find(
|
|
||||||
(p) => p.id === selectedPartId
|
const selectedPart = selectedPartId
|
||||||
);
|
? categoryParts.find((p) => p.id === selectedPartId) ??
|
||||||
|
parts.find((p) => p.id === selectedPartId)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const hasParts = categoryParts.length > 0;
|
const hasParts = categoryParts.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -732,7 +982,7 @@ export default function GunbuilderPage() {
|
|||||||
key={category.id}
|
key={category.id}
|
||||||
className="border-t border-zinc-900 hover:bg-zinc-900/60 transition-colors"
|
className="border-t border-zinc-900 hover:bg-zinc-900/60 transition-colors"
|
||||||
>
|
>
|
||||||
{/* Component / Part Type */}
|
{/* Component */}
|
||||||
<td className="px-3 py-2 align-top">
|
<td className="px-3 py-2 align-top">
|
||||||
<div className="font-medium text-zinc-100">
|
<div className="font-medium text-zinc-100">
|
||||||
{category.name}
|
{category.name}
|
||||||
@@ -764,31 +1014,33 @@ export default function GunbuilderPage() {
|
|||||||
: "—"}
|
: "—"}
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
{/* Sale Price (placeholder until we wire through sale/original) */}
|
{/* Sale price placeholder */}
|
||||||
<td className="px-3 py-2 align-top text-right text-zinc-400">
|
<td className="px-3 py-2 align-top text-right text-zinc-400">
|
||||||
{/* TODO: wire in sale/original prices from backend */}
|
|
||||||
{selectedPart ? "—" : "—"}
|
|
||||||
</td>
|
|
||||||
|
|
||||||
{/* Caliber (placeholder for now) */}
|
|
||||||
<td className="px-3 py-2 align-top text-zinc-400">
|
|
||||||
{/* TODO: wire in caliber from ProductSummaryDto when available */}
|
|
||||||
—
|
—
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
{/* Buy / Choose */}
|
{/* Caliber placeholder */}
|
||||||
|
<td className="px-3 py-2 align-top text-zinc-400">
|
||||||
|
—
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
<td className="px-3 py-2 align-top">
|
<td className="px-3 py-2 align-top">
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
{selectedPart && selectedPart.url ? (
|
{selectedPart && selectedPart.url ? (
|
||||||
<>
|
<>
|
||||||
<Link
|
<Link
|
||||||
href={`/builder/${category.id}`}
|
href={{
|
||||||
|
pathname: `/parts/${category.id}`,
|
||||||
|
query: platform ? { platform } : {},
|
||||||
|
}}
|
||||||
className="hidden md:inline-flex items-center justify-center rounded-md border border-zinc-700 bg-zinc-900/70 px-2 py-1 hover:bg-zinc-800 hover:border-zinc-600 transition-colors"
|
className="hidden md:inline-flex items-center justify-center rounded-md border border-zinc-700 bg-zinc-900/70 px-2 py-1 hover:bg-zinc-800 hover:border-zinc-600 transition-colors"
|
||||||
aria-label="Change part"
|
aria-label="Change part"
|
||||||
title="Change part"
|
title="Change part"
|
||||||
>
|
>
|
||||||
<Pencil className="h-3.5 w-3.5 text-zinc-300" />
|
<Pencil className="h-3.5 w-3.5 text-zinc-300" />
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<a
|
<a
|
||||||
href={selectedPart.url}
|
href={selectedPart.url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@@ -803,7 +1055,7 @@ export default function GunbuilderPage() {
|
|||||||
) : hasParts ? (
|
) : hasParts ? (
|
||||||
<Link
|
<Link
|
||||||
href={{
|
href={{
|
||||||
pathname: `/builder/${category.id}`,
|
pathname: `/parts/${category.id}`,
|
||||||
query: platform ? { platform } : {},
|
query: platform ? { platform } : {},
|
||||||
}}
|
}}
|
||||||
className="inline-flex items-center gap-1.5 whitespace-nowrap rounded-md border border-zinc-700 bg-zinc-900/70 px-3 py-1.5 text-xs font-medium text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600 transition-colors"
|
className="inline-flex items-center gap-1.5 whitespace-nowrap rounded-md border border-zinc-700 bg-zinc-900/70 px-3 py-1.5 text-xs font-medium text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600 transition-colors"
|
||||||
@@ -821,7 +1073,7 @@ export default function GunbuilderPage() {
|
|||||||
d="M12 4v16m8-8H4"
|
d="M12 4v16m8-8H4"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<span>Choose a Part</span>
|
Choose
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-[0.7rem] text-zinc-600">
|
<span className="text-[0.7rem] text-zinc-600">
|
||||||
@@ -842,43 +1094,7 @@ export default function GunbuilderPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* What's New */}
|
|
||||||
<section className="mt-8 rounded-lg border border-zinc-800 bg-zinc-950/80 p-3 md:p-4">
|
|
||||||
<h2 className="text-xs font-semibold uppercase tracking-[0.16em] text-amber-300">
|
|
||||||
What's New in Early Access
|
|
||||||
</h2>
|
|
||||||
<ul className="mt-2 space-y-1 text-sm text-zinc-400">
|
|
||||||
<li>• Live parts and pricing pulled from multiple merchants.</li>
|
|
||||||
<li>
|
|
||||||
• Grouped layout for lower and upper receiver parts so you can see
|
|
||||||
at a glance which sections of your build are still missing.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
• Running build total that updates automatically as you add or
|
|
||||||
swap components.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
• Local build persistence so your selections stick around between
|
|
||||||
visits on this device.
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Roadmap */}
|
|
||||||
<section className="mt-8 rounded-lg border border-zinc-800 bg-zinc-950/80 p-3 md:p-4">
|
|
||||||
<h2 className="text-xs font-semibold uppercase tracking-[0.16em] text-amber-300">
|
|
||||||
What's on our roadmap
|
|
||||||
</h2>
|
|
||||||
<ul className="mt-2 space-y-1 text-sm text-zinc-400">
|
|
||||||
<li>• Platform filters (AR-15, AR-9, AR-10)</li>
|
|
||||||
<li>• More part categories and furniture</li>
|
|
||||||
<li>• Richer pricing/stock sync</li>
|
|
||||||
<li>• Smarter compatibility checks between components</li>
|
|
||||||
<li>• AR9/AR10 support and more</li>
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,16 @@
|
|||||||
import { TopNav } from "@/components/TopNav";
|
// app/(builder)/layout.tsx
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
import { AuthProvider } from "@/context/AuthContext";
|
||||||
|
import { BuilderNav } from "@/components/BuilderNav";
|
||||||
|
|
||||||
export default function BuilderLayout({ children }: { children: ReactNode }) {
|
export default function BuilderLayout({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-black text-zinc-50">
|
<div className="min-h-screen bg-neutral-950 text-zinc-50">
|
||||||
<TopNav />
|
<AuthProvider>
|
||||||
{children}
|
<BuilderNav />
|
||||||
|
<main className="min-h-screen">{children}</main>
|
||||||
|
</AuthProvider>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
+85
-112
@@ -26,18 +26,18 @@ type UiPart = Part & {
|
|||||||
inStock?: boolean;
|
inStock?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const PLATFORMS = ["AR-15", "AR-10"];
|
const PLATFORMS = ["AR-15", "AR-10", "AR-9"];
|
||||||
|
|
||||||
const API_BASE_URL =
|
const API_BASE_URL =
|
||||||
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
|
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
|
||||||
|
|
||||||
|
type BuildState = Partial<Record<CategoryId, string>>; // categoryId -> partId
|
||||||
|
|
||||||
|
const STORAGE_KEY = "gunbuilder-build-state";
|
||||||
|
|
||||||
// sort options
|
// sort options
|
||||||
type SortOption = "relevance" | "price-asc" | "price-desc" | "brand-asc";
|
type SortOption = "relevance" | "price-asc" | "price-desc" | "brand-asc";
|
||||||
|
|
||||||
// build state type + storage key (matches /gunbuilder)
|
|
||||||
type BuildState = Partial<Record<CategoryId, string>>;
|
|
||||||
const STORAGE_KEY = "gunbuilder-build-state";
|
|
||||||
|
|
||||||
// support for url id-slug
|
// support for url id-slug
|
||||||
const slugify = (str: string) =>
|
const slugify = (str: string) =>
|
||||||
str
|
str
|
||||||
@@ -45,10 +45,13 @@ const slugify = (str: string) =>
|
|||||||
.replace(/[^a-z0-9]+/g, "-")
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
.replace(/(^-|-$)/g, "");
|
.replace(/(^-|-$)/g, "");
|
||||||
|
|
||||||
const normalizeRole = (role: string) => role.trim().toLowerCase().replace(/_/g, "-");
|
const normalizeRole = (role: string) =>
|
||||||
|
role.trim().toLowerCase().replace(/_/g, "-");
|
||||||
|
|
||||||
function getNormalizedPartRolesForCategory(categoryId: string): string[] {
|
function getNormalizedPartRolesForCategory(categoryId: string): string[] {
|
||||||
const roles = (CATEGORY_TO_PART_ROLES as any)[categoryId] as string[] | undefined;
|
const roles = (CATEGORY_TO_PART_ROLES as any)[categoryId] as
|
||||||
|
| string[]
|
||||||
|
| undefined;
|
||||||
if (!roles || roles.length === 0) return [];
|
if (!roles || roles.length === 0) return [];
|
||||||
return Array.from(new Set(roles.map(normalizeRole)));
|
return Array.from(new Set(roles.map(normalizeRole)));
|
||||||
}
|
}
|
||||||
@@ -83,8 +86,9 @@ export default function CategoryPage() {
|
|||||||
const [currentPage, setCurrentPage] = useState<number>(1);
|
const [currentPage, setCurrentPage] = useState<number>(1);
|
||||||
const PAGE_SIZE = 24;
|
const PAGE_SIZE = 24;
|
||||||
|
|
||||||
// build state for this page, synced with localStorage
|
// build state for this page (read-only), hydrated from localStorage
|
||||||
const [build, setBuild] = useState<BuildState>(() => {
|
// NOTE: The main /builder page is the single source of truth for writing to STORAGE_KEY.
|
||||||
|
const [build] = useState<BuildState>(() => {
|
||||||
if (typeof window === "undefined") return {};
|
if (typeof window === "undefined") return {};
|
||||||
try {
|
try {
|
||||||
const stored = window.localStorage.getItem(STORAGE_KEY);
|
const stored = window.localStorage.getItem(STORAGE_KEY);
|
||||||
@@ -97,6 +101,17 @@ export default function CategoryPage() {
|
|||||||
const initialPlatform = (searchParams.get("platform") as string) || "AR-15";
|
const initialPlatform = (searchParams.get("platform") as string) || "AR-15";
|
||||||
const [platform, setPlatform] = useState<string>(initialPlatform);
|
const [platform, setPlatform] = useState<string>(initialPlatform);
|
||||||
|
|
||||||
|
// If a user lands here without ?platform=..., normalize the URL so downstream navigation keeps platform.
|
||||||
|
useEffect(() => {
|
||||||
|
const qpPlatform = searchParams.get("platform");
|
||||||
|
if (qpPlatform) return;
|
||||||
|
|
||||||
|
const qp = new URLSearchParams(searchParams.toString());
|
||||||
|
qp.set("platform", platform || "AR-15");
|
||||||
|
router.replace(`/builder/${categoryId}?${qp.toString()}`);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [categoryId, router, searchParams]);
|
||||||
|
|
||||||
const category = useMemo(
|
const category = useMemo(
|
||||||
() => CATEGORIES.find((c) => c.id === categoryId),
|
() => CATEGORIES.find((c) => c.id === categoryId),
|
||||||
[categoryId]
|
[categoryId]
|
||||||
@@ -156,41 +171,22 @@ export default function CategoryPage() {
|
|||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}, [categoryId, platform, partRoles]);
|
}, [categoryId, platform, partRoles]);
|
||||||
|
|
||||||
// persist build to localStorage whenever it changes
|
|
||||||
useEffect(() => {
|
|
||||||
if (typeof window === "undefined") return;
|
|
||||||
try {
|
|
||||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(build));
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}, [build]);
|
|
||||||
|
|
||||||
// handler to toggle Add / Remove for this category
|
// handler to toggle Add / Remove for this category
|
||||||
const handleTogglePart = (categoryId: CategoryId, partId: string) => {
|
const handleTogglePart = (categoryId: CategoryId, partId: string) => {
|
||||||
const isSelected = build[categoryId] === partId;
|
const isSelected = build[categoryId] === partId;
|
||||||
|
|
||||||
|
const qp = new URLSearchParams();
|
||||||
|
qp.set("platform", platform || "AR-15");
|
||||||
|
|
||||||
if (isSelected) {
|
if (isSelected) {
|
||||||
// Remove from build
|
// Tell the main builder to remove this category selection
|
||||||
setBuild((prev) => {
|
qp.set("remove", String(categoryId));
|
||||||
const updated: BuildState = { ...prev };
|
router.push(`/builder?${qp.toString()}`);
|
||||||
delete updated[categoryId];
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add to build and navigate back to main builder page.
|
// Tell the main builder to add/replace this category selection
|
||||||
// Pass selection via URL so `/builder` can apply it.
|
|
||||||
setBuild((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[categoryId]: partId,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const qp = new URLSearchParams();
|
|
||||||
qp.set("platform", platform || "AR-15");
|
|
||||||
qp.set("select", `${categoryId}:${partId}`);
|
qp.set("select", `${categoryId}:${partId}`);
|
||||||
|
|
||||||
router.push(`/builder?${qp.toString()}`);
|
router.push(`/builder?${qp.toString()}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -202,6 +198,7 @@ export default function CategoryPage() {
|
|||||||
.sort((a, b) => a.localeCompare(b)),
|
.sort((a, b) => a.localeCompare(b)),
|
||||||
[parts]
|
[parts]
|
||||||
);
|
);
|
||||||
|
|
||||||
const priceBounds = useMemo(() => {
|
const priceBounds = useMemo(() => {
|
||||||
if (parts.length === 0) {
|
if (parts.length === 0) {
|
||||||
return { min: null as number | null, max: null as number | null };
|
return { min: null as number | null, max: null as number | null };
|
||||||
@@ -268,9 +265,7 @@ export default function CategoryPage() {
|
|||||||
result = result.filter((p) => {
|
result = result.filter((p) => {
|
||||||
const name = p.name.toLowerCase();
|
const name = p.name.toLowerCase();
|
||||||
const brand = p.brand.toLowerCase();
|
const brand = p.brand.toLowerCase();
|
||||||
return (
|
return name.includes(normalizedQuery) || brand.includes(normalizedQuery);
|
||||||
name.includes(normalizedQuery) || brand.includes(normalizedQuery)
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -299,13 +294,11 @@ export default function CategoryPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}, [parts, brandFilter, sortBy, build, categoryId, searchQuery]);
|
}, [parts, brandFilter, sortBy, build, categoryId, searchQuery, priceBounds, priceRange, inStockOnly]);
|
||||||
|
|
||||||
const totalPages = useMemo(
|
const totalPages = useMemo(
|
||||||
() =>
|
() =>
|
||||||
filteredParts.length === 0
|
filteredParts.length === 0 ? 1 : Math.ceil(filteredParts.length / PAGE_SIZE),
|
||||||
? 1
|
|
||||||
: Math.ceil(filteredParts.length / PAGE_SIZE),
|
|
||||||
[filteredParts, PAGE_SIZE]
|
[filteredParts, PAGE_SIZE]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -318,30 +311,33 @@ export default function CategoryPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// whenever the category or filters change, jump back to page 1
|
// whenever the category or filters change, jump back to page 1
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
}, [
|
}, [categoryId, brandFilter, sortBy, searchQuery, priceRange, inStockOnly, platform]);
|
||||||
categoryId,
|
|
||||||
brandFilter,
|
|
||||||
sortBy,
|
|
||||||
searchQuery,
|
|
||||||
priceRange,
|
|
||||||
inStockOnly,
|
|
||||||
platform,
|
|
||||||
]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const nextPlatform = (searchParams.get("platform") as string) || "AR-15";
|
const nextPlatform = (searchParams.get("platform") as string) || "AR-15";
|
||||||
setPlatform(nextPlatform);
|
setPlatform(nextPlatform);
|
||||||
}, [searchParams]);
|
}, [searchParams]);
|
||||||
|
|
||||||
|
// Keep selection highlight in sync if the build is changed elsewhere and user comes back.
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
const onStorage = (e: StorageEvent) => {
|
||||||
|
if (e.key !== STORAGE_KEY) return;
|
||||||
|
// Force a re-render by reading current storage (since build state is read-only)
|
||||||
|
// This keeps the selected row pinned/highlighted when navigating back.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||||
|
window.localStorage.getItem(STORAGE_KEY);
|
||||||
|
};
|
||||||
|
window.addEventListener("storage", onStorage);
|
||||||
|
return () => window.removeEventListener("storage", onStorage);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const visibleRange = useMemo(() => {
|
const visibleRange = useMemo(() => {
|
||||||
if (filteredParts.length === 0) {
|
if (filteredParts.length === 0) {
|
||||||
return { start: 0, end: 0 };
|
return { start: 0, end: 0 };
|
||||||
}
|
}
|
||||||
const start = (currentPage - 1) * PAGE_SIZE + 1;
|
const start = (currentPage - 1) * PAGE_SIZE + 1;
|
||||||
const end = Math.min(
|
const end = Math.min(start + paginatedParts.length - 1, filteredParts.length);
|
||||||
start + paginatedParts.length - 1,
|
|
||||||
filteredParts.length
|
|
||||||
);
|
|
||||||
return { start, end };
|
return { start, end };
|
||||||
}, [filteredParts.length, currentPage, paginatedParts.length, PAGE_SIZE]);
|
}, [filteredParts.length, currentPage, paginatedParts.length, PAGE_SIZE]);
|
||||||
|
|
||||||
@@ -354,7 +350,7 @@ export default function CategoryPage() {
|
|||||||
Category Not Found
|
Category Not Found
|
||||||
</h1>
|
</h1>
|
||||||
<Link
|
<Link
|
||||||
href="/builder"
|
href={{ pathname: "/builder", query: platform ? { platform } : {} }}
|
||||||
className="text-amber-300 hover:text-amber-200 underline"
|
className="text-amber-300 hover:text-amber-200 underline"
|
||||||
>
|
>
|
||||||
Return to Builder
|
Return to Builder
|
||||||
@@ -371,7 +367,7 @@ export default function CategoryPage() {
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<header className="mb-6">
|
<header className="mb-6">
|
||||||
<Link
|
<Link
|
||||||
href="/builder"
|
href={{ pathname: "/builder", query: platform ? { platform } : {} }}
|
||||||
className="text-xs text-zinc-400 hover:text-zinc-300 mb-4 inline-block"
|
className="text-xs text-zinc-400 hover:text-zinc-300 mb-4 inline-block"
|
||||||
>
|
>
|
||||||
← Back to The Armory
|
← Back to The Armory
|
||||||
@@ -385,8 +381,8 @@ export default function CategoryPage() {
|
|||||||
{category.name} <span className="text-amber-300">Parts</span>
|
{category.name} <span className="text-amber-300">Parts</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-2 text-sm text-zinc-400 max-w-xl">
|
<p className="mt-2 text-sm text-zinc-400 max-w-xl">
|
||||||
Browse all available {category.name.toLowerCase()} options for
|
Browse all available {category.name.toLowerCase()} options for your
|
||||||
your build, pulled live from your Ballistic backend.
|
build, pulled live from your Ballistic backend.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{!loading && !error && parts.length > 0 && (
|
{!loading && !error && parts.length > 0 && (
|
||||||
@@ -445,10 +441,7 @@ export default function CategoryPage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setPriceRange({
|
setPriceRange({ min: priceBounds.min, max: priceBounds.max })
|
||||||
min: priceBounds.min,
|
|
||||||
max: priceBounds.max,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
className="text-[10px] text-zinc-400 hover:text-zinc-200"
|
className="text-[10px] text-zinc-400 hover:text-zinc-200"
|
||||||
>
|
>
|
||||||
@@ -485,10 +478,7 @@ export default function CategoryPage() {
|
|||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const value = Number(e.target.value);
|
const value = Number(e.target.value);
|
||||||
setPriceRange((prev) => ({
|
setPriceRange((prev) => ({
|
||||||
min: Math.min(
|
min: Math.min(value, prev.max ?? priceBounds.max ?? value),
|
||||||
value,
|
|
||||||
prev.max ?? priceBounds.max ?? value
|
|
||||||
),
|
|
||||||
max: prev.max ?? priceBounds.max ?? value,
|
max: prev.max ?? priceBounds.max ?? value,
|
||||||
}));
|
}));
|
||||||
}}
|
}}
|
||||||
@@ -503,10 +493,7 @@ export default function CategoryPage() {
|
|||||||
const value = Number(e.target.value);
|
const value = Number(e.target.value);
|
||||||
setPriceRange((prev) => ({
|
setPriceRange((prev) => ({
|
||||||
min: prev.min ?? priceBounds.min ?? value,
|
min: prev.min ?? priceBounds.min ?? value,
|
||||||
max: Math.max(
|
max: Math.max(value, prev.min ?? priceBounds.min ?? value),
|
||||||
value,
|
|
||||||
prev.min ?? priceBounds.min ?? value
|
|
||||||
),
|
|
||||||
}));
|
}));
|
||||||
}}
|
}}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
@@ -608,10 +595,7 @@ export default function CategoryPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-wrap gap-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<label
|
<label htmlFor="part-search" className="text-xs text-zinc-500">
|
||||||
htmlFor="part-search"
|
|
||||||
className="text-xs text-zinc-500"
|
|
||||||
>
|
|
||||||
Search
|
Search
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@@ -635,17 +619,22 @@ export default function CategoryPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<label
|
<label htmlFor="platform-select" className="text-xs text-zinc-500">
|
||||||
htmlFor="platform-select"
|
|
||||||
className="text-xs text-zinc-500"
|
|
||||||
>
|
|
||||||
Platform
|
Platform
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
id="platform-select"
|
id="platform-select"
|
||||||
value={platform}
|
value={platform}
|
||||||
onChange={(e) => setPlatform(e.target.value)}
|
onChange={(e) => {
|
||||||
|
const next = e.target.value;
|
||||||
|
setPlatform(next);
|
||||||
|
|
||||||
|
const qp = new URLSearchParams(searchParams.toString());
|
||||||
|
qp.set("platform", next);
|
||||||
|
router.replace(`/builder/${categoryId}?${qp.toString()}`);
|
||||||
|
}}
|
||||||
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"
|
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) => (
|
{PLATFORMS.map((p) => (
|
||||||
@@ -655,19 +644,15 @@ export default function CategoryPage() {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<label
|
<label htmlFor="sort-by" className="text-xs text-zinc-500">
|
||||||
htmlFor="sort-by"
|
|
||||||
className="text-xs text-zinc-500"
|
|
||||||
>
|
|
||||||
Sort
|
Sort
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
id="sort-by"
|
id="sort-by"
|
||||||
value={sortBy}
|
value={sortBy}
|
||||||
onChange={(e) =>
|
onChange={(e) => setSortBy(e.target.value as SortOption)}
|
||||||
setSortBy(e.target.value as SortOption)
|
|
||||||
}
|
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
<option value="relevance">Relevance</option>
|
<option value="relevance">Relevance</option>
|
||||||
@@ -726,9 +711,9 @@ export default function CategoryPage() {
|
|||||||
<div className="mt-2 flex gap-2">
|
<div className="mt-2 flex gap-2">
|
||||||
<Link
|
<Link
|
||||||
href={{
|
href={{
|
||||||
pathname: `/builder/${categoryId}/${
|
pathname: `/builder/${categoryId}/${part.id}-${slugify(
|
||||||
part.id
|
part.name
|
||||||
}-${slugify(part.name)}`,
|
)}`,
|
||||||
query: { platform },
|
query: { platform },
|
||||||
}}
|
}}
|
||||||
className="flex-1 rounded-md border border-zinc-700 bg-zinc-800/50 px-3 py-2 text-center text-xs font-medium text-zinc-300 transition-colors hover:border-zinc-600 hover:bg-zinc-700"
|
className="flex-1 rounded-md border border-zinc-700 bg-zinc-800/50 px-3 py-2 text-center text-xs font-medium text-zinc-300 transition-colors hover:border-zinc-600 hover:bg-zinc-700"
|
||||||
@@ -737,9 +722,7 @@ export default function CategoryPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() =>
|
onClick={() => handleTogglePart(categoryId, part.id)}
|
||||||
handleTogglePart(categoryId, part.id)
|
|
||||||
}
|
|
||||||
className={`flex-1 rounded-md px-3 py-2 text-center text-xs font-semibold transition-colors ${
|
className={`flex-1 rounded-md px-3 py-2 text-center text-xs font-semibold transition-colors ${
|
||||||
isSelected
|
isSelected
|
||||||
? "border border-zinc-600 bg-zinc-800 text-zinc-100 hover:bg-zinc-700"
|
? "border border-zinc-600 bg-zinc-800 text-zinc-100 hover:bg-zinc-700"
|
||||||
@@ -796,9 +779,9 @@ export default function CategoryPage() {
|
|||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
<Link
|
<Link
|
||||||
href={{
|
href={{
|
||||||
pathname: `/builder/${categoryId}/${
|
pathname: `/builder/${categoryId}/${part.id}-${slugify(
|
||||||
part.id
|
part.name
|
||||||
}-${slugify(part.name)}`,
|
)}`,
|
||||||
query: { platform },
|
query: { platform },
|
||||||
}}
|
}}
|
||||||
className="whitespace-nowrap rounded-md border border-zinc-700 bg-zinc-800/50 px-3 py-1.5 text-xs font-medium text-zinc-300 transition-colors hover:border-zinc-600 hover:bg-zinc-700"
|
className="whitespace-nowrap rounded-md border border-zinc-700 bg-zinc-800/50 px-3 py-1.5 text-xs font-medium text-zinc-300 transition-colors hover:border-zinc-600 hover:bg-zinc-700"
|
||||||
@@ -807,18 +790,14 @@ export default function CategoryPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() =>
|
onClick={() => handleTogglePart(categoryId, part.id)}
|
||||||
handleTogglePart(categoryId, part.id)
|
|
||||||
}
|
|
||||||
className={`whitespace-nowrap rounded-md px-3 py-1.5 text-xs font-semibold transition-colors ${
|
className={`whitespace-nowrap rounded-md px-3 py-1.5 text-xs font-semibold transition-colors ${
|
||||||
isSelected
|
isSelected
|
||||||
? "border border-zinc-600 bg-zinc-800 text-zinc-100 hover:bg-zinc-700"
|
? "border border-zinc-600 bg-zinc-800 text-zinc-100 hover:bg-zinc-700"
|
||||||
: "bg-amber-400 text-black hover:bg-amber-300"
|
: "bg-amber-400 text-black hover:bg-amber-300"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{isSelected
|
{isSelected ? "Remove from Build" : "Add to Build"}
|
||||||
? "Remove from Build"
|
|
||||||
: "Add to Build"}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -834,13 +813,9 @@ export default function CategoryPage() {
|
|||||||
<div className="mt-4 flex items-center justify-between text-xs text-zinc-400">
|
<div className="mt-4 flex items-center justify-between text-xs text-zinc-400">
|
||||||
<div>
|
<div>
|
||||||
Page{" "}
|
Page{" "}
|
||||||
<span className="font-semibold text-zinc-200">
|
<span className="font-semibold text-zinc-200">{currentPage}</span>{" "}
|
||||||
{currentPage}
|
|
||||||
</span>{" "}
|
|
||||||
of{" "}
|
of{" "}
|
||||||
<span className="font-semibold text-zinc-200">
|
<span className="font-semibold text-zinc-200">{totalPages}</span>
|
||||||
{totalPages}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
@@ -853,9 +828,7 @@ export default function CategoryPage() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() =>
|
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
||||||
setCurrentPage((p) => Math.min(totalPages, p + 1))
|
|
||||||
}
|
|
||||||
disabled={currentPage === totalPages}
|
disabled={currentPage === totalPages}
|
||||||
className="rounded border border-zinc-700 bg-zinc-900/70 px-3 py-1 hover:bg-zinc-800 disabled:opacity-40"
|
className="rounded border border-zinc-700 bg-zinc-900/70 px-3 py-1 hover:bg-zinc-800 disabled:opacity-40"
|
||||||
>
|
>
|
||||||
@@ -870,4 +843,4 @@ export default function CategoryPage() {
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,802 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import { CATEGORIES } from "@/data/gunbuilderParts";
|
||||||
|
import type { CategoryId, Part } from "@/types/gunbuilder";
|
||||||
|
import { CATEGORY_TO_PART_ROLES } from "@/data/partRoleMappings";
|
||||||
|
|
||||||
|
type ViewMode = "card" | "list";
|
||||||
|
|
||||||
|
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 UiPart = Part & {
|
||||||
|
inStock?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const PLATFORMS = ["AR-15", "AR-10", "AR-9"];
|
||||||
|
|
||||||
|
const API_BASE_URL =
|
||||||
|
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
|
||||||
|
|
||||||
|
type BuildState = Partial<Record<CategoryId, string>>;
|
||||||
|
|
||||||
|
const STORAGE_KEY = "gunbuilder-build-state";
|
||||||
|
|
||||||
|
type SortOption = "relevance" | "price-asc" | "price-desc" | "brand-asc";
|
||||||
|
|
||||||
|
const slugify = (str: string) =>
|
||||||
|
str
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/(^-|-$)/g, "");
|
||||||
|
|
||||||
|
const normalizeRole = (role: string) =>
|
||||||
|
role.trim().toLowerCase().replace(/_/g, "-");
|
||||||
|
|
||||||
|
function getNormalizedPartRolesForCategory(categoryId: string): string[] {
|
||||||
|
const roles = (CATEGORY_TO_PART_ROLES as any)[categoryId] as
|
||||||
|
| string[]
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
if (!roles || roles.length === 0) return [];
|
||||||
|
return Array.from(new Set(roles.map(normalizeRole)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CategoryPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const categoryId = params.category as CategoryId;
|
||||||
|
|
||||||
|
const partRoles = useMemo(
|
||||||
|
() => getNormalizedPartRolesForCategory(String(categoryId)),
|
||||||
|
[categoryId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
const [viewMode, setViewMode] = useState<ViewMode>("list");
|
||||||
|
const [parts, setParts] = useState<UiPart[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [brandFilter, setBrandFilter] = useState<string[]>([]);
|
||||||
|
const [sortBy, setSortBy] = useState<SortOption>("relevance");
|
||||||
|
const [searchQuery, setSearchQuery] = useState<string>("");
|
||||||
|
const [priceRange, setPriceRange] = useState<{
|
||||||
|
min: number | null;
|
||||||
|
max: number | null;
|
||||||
|
}>({ min: null, max: null });
|
||||||
|
const [inStockOnly, setInStockOnly] = useState<boolean>(false);
|
||||||
|
const [currentPage, setCurrentPage] = useState<number>(1);
|
||||||
|
const PAGE_SIZE = 24;
|
||||||
|
|
||||||
|
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 initialPlatform = (searchParams.get("platform") as string) || "AR-15";
|
||||||
|
const [platform, setPlatform] = useState<string>(initialPlatform);
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
// If no role mapping exists, fail loudly (helps debugging)
|
||||||
|
if (partRoles.length === 0) {
|
||||||
|
setParts([]);
|
||||||
|
setError(
|
||||||
|
`No partRole mapping found for category "${categoryId}". Check CATEGORY_TO_PART_ROLES.`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const search = new URLSearchParams();
|
||||||
|
search.set("platform", platform);
|
||||||
|
|
||||||
|
for (const r of partRoles) {
|
||||||
|
search.append("partRoles", r);
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `${API_BASE_URL}/api/products?${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();
|
||||||
|
|
||||||
|
const normalized: UiPart[] = data.map((p) => ({
|
||||||
|
id: String(p.id),
|
||||||
|
categoryId,
|
||||||
|
name: p.name,
|
||||||
|
brand: p.brand,
|
||||||
|
price: p.price ?? 0,
|
||||||
|
imageUrl: p.imageUrl ?? undefined,
|
||||||
|
url: p.buyUrl ?? undefined,
|
||||||
|
affiliateUrl: p.buyUrl ?? undefined,
|
||||||
|
notes: undefined,
|
||||||
|
inStock: p.inStock ?? true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
setParts(normalized);
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err.name === "AbortError") return;
|
||||||
|
setError(err.message ?? "Failed to load products");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log("[parts] categoryId", categoryId, "partRoles", partRoles);
|
||||||
|
fetchCategoryParts();
|
||||||
|
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [categoryId, platform, partRoles]);
|
||||||
|
|
||||||
|
const handleTogglePart = (categoryId: CategoryId, partId: string) => {
|
||||||
|
const isSelected = build[categoryId] === partId;
|
||||||
|
|
||||||
|
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}:${partId}`);
|
||||||
|
router.push(`/builder?${qp.toString()}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const availableBrands = useMemo(
|
||||||
|
() =>
|
||||||
|
Array.from(new Set(parts.map((p) => p.brand)))
|
||||||
|
.filter(Boolean)
|
||||||
|
.sort((a, b) => a.localeCompare(b)),
|
||||||
|
[parts]
|
||||||
|
);
|
||||||
|
|
||||||
|
const priceBounds = useMemo(() => {
|
||||||
|
if (parts.length === 0) {
|
||||||
|
return { min: null as number | null, max: null as number | null };
|
||||||
|
}
|
||||||
|
let min = Number.POSITIVE_INFINITY;
|
||||||
|
let max = Number.NEGATIVE_INFINITY;
|
||||||
|
|
||||||
|
for (const p of parts) {
|
||||||
|
const price = p.price ?? 0;
|
||||||
|
if (price < min) min = price;
|
||||||
|
if (price > max) max = price;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Number.isFinite(min) || !Number.isFinite(max)) {
|
||||||
|
return { min: null as number | null, max: null as number | null };
|
||||||
|
}
|
||||||
|
return { min, max };
|
||||||
|
}, [parts]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (priceBounds.min == null || priceBounds.max == null) return;
|
||||||
|
|
||||||
|
setPriceRange((prev) => {
|
||||||
|
const min = prev.min ?? priceBounds.min!;
|
||||||
|
const max = prev.max ?? priceBounds.max!;
|
||||||
|
return {
|
||||||
|
min: Math.max(priceBounds.min!, Math.min(min, priceBounds.max!)),
|
||||||
|
max: Math.min(priceBounds.max!, Math.max(max, priceBounds.min!)),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [priceBounds.min, priceBounds.max]);
|
||||||
|
|
||||||
|
const filteredParts = useMemo(() => {
|
||||||
|
let result = [...parts];
|
||||||
|
|
||||||
|
const effectiveMin =
|
||||||
|
priceRange.min ?? (priceBounds.min != null ? priceBounds.min : null);
|
||||||
|
const effectiveMax =
|
||||||
|
priceRange.max ?? (priceBounds.max != null ? priceBounds.max : null);
|
||||||
|
|
||||||
|
if (effectiveMin != null && effectiveMax != null) {
|
||||||
|
result = result.filter((p) => {
|
||||||
|
const price = p.price ?? 0;
|
||||||
|
return price >= effectiveMin && price <= effectiveMax;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (brandFilter.length > 0) {
|
||||||
|
result = result.filter((p) => brandFilter.includes(p.brand));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inStockOnly) {
|
||||||
|
result = result.filter((p) => p.inStock ?? true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedQuery = searchQuery.trim().toLowerCase();
|
||||||
|
if (normalizedQuery) {
|
||||||
|
result = result.filter((p) => {
|
||||||
|
const name = p.name.toLowerCase();
|
||||||
|
const brand = p.brand.toLowerCase();
|
||||||
|
return name.includes(normalizedQuery) || brand.includes(normalizedQuery);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (sortBy) {
|
||||||
|
case "price-asc":
|
||||||
|
result.sort((a, b) => a.price - b.price);
|
||||||
|
break;
|
||||||
|
case "price-desc":
|
||||||
|
result.sort((a, b) => b.price - a.price);
|
||||||
|
break;
|
||||||
|
case "brand-asc":
|
||||||
|
result.sort((a, b) => a.brand.localeCompare(b.brand));
|
||||||
|
break;
|
||||||
|
case "relevance":
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedId = build[categoryId];
|
||||||
|
if (selectedId) {
|
||||||
|
const selected = result.filter((p) => p.id === selectedId);
|
||||||
|
const others = result.filter((p) => p.id !== selectedId);
|
||||||
|
result = [...selected, ...others];
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}, [parts, brandFilter, sortBy, build, categoryId, searchQuery, priceRange, inStockOnly, priceBounds.min, priceBounds.max]);
|
||||||
|
|
||||||
|
const totalPages = useMemo(
|
||||||
|
() =>
|
||||||
|
filteredParts.length === 0
|
||||||
|
? 1
|
||||||
|
: Math.ceil(filteredParts.length / PAGE_SIZE),
|
||||||
|
[filteredParts, PAGE_SIZE]
|
||||||
|
);
|
||||||
|
|
||||||
|
const paginatedParts = useMemo(() => {
|
||||||
|
if (filteredParts.length === 0) return [];
|
||||||
|
const startIndex = (currentPage - 1) * PAGE_SIZE;
|
||||||
|
return filteredParts.slice(startIndex, startIndex + PAGE_SIZE);
|
||||||
|
}, [filteredParts, currentPage, PAGE_SIZE]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentPage(1);
|
||||||
|
}, [categoryId, brandFilter, sortBy, searchQuery, priceRange, inStockOnly, platform]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const nextPlatform = (searchParams.get("platform") as string) || "AR-15";
|
||||||
|
setPlatform(nextPlatform);
|
||||||
|
}, [searchParams]);
|
||||||
|
|
||||||
|
const visibleRange = useMemo(() => {
|
||||||
|
if (filteredParts.length === 0) return { start: 0, end: 0 };
|
||||||
|
const start = (currentPage - 1) * PAGE_SIZE + 1;
|
||||||
|
const end = Math.min(start + paginatedParts.length - 1, filteredParts.length);
|
||||||
|
return { start, end };
|
||||||
|
}, [filteredParts.length, currentPage, paginatedParts.length, PAGE_SIZE]);
|
||||||
|
|
||||||
|
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={{ pathname: "/builder", query: { platform } }}
|
||||||
|
className="text-xs text-zinc-400 hover:text-zinc-300 mb-4 inline-block"
|
||||||
|
>
|
||||||
|
← Back to The Armory
|
||||||
|
</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 className="mb-6">
|
||||||
|
<Link
|
||||||
|
href={{ pathname: "/builder", query: { platform } }}
|
||||||
|
className="text-xs text-zinc-400 hover:text-zinc-300 mb-4 inline-block"
|
||||||
|
>
|
||||||
|
← Back to The Armory
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
|
||||||
|
Battl Builder
|
||||||
|
</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"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
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"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
List
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="rounded-lg border border-zinc-800 bg-zinc-950/60 p-3 md:p-4">
|
||||||
|
<div className="flex flex-col gap-4 md:flex-row">
|
||||||
|
{!loading && !error && parts.length > 0 && (
|
||||||
|
<aside className="w-full md:w-64 shrink-0 rounded-md border border-zinc-800 bg-zinc-950/80 p-3 space-y-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xs font-semibold uppercase tracking-[0.16em] text-zinc-500">
|
||||||
|
Filters
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-[11px] text-zinc-500">
|
||||||
|
Narrow down the {category.name.toLowerCase()} list.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="mb-1.5 flex items-center justify-between">
|
||||||
|
<span className="text-[11px] font-medium uppercase tracking-[0.12em] text-zinc-500">
|
||||||
|
Price
|
||||||
|
</span>
|
||||||
|
{(priceRange.min !== null || priceRange.max !== null) && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setPriceRange({
|
||||||
|
min: priceBounds.min,
|
||||||
|
max: priceBounds.max,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="text-[10px] text-zinc-400 hover:text-zinc-200"
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{priceBounds.min == null || priceBounds.max == null ? (
|
||||||
|
<p className="text-[11px] text-zinc-500">
|
||||||
|
No price data available.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between text-[10px] text-zinc-400">
|
||||||
|
<span>
|
||||||
|
Min:{" "}
|
||||||
|
<span className="font-semibold text-zinc-200">
|
||||||
|
${(priceRange.min ?? priceBounds.min).toFixed(0)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
Max:{" "}
|
||||||
|
<span className="font-semibold text-zinc-200">
|
||||||
|
${(priceRange.max ?? priceBounds.max).toFixed(0)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={priceBounds.min ?? 0}
|
||||||
|
max={priceBounds.max ?? 0}
|
||||||
|
value={priceRange.min ?? priceBounds.min ?? 0}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = Number(e.target.value);
|
||||||
|
setPriceRange((prev) => ({
|
||||||
|
min: Math.min(
|
||||||
|
value,
|
||||||
|
prev.max ?? priceBounds.max ?? value
|
||||||
|
),
|
||||||
|
max: prev.max ?? priceBounds.max ?? value,
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={priceBounds.min ?? 0}
|
||||||
|
max={priceBounds.max ?? 0}
|
||||||
|
value={priceRange.max ?? priceBounds.max ?? 0}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = Number(e.target.value);
|
||||||
|
setPriceRange((prev) => ({
|
||||||
|
min: prev.min ?? priceBounds.min ?? value,
|
||||||
|
max: Math.max(
|
||||||
|
value,
|
||||||
|
prev.min ?? priceBounds.min ?? value
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="mb-1.5 flex items-center justify-between">
|
||||||
|
<span className="text-[11px] font-medium uppercase tracking-[0.12em] text-zinc-500">
|
||||||
|
Brand
|
||||||
|
</span>
|
||||||
|
{brandFilter.length > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setBrandFilter([])}
|
||||||
|
className="text-[10px] text-zinc-400 hover:text-zinc-200"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{availableBrands.length === 0 ? (
|
||||||
|
<p className="text-[11px] text-zinc-500">
|
||||||
|
No brands available yet.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="max-h-56 space-y-1 overflow-y-auto pr-1">
|
||||||
|
{availableBrands.map((b) => {
|
||||||
|
const checked = brandFilter.includes(b);
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={b}
|
||||||
|
className="flex cursor-pointer items-center gap-2 rounded px-1 py-0.5 text-[11px] text-zinc-200 hover:bg-zinc-900/80"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="h-3 w-3 rounded border-zinc-600 bg-zinc-900 text-amber-400 focus:ring-amber-400"
|
||||||
|
checked={checked}
|
||||||
|
onChange={(e) => {
|
||||||
|
setBrandFilter((prev) =>
|
||||||
|
e.target.checked
|
||||||
|
? [...prev, b]
|
||||||
|
: prev.filter((brand) => brand !== b)
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="truncate">{b}</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{brandFilter.length === 0 && availableBrands.length > 0 && (
|
||||||
|
<p className="mt-1 text-[10px] text-zinc-500">
|
||||||
|
No brands selected — showing all.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-zinc-800 pt-3">
|
||||||
|
<label className="flex cursor-pointer items-center justify-between text-[11px] text-zinc-200">
|
||||||
|
<span className="font-medium uppercase tracking-[0.12em] text-zinc-500">
|
||||||
|
In stock only
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="h-3 w-3 rounded border-zinc-600 bg-zinc-900 text-amber-400 focus:ring-amber-400"
|
||||||
|
checked={inStockOnly}
|
||||||
|
onChange={(e) => setInStockOnly(e.target.checked)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<p className="mt-1 text-[10px] text-zinc-500">
|
||||||
|
Hides items marked out of stock by the backend.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex-1">
|
||||||
|
{!loading && !error && parts.length > 0 && (
|
||||||
|
<div className="mb-4 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div className="text-xs text-zinc-500">
|
||||||
|
Showing{" "}
|
||||||
|
<span className="font-medium text-zinc-200">
|
||||||
|
{visibleRange.start}-{visibleRange.end}
|
||||||
|
</span>{" "}
|
||||||
|
of{" "}
|
||||||
|
<span className="font-medium text-zinc-200">
|
||||||
|
{filteredParts.length}
|
||||||
|
</span>{" "}
|
||||||
|
matching parts
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label
|
||||||
|
htmlFor="part-search"
|
||||||
|
className="text-xs text-zinc-500"
|
||||||
|
>
|
||||||
|
Search
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
id="part-search"
|
||||||
|
type="text"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
placeholder={`Search ${category.name.toLowerCase()}...`}
|
||||||
|
className="rounded-md border border-zinc-700 bg-zinc-900/70 px-2 py-1 pr-6 text-xs text-zinc-200 focus:outline-none focus:ring-1 focus:ring-amber-400"
|
||||||
|
/>
|
||||||
|
{searchQuery && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSearchQuery("")}
|
||||||
|
className="absolute right-1 top-1/2 -translate-y-1/2 text-xs leading-none text-zinc-500 hover:text-zinc-300"
|
||||||
|
aria-label="Clear search"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<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) => setPlatform(e.target.value)}
|
||||||
|
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 className="flex items-center gap-2">
|
||||||
|
<label htmlFor="sort-by" className="text-xs text-zinc-500">
|
||||||
|
Sort
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="sort-by"
|
||||||
|
value={sortBy}
|
||||||
|
onChange={(e) => setSortBy(e.target.value as SortOption)}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<option value="relevance">Relevance</option>
|
||||||
|
<option value="price-asc">Price: Low → High</option>
|
||||||
|
<option value="price-desc">Price: High → Low</option>
|
||||||
|
<option value="brand-asc">Brand: A → Z</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<p className="py-8 text-center text-sm text-zinc-500">
|
||||||
|
Loading {category.name.toLowerCase()}…
|
||||||
|
</p>
|
||||||
|
) : error ? (
|
||||||
|
<p className="py-8 text-center text-sm text-red-400">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : filteredParts.length === 0 ? (
|
||||||
|
<p className="py-8 text-center text-sm text-zinc-500">
|
||||||
|
No parts available for this category yet.
|
||||||
|
</p>
|
||||||
|
) : viewMode === "card" ? (
|
||||||
|
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{paginatedParts.map((part) => {
|
||||||
|
const isSelected = build[categoryId] === part.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={part.id}
|
||||||
|
className={`group relative flex flex-col rounded-md border p-3 transition-all duration-200 ${
|
||||||
|
isSelected
|
||||||
|
? "border-amber-400/70 bg-amber-400/10 shadow-[0_0_0_1px_rgba(251,191,36,0.25)]"
|
||||||
|
: "border-zinc-700 bg-zinc-900/50 hover:border-amber-400/60 hover:bg-amber-400/10"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="mb-3 flex items-center gap-2 justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-sm font-semibold text-zinc-50">
|
||||||
|
{part.brand}{" "}
|
||||||
|
<span className="font-normal">— {part.name}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="whitespace-nowrap text-sm font-semibold text-amber-300">
|
||||||
|
${part.price.toFixed(2)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex gap-2">
|
||||||
|
<Link
|
||||||
|
href={{
|
||||||
|
pathname: `/parts/${categoryId}/${part.id}-${slugify(
|
||||||
|
part.name
|
||||||
|
)}`,
|
||||||
|
query: { platform },
|
||||||
|
}}
|
||||||
|
className="flex-1 rounded-md border border-zinc-700 bg-zinc-800/50 px-3 py-2 text-center text-xs font-medium text-zinc-300 transition-colors hover:border-zinc-600 hover:bg-zinc-700"
|
||||||
|
>
|
||||||
|
View Details
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleTogglePart(categoryId, part.id)}
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="hidden grid-cols-[minmax(0,3fr)_minmax(0,1fr)_minmax(0,1fr)_auto] px-3 pb-2 text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500 md:grid">
|
||||||
|
<span>Part</span>
|
||||||
|
<span>Brand</span>
|
||||||
|
<span className="text-right">Price</span>
|
||||||
|
<span className="pr-2 text-right">Actions</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{paginatedParts.map((part) => {
|
||||||
|
const isSelected = build[categoryId] === part.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={part.id}
|
||||||
|
className={`group relative rounded-md border p-3 transition-all duration-200 ${
|
||||||
|
isSelected
|
||||||
|
? "border-amber-400/70 bg-amber-400/10 shadow-[0_0_0_1px_rgba(251,191,36,0.25)]"
|
||||||
|
: "border-zinc-700 bg-zinc-900/50 hover:border-amber-400/60 hover:bg-amber-400/10"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-2 md:grid md:grid-cols-[minmax(0,3fr)_minmax(0,1fr)_minmax(0,1fr)_auto] md:items-center md:gap-4">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-sm font-semibold text-zinc-50">
|
||||||
|
{part.name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-zinc-400 md:text-left md:text-sm">
|
||||||
|
{part.brand}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-semibold text-amber-300 md:text-right">
|
||||||
|
${part.price.toFixed(2)}
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Link
|
||||||
|
href={{
|
||||||
|
pathname: `/parts/${categoryId}/${part.id}-${slugify(
|
||||||
|
part.name
|
||||||
|
)}`,
|
||||||
|
query: { platform },
|
||||||
|
}}
|
||||||
|
className="whitespace-nowrap rounded-md border border-zinc-700 bg-zinc-800/50 px-3 py-1.5 text-xs font-medium text-zinc-300 transition-colors hover:border-zinc-600 hover:bg-zinc-700"
|
||||||
|
>
|
||||||
|
View Details
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleTogglePart(categoryId, part.id)}
|
||||||
|
className={`whitespace-nowrap rounded-md px-3 py-1.5 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>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{filteredParts.length > 0 && totalPages > 1 && (
|
||||||
|
<div className="mt-4 flex items-center justify-between text-xs text-zinc-400">
|
||||||
|
<div>
|
||||||
|
Page{" "}
|
||||||
|
<span className="font-semibold text-zinc-200">
|
||||||
|
{currentPage}
|
||||||
|
</span>{" "}
|
||||||
|
of{" "}
|
||||||
|
<span className="font-semibold text-zinc-200">
|
||||||
|
{totalPages}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
className="rounded border border-zinc-700 bg-zinc-900/70 px-3 py-1 hover:bg-zinc-800 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setCurrentPage((p) => Math.min(totalPages, p + 1))
|
||||||
|
}
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
className="rounded border border-zinc-700 bg-zinc-900/70 px-3 py-1 hover:bg-zinc-800 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
+102
-127
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
const API_BASE_URL =
|
const API_BASE_URL =
|
||||||
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
|
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
|
||||||
@@ -13,123 +13,58 @@ type PendingBucket = {
|
|||||||
productCount: number;
|
productCount: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
const PART_ROLE_OPTIONS: string[] = [
|
/**
|
||||||
// LOWER – receivers
|
* Canonical part roles (kebab-case) — should match what the importer normalizes to.
|
||||||
"LOWER_RECEIVER_STRIPPED",
|
* Keep this list tight + intentional so mappings don't create junk roles in the DB.
|
||||||
"LOWER_RECEIVER_COMPLETE",
|
*/
|
||||||
"LOWER_RECEIVER_COMPLETE_PISTOL",
|
const PART_ROLE_OPTIONS = [
|
||||||
"LOWER_RECEIVER_COMPLETE_RIFLE",
|
// Assemblies / receivers
|
||||||
"LOWER_RECEIVER_80",
|
"upper-receiver",
|
||||||
"LOWER_RECEIVER_BILLET",
|
"complete-upper",
|
||||||
"LOWER_RECEIVER_FORGED",
|
"lower-receiver",
|
||||||
"LOWER_RECEIVER_POLYMER",
|
"complete-lower",
|
||||||
|
|
||||||
// LOWER – internals & controls
|
|
||||||
"LOWER_PARTS_KIT",
|
|
||||||
"LOWER_PARTS_KIT_ENHANCED",
|
|
||||||
|
|
||||||
"TRIGGER",
|
// Upper sub-parts
|
||||||
"TRIGGER_OTHER",
|
"bcg",
|
||||||
"TRIGGER_COMBAT",
|
"barrel",
|
||||||
"TRIGGER_MATCH",
|
"gas-block",
|
||||||
"TRIGGER_DROP_IN",
|
"gas-tube",
|
||||||
"TRIGGER_SINGLE_STAGE",
|
"muzzle-device",
|
||||||
"TRIGGER_TWO_STAGE",
|
"suppressor",
|
||||||
"TRIGGER_GUARD",
|
"handguard",
|
||||||
"PISTOL_GRIP",
|
"charging-handle",
|
||||||
"PISTOL_GRIP_ERGO",
|
|
||||||
"PISTOL_GRIP_VERTICAL",
|
|
||||||
"SAFETY_SELECTOR",
|
|
||||||
"SAFETY_SELECTOR_AMBI",
|
|
||||||
"SAFETY_SELECTOR_45_DEG",
|
|
||||||
"MAG_RELEASE",
|
|
||||||
"TAKEDOWN_PINS",
|
|
||||||
"SPRINGS_PINS_MISC",
|
|
||||||
|
|
||||||
// LOWER – buffer & stock
|
|
||||||
"BUFFER_KIT",
|
|
||||||
"BUFFER_TUBE",
|
|
||||||
"BUFFER_TUBE_MILSPEC",
|
|
||||||
"BUFFER_TUBE_COMMERCIAL",
|
|
||||||
"BUFFER_SPRING",
|
|
||||||
"BUFFER_WEIGHT",
|
|
||||||
"STOCK_ADJUSTABLE",
|
|
||||||
"STOCK_FIXED",
|
|
||||||
"STOCK_FOLDER",
|
|
||||||
"STOCK_PRECISION",
|
|
||||||
"BRACE_PISTOL",
|
|
||||||
|
|
||||||
// UPPER – receivers & BCG
|
|
||||||
"UPPER_RECEIVER_STRIPPED",
|
|
||||||
"UPPER_RECEIVER_BILLET",
|
|
||||||
"UPPER_RECEIVER_MONOLITHIC",
|
|
||||||
"UPPER_RECEIVER_COMPLETE",
|
|
||||||
"UPPER_RECEIVER_COMPLETE_PISTOL",
|
|
||||||
"UPPER_RECEIVER_COMPLETE_RIFLE",
|
|
||||||
"BOLT_CARRIER_GROUP",
|
|
||||||
"BCG_COMPLETE",
|
|
||||||
"BCG_LIGHTWEIGHT",
|
|
||||||
"BCG_NICKEL_BORON",
|
|
||||||
|
|
||||||
// UPPER – barrel & gas
|
|
||||||
"BARREL",
|
|
||||||
"BARREL_THREADED",
|
|
||||||
"BARREL_PENCIL",
|
|
||||||
"BARREL_MATCH",
|
|
||||||
"BARREL_HEAVY",
|
|
||||||
"BARREL_14_5_PINNED",
|
|
||||||
"GAS_BLOCK",
|
|
||||||
"GAS_BLOCK_ADJUSTABLE",
|
|
||||||
"GAS_BLOCK_LOW_PROFILE",
|
|
||||||
"GAS_TUBE_PISTOL",
|
|
||||||
"GAS_TUBE_CAR",
|
|
||||||
"GAS_TUBE_MID",
|
|
||||||
"GAS_TUBE_RIFLE",
|
|
||||||
|
|
||||||
// UPPER – front end / muzzle
|
|
||||||
"HANDGUARD_MLOK",
|
|
||||||
"HANDGUARD_KEYMOD",
|
|
||||||
"HANDGUARD_QUAD",
|
|
||||||
"HANDGUARD_SLICK",
|
|
||||||
"MUZZLE_BRAKE",
|
|
||||||
"MUZZLE_COMPENSATOR",
|
|
||||||
|
|
||||||
// 👇 generic / backup muzzle roles
|
// Lower sub-parts
|
||||||
"MUZZLE_DEVICE",
|
"lower-parts",
|
||||||
"MUZZLE_DEVICE_OTHER",
|
"trigger",
|
||||||
|
"grip",
|
||||||
"FLASH_HIDER",
|
"safety",
|
||||||
"MUZZLE_DEVICE_QD_MOUNT",
|
"buffer",
|
||||||
"SUPPRESSOR_DIRECT_THREAD",
|
"stock",
|
||||||
"SUPPRESSOR_QD",
|
|
||||||
"SUPPRESSOR_RIFLE",
|
// Sights / optics
|
||||||
"SUPPRESSOR_PISTOL",
|
"sights",
|
||||||
"CHARGING_HANDLE",
|
"optic",
|
||||||
"CHARGING_HANDLE_AMBI",
|
|
||||||
"CHARGING_HANDLE_GAS_BUSTER",
|
// Accessories / gear
|
||||||
|
"magazine",
|
||||||
// Sights / optics
|
"weapon-light",
|
||||||
"SIGHTS_BACKUP",
|
"foregrip",
|
||||||
"SIGHTS_IRON_FIXED",
|
"bipod",
|
||||||
"SIGHTS_IRON_FLIP",
|
"sling",
|
||||||
"SIGHTS_OFFSET",
|
"rail-accessory",
|
||||||
"OPTIC_REDDOT",
|
"tools",
|
||||||
"OPTIC_LPVO",
|
] as const;
|
||||||
"OPTIC_HOLOGRAPHIC",
|
|
||||||
"OPTIC_PRISM",
|
type PartRole = (typeof PART_ROLE_OPTIONS)[number];
|
||||||
"OPTIC_MAGNIFIER",
|
|
||||||
|
function toLabel(role: string) {
|
||||||
// Accessories
|
// "complete-upper" -> "Complete Upper"
|
||||||
"MAGAZINE",
|
return role
|
||||||
"SLING",
|
.split("-")
|
||||||
"BIPOD",
|
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
||||||
"MOUNT_SCOPE",
|
.join(" ");
|
||||||
"MOUNT_OFFSET",
|
}
|
||||||
"LIGHT_WEAPON",
|
|
||||||
"LASER_VISIBLE",
|
|
||||||
"LASER_IR",
|
|
||||||
"TOOL",
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function MappingAdminPage() {
|
export default function MappingAdminPage() {
|
||||||
const [rows, setRows] = useState<PendingBucket[]>([]);
|
const [rows, setRows] = useState<PendingBucket[]>([]);
|
||||||
@@ -137,17 +72,33 @@ export default function MappingAdminPage() {
|
|||||||
const [savingKey, setSavingKey] = useState<string | null>(null);
|
const [savingKey, setSavingKey] = useState<string | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const options = useMemo(
|
||||||
|
() =>
|
||||||
|
PART_ROLE_OPTIONS.map((value) => ({
|
||||||
|
value,
|
||||||
|
label: toLabel(value),
|
||||||
|
})),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function load() {
|
async function load() {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`${API_BASE_URL}/api/admin/mapping/pending-buckets`
|
`${API_BASE_URL}/api/admin/mapping/pending-buckets`,
|
||||||
|
{ headers: { Accept: "application/json" } }
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(`Failed to load pending buckets (${res.status})`);
|
const text = await res.text().catch(() => "");
|
||||||
|
throw new Error(
|
||||||
|
`Failed to load pending buckets (${res.status})${text ? `: ${text}` : ""}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const data: PendingBucket[] = await res.json();
|
const data: PendingBucket[] = await res.json();
|
||||||
setRows(data);
|
setRows(data);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -162,9 +113,14 @@ export default function MappingAdminPage() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleChangeRole = (idx: number, value: string) => {
|
const handleChangeRole = (idx: number, value: string) => {
|
||||||
|
// Always store normalized kebab-case (defensive)
|
||||||
|
const normalized = value
|
||||||
|
? value.trim().toLowerCase().replace(/_/g, "-")
|
||||||
|
: "";
|
||||||
|
|
||||||
setRows((prev) => {
|
setRows((prev) => {
|
||||||
const next = [...prev];
|
const next = [...prev];
|
||||||
next[idx] = { ...next[idx], mappedPartRole: value || null };
|
next[idx] = { ...next[idx], mappedPartRole: normalized || null };
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -172,6 +128,20 @@ export default function MappingAdminPage() {
|
|||||||
const handleSave = async (row: PendingBucket) => {
|
const handleSave = async (row: PendingBucket) => {
|
||||||
if (!row.mappedPartRole) return;
|
if (!row.mappedPartRole) return;
|
||||||
|
|
||||||
|
const mapped = row.mappedPartRole
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/_/g, "-");
|
||||||
|
|
||||||
|
// Basic guard: prevent saving random roles from stale data
|
||||||
|
const allowed = new Set<string>(PART_ROLE_OPTIONS as readonly string[]);
|
||||||
|
if (!allowed.has(mapped)) {
|
||||||
|
setError(
|
||||||
|
`Refusing to save unknown part role: "${row.mappedPartRole}". Pick a role from the dropdown list.`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const key = `${row.merchantId}-${row.rawCategoryKey}`;
|
const key = `${row.merchantId}-${row.rawCategoryKey}`;
|
||||||
try {
|
try {
|
||||||
setSavingKey(key);
|
setSavingKey(key);
|
||||||
@@ -183,12 +153,15 @@ export default function MappingAdminPage() {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
merchantId: row.merchantId,
|
merchantId: row.merchantId,
|
||||||
rawCategoryKey: row.rawCategoryKey,
|
rawCategoryKey: row.rawCategoryKey,
|
||||||
mappedPartRole: row.mappedPartRole,
|
mappedPartRole: mapped,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(`Failed to save mapping (${res.status})`);
|
const text = await res.text().catch(() => "");
|
||||||
|
throw new Error(
|
||||||
|
`Failed to save mapping (${res.status})${text ? `: ${text}` : ""}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// After save, remove this bucket from the list
|
// After save, remove this bucket from the list
|
||||||
@@ -241,8 +214,8 @@ export default function MappingAdminPage() {
|
|||||||
Pending Buckets
|
Pending Buckets
|
||||||
</h2>
|
</h2>
|
||||||
<span className="text-xs text-zinc-400">
|
<span className="text-xs text-zinc-400">
|
||||||
{rows.length} buckets • {rows.reduce((s, r) => s + r.productCount, 0)}{" "}
|
{rows.length} buckets •{" "}
|
||||||
products
|
{rows.reduce((s, r) => s + r.productCount, 0)} products
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -283,7 +256,9 @@ export default function MappingAdminPage() {
|
|||||||
<td className="px-2 py-1 text-zinc-300">
|
<td className="px-2 py-1 text-zinc-300">
|
||||||
{row.rawCategoryKey}
|
{row.rawCategoryKey}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-2 py-1 text-zinc-200">{row.productCount}</td>
|
<td className="px-2 py-1 text-zinc-200">
|
||||||
|
{row.productCount}
|
||||||
|
</td>
|
||||||
<td className="px-2 py-1 text-zinc-100">
|
<td className="px-2 py-1 text-zinc-100">
|
||||||
<select
|
<select
|
||||||
className="w-full rounded-md border border-zinc-700 bg-zinc-950/80 px-2 py-1 text-xs text-zinc-100 outline-none focus:border-amber-400"
|
className="w-full rounded-md border border-zinc-700 bg-zinc-950/80 px-2 py-1 text-xs text-zinc-100 outline-none focus:border-amber-400"
|
||||||
@@ -293,9 +268,9 @@ export default function MappingAdminPage() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<option value="">Select part role…</option>
|
<option value="">Select part role…</option>
|
||||||
{PART_ROLE_OPTIONS.map((pr) => (
|
{options.map((opt) => (
|
||||||
<option key={pr} value={pr}>
|
<option key={opt.value} value={opt.value}>
|
||||||
{pr}
|
{opt.label} ({opt.value})
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@@ -34,52 +34,50 @@ export default function MerchantsAdminPage() {
|
|||||||
|
|
||||||
// --- load merchants ---
|
// --- load merchants ---
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
const controller = new AbortController();
|
||||||
|
|
||||||
async function loadMerchants() {
|
async function loadMerchants() {
|
||||||
try {
|
try {
|
||||||
console.log("Loading merchants from:", `${API_BASE_URL}/admin/merchants`);
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
const res = await fetch(`${API_BASE_URL}/admin/merchants`, {
|
|
||||||
|
const url = `${API_BASE_URL}/api/admin/merchants`;
|
||||||
|
console.log("Loading merchants from:", url);
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers: {
|
headers: { Accept: "application/json" },
|
||||||
Accept: "application/json",
|
signal: controller.signal,
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("Merchants response status:", res.status);
|
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const text = await res.text().catch(() => "");
|
const text = await res.text().catch(() => "");
|
||||||
throw new Error(`HTTP ${res.status} ${res.statusText} – ${text}`);
|
throw new Error(`HTTP ${res.status} ${res.statusText} – ${text}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const data: MerchantAdminDto[] = await res.json();
|
const data: MerchantAdminDto[] = await res.json();
|
||||||
setMerchants(data);
|
setMerchants(data);
|
||||||
setError(null);
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
if (err?.name === "AbortError") return;
|
||||||
console.error("Error loading merchants", err);
|
console.error("Error loading merchants", err);
|
||||||
setError(err.message ?? "Failed to load merchants");
|
setError(err?.message ?? "Failed to load merchants");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
loadMerchants();
|
|
||||||
|
|
||||||
return () => {
|
loadMerchants();
|
||||||
cancelled = true;
|
return () => controller.abort();
|
||||||
};
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// --- local field editing ---
|
// --- local field editing ---
|
||||||
const updateMerchantField = <K extends keyof MerchantAdminDto>(
|
const updateMerchantField = <K extends keyof MerchantAdminDto>(
|
||||||
id: number,
|
id: number,
|
||||||
field: K,
|
field: K,
|
||||||
value: MerchantAdminDto[K],
|
value: MerchantAdminDto[K]
|
||||||
) => {
|
) => {
|
||||||
setMerchants((prev) =>
|
setMerchants((prev) =>
|
||||||
prev.map((m) => (m.id === id ? { ...m, [field]: value } : m)),
|
prev.map((m) => (m.id === id ? { ...m, [field]: value } : m))
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -93,7 +91,7 @@ export default function MerchantsAdminPage() {
|
|||||||
setError(null);
|
setError(null);
|
||||||
setBanner(null);
|
setBanner(null);
|
||||||
|
|
||||||
const res = await fetch(`${API_BASE_URL}/admin/merchants/${id}`, {
|
const res = await fetch(`${API_BASE_URL}/api/admin/merchants/${id}`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -108,20 +106,16 @@ export default function MerchantsAdminPage() {
|
|||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const text = await res.text().catch(() => "");
|
const text = await res.text().catch(() => "");
|
||||||
console.error("Merchant save failed", res.status, text);
|
console.error("Merchant save failed", res.status, text);
|
||||||
throw new Error(
|
throw new Error(`Save failed (${res.status})${text ? `: ${text}` : ""}`);
|
||||||
`Save failed (${res.status})${text ? `: ${text}` : ""}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const updated: MerchantAdminDto = await res.json();
|
const updated: MerchantAdminDto = await res.json();
|
||||||
setMerchants((prev) =>
|
setMerchants((prev) => prev.map((m) => (m.id === id ? updated : m)));
|
||||||
prev.map((m) => (m.id === id ? updated : m)),
|
|
||||||
);
|
|
||||||
|
|
||||||
setBanner("Merchant saved successfully.");
|
setBanner("Merchant saved successfully.");
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.error("Error saving merchant", e);
|
console.error("Error saving merchant", e);
|
||||||
setError(e.message ?? "Failed to save merchant");
|
setError(e?.message ?? "Failed to save merchant");
|
||||||
} finally {
|
} finally {
|
||||||
setSavingId(null);
|
setSavingId(null);
|
||||||
setTimeout(() => setBanner(null), 3000);
|
setTimeout(() => setBanner(null), 3000);
|
||||||
@@ -135,29 +129,27 @@ export default function MerchantsAdminPage() {
|
|||||||
setError(null);
|
setError(null);
|
||||||
setBanner(null);
|
setBanner(null);
|
||||||
|
|
||||||
const res = await fetch(`${API_BASE_URL}/admin/imports/${id}`, {
|
const res = await fetch(`${API_BASE_URL}/api/admin/imports/${id}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const text = await res.text().catch(() => "");
|
const text = await res.text().catch(() => "");
|
||||||
console.error("Full import failed", res.status, text);
|
console.error("Full import failed", res.status, text);
|
||||||
throw new Error(
|
throw new Error(`Import failed (${res.status})${text ? `: ${text}` : ""}`);
|
||||||
`Import failed (${res.status})${text ? `: ${text}` : ""}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setBanner("Full import started successfully.");
|
setBanner("Full import started successfully.");
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.error("Error starting full import", e);
|
console.error("Error starting full import", e);
|
||||||
setError(e.message ?? "Failed to start full import");
|
setError(e?.message ?? "Failed to start full import");
|
||||||
} finally {
|
} finally {
|
||||||
setImportingId(null);
|
setImportingId(null);
|
||||||
setTimeout(() => setBanner(null), 3000);
|
setTimeout(() => setBanner(null), 3000);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- run offer sync (matches /{merchantId}/offers-only) ---
|
// --- run offer sync ---
|
||||||
const handleRunOfferSync = async (id: number) => {
|
const handleRunOfferSync = async (id: number) => {
|
||||||
try {
|
try {
|
||||||
setOfferSyncId(id);
|
setOfferSyncId(id);
|
||||||
@@ -165,24 +157,22 @@ export default function MerchantsAdminPage() {
|
|||||||
setBanner(null);
|
setBanner(null);
|
||||||
|
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`${API_BASE_URL}/admin/imports/${id}/offers-only`,
|
`${API_BASE_URL}/api/admin/imports/${id}/offers-only`,
|
||||||
{
|
{ method: "POST" }
|
||||||
method: "POST",
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const text = await res.text().catch(() => "");
|
const text = await res.text().catch(() => "");
|
||||||
console.error("Offer sync failed", res.status, text);
|
console.error("Offer sync failed", res.status, text);
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Offer sync failed (${res.status})${text ? `: ${text}` : ""}`,
|
`Offer sync failed (${res.status})${text ? `: ${text}` : ""}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
setBanner("Offer sync started successfully.");
|
setBanner("Offer sync started successfully.");
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.error("Error starting offer sync", e);
|
console.error("Error starting offer sync", e);
|
||||||
setError(e.message ?? "Failed to start offer sync");
|
setError(e?.message ?? "Failed to start offer sync");
|
||||||
} finally {
|
} finally {
|
||||||
setOfferSyncId(null);
|
setOfferSyncId(null);
|
||||||
setTimeout(() => setBanner(null), 3000);
|
setTimeout(() => setBanner(null), 3000);
|
||||||
@@ -202,9 +192,9 @@ export default function MerchantsAdminPage() {
|
|||||||
Merchant Feeds <span className="text-amber-300">Admin</span>
|
Merchant Feeds <span className="text-amber-300">Admin</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-2 text-sm text-zinc-400 max-w-2xl">
|
<p className="mt-2 text-sm text-zinc-400 max-w-2xl">
|
||||||
Manage your AvantLink merchants, product feed URLs, and offer
|
Manage your AvantLink merchants, product feed URLs, and offer sync
|
||||||
sync settings. Use this to onboard new merchants and keep feeds
|
settings. Use this to onboard new merchants and keep feeds fresh
|
||||||
fresh without touching SQL.
|
without touching SQL.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -244,6 +234,7 @@ export default function MerchantsAdminPage() {
|
|||||||
<th className="py-2 pl-3 text-right">Actions</th>
|
<th className="py-2 pl-3 text-right">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|
||||||
<tbody className="divide-y divide-zinc-800">
|
<tbody className="divide-y divide-zinc-800">
|
||||||
{merchants.map((m) => (
|
{merchants.map((m) => (
|
||||||
<tr key={m.id} className="align-top">
|
<tr key={m.id} className="align-top">
|
||||||
@@ -257,71 +248,62 @@ export default function MerchantsAdminPage() {
|
|||||||
className="w-full rounded-md border border-zinc-700 bg-zinc-900 px-2 py-1 text-sm text-zinc-50 focus:border-amber-400/70 focus:outline-none focus:ring-0"
|
className="w-full rounded-md border border-zinc-700 bg-zinc-900 px-2 py-1 text-sm text-zinc-50 focus:border-amber-400/70 focus:outline-none focus:ring-0"
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td className="py-2 px-3">
|
<td className="py-2 px-3">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={m.avantlinkMid}
|
value={m.avantlinkMid}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
updateMerchantField(
|
updateMerchantField(m.id, "avantlinkMid", e.target.value)
|
||||||
m.id,
|
|
||||||
"avantlinkMid",
|
|
||||||
e.target.value,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
className="w-full rounded-md border border-zinc-700 bg-zinc-900 px-2 py-1 text-sm text-zinc-50 focus:border-amber-400/70 focus:outline-none focus:ring-0"
|
className="w-full rounded-md border border-zinc-700 bg-zinc-900 px-2 py-1 text-sm text-zinc-50 focus:border-amber-400/70 focus:outline-none focus:ring-0"
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td className="py-2 px-3">
|
<td className="py-2 px-3">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={m.feedUrl ?? ""}
|
value={m.feedUrl ?? ""}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
updateMerchantField(
|
updateMerchantField(m.id, "feedUrl", e.target.value)
|
||||||
m.id,
|
|
||||||
"feedUrl",
|
|
||||||
e.target.value,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
className="w-full rounded-md border border-zinc-700 bg-zinc-900 px-2 py-1 text-xs md:text-sm text-zinc-50 focus:border-amber-400/70 focus:outline-none focus:ring-0"
|
className="w-full rounded-md border border-zinc-700 bg-zinc-900 px-2 py-1 text-xs md:text-sm text-zinc-50 focus:border-amber-400/70 focus:outline-none focus:ring-0"
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td className="py-2 px-3">
|
<td className="py-2 px-3">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={m.offerFeedUrl ?? ""}
|
value={m.offerFeedUrl ?? ""}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
updateMerchantField(
|
updateMerchantField(m.id, "offerFeedUrl", e.target.value || null)
|
||||||
m.id,
|
|
||||||
"offerFeedUrl",
|
|
||||||
e.target.value || null,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
className="w-full rounded-md border border-zinc-700 bg-zinc-900 px-2 py-1 text-xs md:text-sm text-zinc-50 focus:border-amber-400/70 focus:outline-none focus:ring-0"
|
className="w-full rounded-md border border-zinc-700 bg-zinc-900 px-2 py-1 text-xs md:text-sm text-zinc-50 focus:border-amber-400/70 focus:outline-none focus:ring-0"
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td className="py-2 px-3">
|
<td className="py-2 px-3">
|
||||||
<label className="inline-flex items-center gap-2 text-xs text-zinc-300">
|
<label className="inline-flex items-center gap-2 text-xs text-zinc-300">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={m.isActive}
|
checked={m.isActive}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
updateMerchantField(
|
updateMerchantField(m.id, "isActive", e.target.checked)
|
||||||
m.id,
|
|
||||||
"isActive",
|
|
||||||
e.target.checked,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
className="h-4 w-4 rounded border-zinc-600 bg-zinc-900 text-amber-400 focus:ring-amber-400"
|
className="h-4 w-4 rounded border-zinc-600 bg-zinc-900 text-amber-400 focus:ring-amber-400"
|
||||||
/>
|
/>
|
||||||
Active
|
Active
|
||||||
</label>
|
</label>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td className="py-2 px-3 text-xs text-zinc-400 whitespace-nowrap">
|
<td className="py-2 px-3 text-xs text-zinc-400 whitespace-nowrap">
|
||||||
{formatDate(m.lastFullImportAt)}
|
{formatDate(m.lastFullImportAt)}
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td className="py-2 px-3 text-xs text-zinc-400 whitespace-nowrap">
|
<td className="py-2 px-3 text-xs text-zinc-400 whitespace-nowrap">
|
||||||
{formatDate(m.lastOfferSyncAt)}
|
{formatDate(m.lastOfferSyncAt)}
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td className="py-2 pl-3">
|
<td className="py-2 pl-3">
|
||||||
<div className="flex flex-col items-end gap-1">
|
<div className="flex flex-col items-end gap-1">
|
||||||
<button
|
<button
|
||||||
@@ -336,33 +318,27 @@ export default function MerchantsAdminPage() {
|
|||||||
>
|
>
|
||||||
{savingId === m.id ? "Saving…" : "Save"}
|
{savingId === m.id ? "Saving…" : "Save"}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleRunFullImport(m.id)}
|
onClick={() => handleRunFullImport(m.id)}
|
||||||
disabled={importingId === m.id}
|
disabled={importingId === m.id}
|
||||||
className={`rounded-md px-3 py-1.5 text-xs font-medium border border-zinc-700 bg-zinc-900 text-zinc-200 hover:bg-zinc-800 transition-colors ${
|
className={`rounded-md px-3 py-1.5 text-xs font-medium border border-zinc-700 bg-zinc-900 text-zinc-200 hover:bg-zinc-800 transition-colors ${
|
||||||
importingId === m.id
|
importingId === m.id ? "opacity-60 cursor-wait" : ""
|
||||||
? "opacity-60 cursor-wait"
|
|
||||||
: ""
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{importingId === m.id
|
{importingId === m.id ? "Importing…" : "Run Full Import"}
|
||||||
? "Importing…"
|
|
||||||
: "Run Full Import"}
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleRunOfferSync(m.id)}
|
onClick={() => handleRunOfferSync(m.id)}
|
||||||
disabled={offerSyncId === m.id}
|
disabled={offerSyncId === m.id}
|
||||||
className={`rounded-md px-3 py-1.5 text-xs font-medium border border-zinc-700 bg-zinc-900 text-zinc-200 hover:bg-zinc-800 transition-colors ${
|
className={`rounded-md px-3 py-1.5 text-xs font-medium border border-zinc-700 bg-zinc-900 text-zinc-200 hover:bg-zinc-800 transition-colors ${
|
||||||
offerSyncId === m.id
|
offerSyncId === m.id ? "opacity-60 cursor-wait" : ""
|
||||||
? "opacity-60 cursor-wait"
|
|
||||||
: ""
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{offerSyncId === m.id
|
{offerSyncId === m.id ? "Syncing Offers…" : "Run Offer Sync"}
|
||||||
? "Syncing Offers…"
|
|
||||||
: "Run Offer Sync"}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export function BuilderNav({ activeCategoryId }: BuilderNavProps) {
|
|||||||
const currentCategory =
|
const currentCategory =
|
||||||
activeCategoryId ?? searchParams.get("category") ?? undefined;
|
activeCategoryId ?? searchParams.get("category") ?? undefined;
|
||||||
|
|
||||||
const baseHref = "/builder";
|
const baseHref = "/parts";
|
||||||
|
|
||||||
const renderDropdown = (label: string, items: Category[]) => {
|
const renderDropdown = (label: string, items: Category[]) => {
|
||||||
if (!items.length) return null;
|
if (!items.length) return null;
|
||||||
@@ -71,10 +71,10 @@ export function BuilderNav({ activeCategoryId }: BuilderNavProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-6xl mx-auto w-full"> {/* ← aligns with topnav */}
|
<div className="max-w-6xl mx-auto w-full"> {/* ← aligns with topnav */}
|
||||||
<nav className="flex items-center gap-6 border-b border-neutral-900 px-6 py-3 text-sm bg-black/95 backdrop-blur-sm">
|
<nav className="flex items-center gap-6 border-b border-neutral-900 px-6 py-3 text-sm backdrop-blur-sm">
|
||||||
{/* Primary builder link */}
|
{/* Primary builder link */}
|
||||||
<Link
|
<Link
|
||||||
href={baseHref}
|
href={'/builder'}
|
||||||
className="font-semibold text-neutral-100 hover:text-white tracking-[0.25em] uppercase text-[11px]"
|
className="font-semibold text-neutral-100 hover:text-white tracking-[0.25em] uppercase text-[11px]"
|
||||||
>
|
>
|
||||||
Builder
|
Builder
|
||||||
|
|||||||
@@ -1,87 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import Link from "next/link";
|
|
||||||
import type { Category, Part } from "@/types/gunbuilder";
|
|
||||||
import { PartCard } from "@/components/PartCard";
|
|
||||||
|
|
||||||
interface CategoryColumnProps {
|
|
||||||
category: Category;
|
|
||||||
parts: Part[];
|
|
||||||
selectedPartId?: string;
|
|
||||||
onSelectPart: (partId: string) => void;
|
|
||||||
platform?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CategoryColumn({
|
|
||||||
category,
|
|
||||||
parts,
|
|
||||||
selectedPartId,
|
|
||||||
onSelectPart,
|
|
||||||
platform,
|
|
||||||
}: CategoryColumnProps) {
|
|
||||||
// Show selected part if available, otherwise show placeholder
|
|
||||||
const displayedPart = selectedPartId
|
|
||||||
? parts.find((p) => p.id === selectedPartId)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col h-full">
|
|
||||||
<div className="mb-3">
|
|
||||||
<h2 className="text-xs font-semibold tracking-[0.15em] text-zinc-400 uppercase">
|
|
||||||
{category.name}
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 overflow-y-auto pr-1">
|
|
||||||
{parts.length === 0 && (
|
|
||||||
<p className="text-xs text-zinc-500">No parts available yet.</p>
|
|
||||||
)}
|
|
||||||
{!displayedPart && parts.length > 0 && (
|
|
||||||
<div className="w-full border border-zinc-700 rounded-md p-3 mb-2 flex items-center justify-between gap-3">
|
|
||||||
<p className="text-sm text-zinc-500">No part selected</p>
|
|
||||||
<Link
|
|
||||||
href={{
|
|
||||||
pathname: `/builder/${category.id}`,
|
|
||||||
query: platform ? { platform } : {},
|
|
||||||
}}
|
|
||||||
className="inline-flex w-full items-center justify-center rounded-md border border-zinc-700 bg-zinc-900 px-4 py-2 text-xs font-semibold text-zinc-100 hover:bg-zinc-800 hover:border-zinc-600 transition-colors"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
className="w-4 h-4"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M12 4v16m8-8H4"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Choose a Part
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{displayedPart && (
|
|
||||||
<PartCard
|
|
||||||
key={displayedPart.id}
|
|
||||||
part={displayedPart}
|
|
||||||
selected={displayedPart.id === selectedPartId}
|
|
||||||
onSelect={() => onSelectPart(displayedPart.id)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{parts.length >= 1 && (
|
|
||||||
<Link
|
|
||||||
href={{
|
|
||||||
pathname: `/builder/${category.id}`,
|
|
||||||
query: platform ? { platform } : {},
|
|
||||||
}}
|
|
||||||
className="mt-2 w-full rounded-md border border-zinc-700 bg-zinc-900/50 px-3 py-2 text-xs font-medium text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600 transition-colors text-center"
|
|
||||||
>
|
|
||||||
View All ({parts.length})
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { PriceHistoryPoint } from "@/app/(builder)/builder/[categoryId]/[partId]/data";
|
import type { PriceHistoryPoint } from "@/app/(builder)/parts/[category]/[partId]/data";
|
||||||
|
|
||||||
interface PricingHistoryGraphProps {
|
interface PricingHistoryGraphProps {
|
||||||
data: PriceHistoryPoint[];
|
data: PriceHistoryPoint[];
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { RetailerOffer } from "@/app/(builder)/builder/[categoryId]/[partId]/data";
|
import type { RetailerOffer } from "@/app/(builder)/parts/[category]/[partId]/data";
|
||||||
|
|
||||||
interface RetailersListProps {
|
interface RetailersListProps {
|
||||||
retailers: RetailerOffer[];
|
retailers: RetailerOffer[];
|
||||||
|
|||||||
+32
-7
@@ -1,11 +1,26 @@
|
|||||||
import type { Category, CategoryId } from "@/types/gunbuilder";
|
import type { Category } from "@/types/gunbuilder";
|
||||||
import { CATEGORY_SLUGS } from "@/types/gunbuilder";
|
import { CATEGORY_SLUGS } from "@/types/gunbuilder";
|
||||||
|
|
||||||
export const CATEGORIES: Category[] = [
|
export const CATEGORIES: Category[] = [
|
||||||
// Lower group
|
// Lower group
|
||||||
{ id: "lower", name: "Stripped Lowers", group: "lower", slug: CATEGORY_SLUGS.lower },
|
{
|
||||||
{ id: "complete-lower", name: "Complete Lower", group: "lower", slug: CATEGORY_SLUGS["complete-lower"] },
|
id: "lower-receiver",
|
||||||
{ id: "lower-parts", name: "Lower Parts Kit", group: "lower", slug: CATEGORY_SLUGS["lower-parts"] },
|
name: "Stripped Lower",
|
||||||
|
group: "lower",
|
||||||
|
slug: CATEGORY_SLUGS["lower-receiver"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "complete-lower",
|
||||||
|
name: "Complete Lower",
|
||||||
|
group: "lower",
|
||||||
|
slug: CATEGORY_SLUGS["complete-lower"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "lower-parts",
|
||||||
|
name: "Lower Parts Kit",
|
||||||
|
group: "lower",
|
||||||
|
slug: CATEGORY_SLUGS["lower-parts"],
|
||||||
|
},
|
||||||
{ id: "trigger", name: "Trigger / Fire Control", group: "lower", slug: CATEGORY_SLUGS.trigger },
|
{ id: "trigger", name: "Trigger / Fire Control", group: "lower", slug: CATEGORY_SLUGS.trigger },
|
||||||
{ id: "grip", name: "Pistol Grip", group: "lower", slug: CATEGORY_SLUGS.grip },
|
{ id: "grip", name: "Pistol Grip", group: "lower", slug: CATEGORY_SLUGS.grip },
|
||||||
{ id: "safety", name: "Safety / Selector", group: "lower", slug: CATEGORY_SLUGS.safety },
|
{ id: "safety", name: "Safety / Selector", group: "lower", slug: CATEGORY_SLUGS.safety },
|
||||||
@@ -13,8 +28,18 @@ export const CATEGORIES: Category[] = [
|
|||||||
{ id: "stock", name: "Stock / Brace", group: "lower", slug: CATEGORY_SLUGS.stock },
|
{ id: "stock", name: "Stock / Brace", group: "lower", slug: CATEGORY_SLUGS.stock },
|
||||||
|
|
||||||
// Upper group
|
// Upper group
|
||||||
{ id: "upper", name: "Stripped Upper", group: "upper", slug: CATEGORY_SLUGS.upper },
|
{
|
||||||
{ id: "complete-upper", name: "Complete Upper", group: "upper", slug: CATEGORY_SLUGS["complete-upper"] },
|
id: "upper-receiver",
|
||||||
|
name: "Stripped Upper",
|
||||||
|
group: "upper",
|
||||||
|
slug: CATEGORY_SLUGS["upper-receiver"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "complete-upper",
|
||||||
|
name: "Complete Upper",
|
||||||
|
group: "upper",
|
||||||
|
slug: CATEGORY_SLUGS["complete-upper"],
|
||||||
|
},
|
||||||
{ id: "barrel", name: "Barrels", group: "upper", slug: CATEGORY_SLUGS.barrel },
|
{ id: "barrel", name: "Barrels", group: "upper", slug: CATEGORY_SLUGS.barrel },
|
||||||
{ id: "handguard", name: "Handguards / Rails", group: "upper", slug: CATEGORY_SLUGS.handguard },
|
{ id: "handguard", name: "Handguards / Rails", group: "upper", slug: CATEGORY_SLUGS.handguard },
|
||||||
{ id: "gas-block", name: "Gas Block", group: "upper", slug: CATEGORY_SLUGS["gas-block"] },
|
{ id: "gas-block", name: "Gas Block", group: "upper", slug: CATEGORY_SLUGS["gas-block"] },
|
||||||
@@ -34,4 +59,4 @@ export const CATEGORIES: Category[] = [
|
|||||||
{ id: "sling", name: "Slings & Mounts", group: "accessories", slug: CATEGORY_SLUGS.sling },
|
{ id: "sling", name: "Slings & Mounts", group: "accessories", slug: CATEGORY_SLUGS.sling },
|
||||||
{ id: "rail-accessory", name: "Rail Accessories", group: "accessories", slug: CATEGORY_SLUGS["rail-accessory"] },
|
{ id: "rail-accessory", name: "Rail Accessories", group: "accessories", slug: CATEGORY_SLUGS["rail-accessory"] },
|
||||||
{ id: "tools", name: "Tools & Maintenance", group: "accessories", slug: CATEGORY_SLUGS.tools },
|
{ id: "tools", name: "Tools & Maintenance", group: "accessories", slug: CATEGORY_SLUGS.tools },
|
||||||
];
|
];
|
||||||
+56
-10
@@ -1,11 +1,27 @@
|
|||||||
import type { CategoryId } from "@/types/gunbuilder";
|
import type { CategoryId } from "@/types/gunbuilder";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize backend part roles into a canonical kebab-case form.
|
||||||
|
* - trims whitespace
|
||||||
|
* - lowercases
|
||||||
|
* - converts snake_case / ENUM_CASE to kebab-case
|
||||||
|
*/
|
||||||
|
export const normalizePartRole = (role: string) =>
|
||||||
|
role.trim().toLowerCase().replace(/_/g, "-");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Maps canonical kebab-case CategoryIds to their associated part roles from the backend.
|
* Maps canonical kebab-case CategoryIds to their associated part roles from the backend.
|
||||||
|
*
|
||||||
|
* Notes:
|
||||||
|
* - Keep the list here as the *source of truth*.
|
||||||
|
* - The derived `PART_ROLE_TO_CATEGORY` map below stores both the original and normalized keys.
|
||||||
*/
|
*/
|
||||||
export const CATEGORY_TO_PART_ROLES: Partial<Record<CategoryId, string[]>> = {
|
export const CATEGORY_TO_PART_ROLES: Partial<Record<CategoryId, string[]>> = {
|
||||||
// ===== UPPER =====
|
// ===== UPPER =====
|
||||||
upper: ["upper-receiver", "upper"],
|
"upper-receiver": ["upper-receiver"],
|
||||||
|
// (optional) Back-compat if anything still uses the old ids:
|
||||||
|
// upper: ["upper-receiver", "upper"],
|
||||||
|
|
||||||
"complete-upper": ["complete-upper"],
|
"complete-upper": ["complete-upper"],
|
||||||
barrel: ["barrel"],
|
barrel: ["barrel"],
|
||||||
"gas-block": ["gas-block"],
|
"gas-block": ["gas-block"],
|
||||||
@@ -17,7 +33,10 @@ export const CATEGORY_TO_PART_ROLES: Partial<Record<CategoryId, string[]>> = {
|
|||||||
bcg: ["bcg", "bolt-carrier-group"],
|
bcg: ["bcg", "bolt-carrier-group"],
|
||||||
|
|
||||||
// ===== LOWER =====
|
// ===== LOWER =====
|
||||||
|
"lower-receiver": ["lower-receiver", "lower", "LOWER_RECEIVER_STRIPPED"],
|
||||||
|
// (optional) Back-compat if anything still uses the old ids:
|
||||||
lower: ["lower-receiver", "lower", "LOWER_RECEIVER_STRIPPED"],
|
lower: ["lower-receiver", "lower", "LOWER_RECEIVER_STRIPPED"],
|
||||||
|
|
||||||
"complete-lower": ["complete-lower"],
|
"complete-lower": ["complete-lower"],
|
||||||
"lower-parts": ["lower-parts-kit", "lower-parts"],
|
"lower-parts": ["lower-parts-kit", "lower-parts"],
|
||||||
trigger: ["trigger", "trigger-kit"],
|
trigger: ["trigger", "trigger-kit"],
|
||||||
@@ -32,24 +51,51 @@ export const CATEGORY_TO_PART_ROLES: Partial<Record<CategoryId, string[]>> = {
|
|||||||
|
|
||||||
// ===== ACCESSORIES =====
|
// ===== ACCESSORIES =====
|
||||||
magazine: ["magazine", "mag", "magazine-ar15", "magazine-308", "drum-magazine"],
|
magazine: ["magazine", "mag", "magazine-ar15", "magazine-308", "drum-magazine"],
|
||||||
"weapon-light": ["weapon-light", "light", "weapon-light-laser", "light-laser-combo", "laser"],
|
"weapon-light": [
|
||||||
|
"weapon-light",
|
||||||
|
"light",
|
||||||
|
"weapon-light-laser",
|
||||||
|
"light-laser-combo",
|
||||||
|
"laser",
|
||||||
|
],
|
||||||
foregrip: ["vertical-grip", "angled-foregrip", "foregrip", "handstop"],
|
foregrip: ["vertical-grip", "angled-foregrip", "foregrip", "handstop"],
|
||||||
bipod: ["bipod"],
|
bipod: ["bipod"],
|
||||||
sling: ["sling", "sling-mount", "sling-swivel", "qd-sling-mount"],
|
sling: ["sling", "sling-mount", "sling-swivel", "qd-sling-mount"],
|
||||||
"rail-accessory": ["rail-section", "picatinny-rail-section", "m-lok-rail-section", "keymod-rail-section", "rail-cover", "rail-panel"],
|
"rail-accessory": [
|
||||||
tools: ["tool", "armorer-tool", "armorer-wrench", "cleaning-kit", "bore-snake", "vise-block", "torque-wrench"],
|
"rail-section",
|
||||||
|
"picatinny-rail-section",
|
||||||
|
"m-lok-rail-section",
|
||||||
|
"keymod-rail-section",
|
||||||
|
"rail-cover",
|
||||||
|
"rail-panel",
|
||||||
|
],
|
||||||
|
tools: [
|
||||||
|
"tool",
|
||||||
|
"armorer-tool",
|
||||||
|
"armorer-wrench",
|
||||||
|
"cleaning-kit",
|
||||||
|
"bore-snake",
|
||||||
|
"vise-block",
|
||||||
|
"torque-wrench",
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse lookup: backend partRole -> CategoryId.
|
||||||
|
*
|
||||||
|
* Implementation details:
|
||||||
|
* - Stores BOTH the original role and its normalized version as keys.
|
||||||
|
* - De-dupes role lists per category.
|
||||||
|
*/
|
||||||
export const PART_ROLE_TO_CATEGORY: Record<string, CategoryId> = Object.entries(
|
export const PART_ROLE_TO_CATEGORY: Record<string, CategoryId> = Object.entries(
|
||||||
CATEGORY_TO_PART_ROLES
|
CATEGORY_TO_PART_ROLES
|
||||||
).reduce((acc, [categoryId, roles]) => {
|
).reduce((acc, [categoryId, roles]) => {
|
||||||
for (const role of roles) {
|
const unique = Array.from(new Set(roles ?? []));
|
||||||
// store the original string
|
|
||||||
acc[role] = categoryId as CategoryId;
|
|
||||||
|
|
||||||
// also store a normalized version (handles ENUM_CASE and snake_case)
|
for (const role of unique) {
|
||||||
const normalized = role.trim().toLowerCase().replace(/_/g, "-");
|
acc[role] = categoryId as CategoryId;
|
||||||
acc[normalized] = categoryId as CategoryId;
|
acc[normalizePartRole(role)] = categoryId as CategoryId;
|
||||||
}
|
}
|
||||||
|
|
||||||
return acc;
|
return acc;
|
||||||
}, {} as Record<string, CategoryId>);
|
}, {} as Record<string, CategoryId>);
|
||||||
+4
-4
@@ -11,7 +11,7 @@ export type CategoryGroup = "lower" | "upper" | "accessories";
|
|||||||
*/
|
*/
|
||||||
export type CategoryId =
|
export type CategoryId =
|
||||||
// ===== LOWER =====
|
// ===== LOWER =====
|
||||||
| "lower"
|
| "lower-receiver"
|
||||||
| "complete-lower"
|
| "complete-lower"
|
||||||
| "lower-parts"
|
| "lower-parts"
|
||||||
| "trigger"
|
| "trigger"
|
||||||
@@ -21,7 +21,7 @@ export type CategoryId =
|
|||||||
| "stock"
|
| "stock"
|
||||||
|
|
||||||
// ===== UPPER =====
|
// ===== UPPER =====
|
||||||
| "upper"
|
| "upper-receiver"
|
||||||
| "complete-upper"
|
| "complete-upper"
|
||||||
| "barrel"
|
| "barrel"
|
||||||
| "handguard"
|
| "handguard"
|
||||||
@@ -84,7 +84,7 @@ export interface Part {
|
|||||||
*/
|
*/
|
||||||
export const CATEGORY_SLUGS = {
|
export const CATEGORY_SLUGS = {
|
||||||
// LOWER
|
// LOWER
|
||||||
lower: "lower",
|
"lower-receiver": "lower-receiver",
|
||||||
"complete-lower": "complete-lower",
|
"complete-lower": "complete-lower",
|
||||||
"lower-parts": "lower-parts",
|
"lower-parts": "lower-parts",
|
||||||
trigger: "trigger",
|
trigger: "trigger",
|
||||||
@@ -94,7 +94,7 @@ export const CATEGORY_SLUGS = {
|
|||||||
stock: "stock",
|
stock: "stock",
|
||||||
|
|
||||||
// UPPER
|
// UPPER
|
||||||
upper: "upper",
|
"upper-receiver": "upper-receiver",
|
||||||
"complete-upper": "complete-upper",
|
"complete-upper": "complete-upper",
|
||||||
barrel: "barrel",
|
barrel: "barrel",
|
||||||
handguard: "handguard",
|
handguard: "handguard",
|
||||||
|
|||||||
Reference in New Issue
Block a user