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:
2025-12-15 20:59:19 -05:00
parent 0f10ff4e09
commit 607939c468
16 changed files with 1538 additions and 1065 deletions
@@ -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 couldnt 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&apos;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>
);
}
+3 -23
View File
@@ -1,26 +1,6 @@
// app/(builder)/layout.tsx
// app/(builder)/builder/layout.tsx
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, datadriven way to build your next firearm.",
};
export default function BuilderLayout({ children }: { children: ReactNode }) {
return (
<div className="min-h-screen bg-neutral-950 text-zinc-50">
<AuthProvider>
<BuilderNav />
<main className="min-h-screen">{children}</main>
</AuthProvider>
</div>
);
}
return <>{children}</>;
}
+389 -173
View File
@@ -1,6 +1,6 @@
"use client";
import { useMemo, useState, useEffect } from "react";
import { useMemo, useState, useEffect, useCallback, useRef } from "react";
import Link from "next/link";
import { useSearchParams, useRouter } from "next/navigation";
import { CATEGORIES } from "@/data/gunbuilderParts";
@@ -16,7 +16,7 @@ type GunbuilderProductFromApi = {
platform: string;
partRole: string;
price: number | null;
mainImageUrl: string | null;
imageUrl: string | null;
buyUrl: string | null;
};
@@ -56,7 +56,7 @@ const CATEGORY_GROUPS: {
description:
"Everything from the serialized lower to small parts, fire control, and core controls.",
categoryIds: [
"lower",
"lower-receiver",
"complete-lower",
"lower-parts",
"trigger",
@@ -72,7 +72,7 @@ const CATEGORY_GROUPS: {
description:
"Barrel, upper, gas system, and the parts that keep the rifle cycling.",
categoryIds: [
"upper",
"upper-receiver",
"complete-upper",
"bcg",
"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() {
const searchParams = useSearchParams();
const router = useRouter();
const [parts, setParts] = useState<Part[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const isValidPlatform = (
value: string | null
): value is (typeof PLATFORMS)[number] =>
@@ -119,6 +148,7 @@ export default function GunbuilderPage() {
const initial = new URLSearchParams(window.location.search).get("platform");
return isValidPlatform(initial) ? initial : "AR-15";
});
const [build, setBuild] = useState<BuildState>(() => {
if (typeof window !== "undefined") {
const stored = localStorage.getItem(STORAGE_KEY);
@@ -132,9 +162,245 @@ export default function GunbuilderPage() {
}
return {};
});
const [shareStatus, setShareStatus] = useState<string | null>(null);
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(() => {
const controller = new AbortController();
@@ -143,14 +409,10 @@ export default function GunbuilderPage() {
setLoading(true);
setError(null);
// 1) Platform-scoped products
const scopedUrl = `${API_BASE_URL}/api/products?platform=${encodeURIComponent(
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 [scopedRes, universalRes] = await Promise.all([
@@ -172,7 +434,6 @@ export default function GunbuilderPage() {
universalData = await universalRes.json();
}
// Normalize both lists
const normalize = (data: GunbuilderProductFromApi[]): Part[] =>
data
.map((p): Part | null => {
@@ -181,11 +442,9 @@ export default function GunbuilderPage() {
.toLowerCase()
.replace(/_/g, "-");
const categoryId = PART_ROLE_TO_CATEGORY[normalizedRole];
const categoryId = resolveCategoryId(normalizedRole);
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 (
data === universalData &&
!UNIVERSAL_CATEGORIES.has(categoryId)
@@ -194,13 +453,15 @@ export default function GunbuilderPage() {
}
const buyUrl = p.buyUrl ?? undefined;
return {
id: String(p.id),
categoryId,
name: p.name,
brand: p.brand,
price: p.price ?? 0,
imageUrl: p.mainImageUrl ?? undefined,
imageUrl:
((p as any).imageUrl ?? (p as any).mainImageUrl) ?? undefined,
affiliateUrl: buyUrl,
url: buyUrl,
notes: undefined,
@@ -211,7 +472,6 @@ export default function GunbuilderPage() {
const scopedParts = normalize(scopedData);
const universalParts = normalize(universalData);
// Merge + de-dupe by (categoryId + id)
const seen = new Set<string>();
const merged: Part[] = [];
for (const p of [...scopedParts, ...universalParts]) {
@@ -231,16 +491,34 @@ export default function GunbuilderPage() {
}
fetchProducts();
return () => controller.abort();
}, [platform]);
// ✅ Persist build state whenever it changes
useEffect(() => {
// When the platform changes, clear ONLY platform-scoped selections.
// Keep universal accessories (optic/light/sling/etc) so the user doesn't lose them.
setBuild((prev) => {
if (typeof window === "undefined") return;
try {
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 = {};
for (const [categoryId, partId] of Object.entries(prev)) {
for (const [categoryId, partId] of Object.entries(prevBuild)) {
const cid = categoryId as CategoryId;
if (UNIVERSAL_CATEGORIES.has(cid)) {
next[cid] = partId;
@@ -250,36 +528,64 @@ export default function GunbuilderPage() {
});
}, [platform]);
// Handle URL query parameter for part selection (?select=upper:165)
// Handle URL query parameters:
// - ?select=categoryId:partId
// - ?remove=categoryId
useEffect(() => {
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) {
const [categoryId, partId] = selectParam.split(":");
if (categoryId && partId && CATEGORIES.some((c) => c.id === categoryId)) {
setBuild((prev) => {
const updated = {
...prev,
[categoryId as CategoryId]: partId,
};
if (typeof window !== "undefined") {
localStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
}
return updated;
});
const [categoryIdRaw, partId] = selectParam.split(":");
const requestedCategoryId = categoryIdRaw as CategoryId;
const qp = searchParams.get("platform");
const nextPlatform = isValidPlatform(qp) ? qp : platform;
router.replace(
`/builder?platform=${encodeURIComponent(nextPlatform)}`,
{
scroll: false,
}
);
if (requestedCategoryId && partId) {
handleSelectPart(requestedCategoryId, partId, { confirm: 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(() => {
const qp = searchParams.get("platform");
if (isValidPlatform(qp) && qp !== platform) {
@@ -287,49 +593,10 @@ export default function GunbuilderPage() {
}
}, [searchParams, platform]);
// Persist build state to localStorage whenever it 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]
);
// Build share URL whenever build changes
useEffect(() => {
if (typeof window === "undefined") return;
// If no parts selected, clear the share URL
if (Object.keys(build).length === 0) {
setShareUrl("");
return;
@@ -339,28 +606,13 @@ export default function GunbuilderPage() {
const payload = JSON.stringify(build);
const encoded = window.btoa(payload);
const origin = window.location?.origin ?? "";
const url = `${origin}/builder/build?build=${encodeURIComponent(
encoded
)}`;
const url = `${origin}/builder/build?build=${encodeURIComponent(encoded)}`;
setShareUrl(url);
} catch {
setShareUrl("");
}
}, [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
const summaryCategoryOrder: CategoryId[] = useMemo(
() =>
@@ -443,13 +695,11 @@ export default function GunbuilderPage() {
});
setShareStatus("Share dialog opened.");
} catch {
// User canceled or share failed; keep it quiet but let them know
setShareStatus(
"Share canceled or unavailable. You can copy the link instead."
);
}
} else {
// Fallback to copying the link
await handleCopyLink();
}
};
@@ -482,10 +732,10 @@ export default function GunbuilderPage() {
const next = e.target.value as (typeof PLATFORMS)[number];
setPlatform(next);
// Keep URL in sync so navigation + refresh preserve platform
const qp = new URLSearchParams(searchParams.toString());
qp.set("platform", next);
qp.delete("select");
qp.delete("remove");
router.replace(`/builder?${qp.toString()}`, {
scroll: false,
@@ -509,7 +759,7 @@ export default function GunbuilderPage() {
{/* 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">
{/* Left: title + totals + primary actions */}
{/* Left */}
<div className="flex flex-col gap-2">
<h2 className="text-xs font-semibold uppercase tracking-[0.16em] text-amber-300">
My Build Breakdown
@@ -524,10 +774,12 @@ export default function GunbuilderPage() {
</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">
<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 ${
selectedParts.length === 0
? "opacity-40 cursor-not-allowed pointer-events-none"
@@ -536,6 +788,7 @@ export default function GunbuilderPage() {
>
Build Summary
</Link>
<button
type="button"
onClick={handleShare}
@@ -548,6 +801,7 @@ export default function GunbuilderPage() {
>
Copy Build Summary
</button>
<button
type="button"
onClick={() => {
@@ -558,9 +812,7 @@ export default function GunbuilderPage() {
}}
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 ${
selectedParts.length === 0
? "opacity-40 cursor-not-allowed"
: ""
selectedParts.length === 0 ? "opacity-40 cursor-not-allowed" : ""
}`}
>
Clear Build
@@ -568,7 +820,7 @@ export default function GunbuilderPage() {
</div>
</div>
{/* Right: Share Your Build */}
{/* Right */}
<div className="flex flex-col gap-3 md:items-end md:text-right md:flex-1">
{selectedParts.length > 0 && shareUrl && (
<div className="w-full md:w-auto">
@@ -576,8 +828,7 @@ export default function GunbuilderPage() {
Share Your Build
</h3>
<p className="mt-1 text-[0.7rem] text-zinc-500">
Share this link to let others view your build or bookmark it
to come back later.
Share this link to let others view your build or bookmark it to come back later.
</p>
<div className="mt-2 flex flex-col gap-2 md:flex-row md:items-center">
<div className="flex-1">
@@ -652,9 +903,8 @@ export default function GunbuilderPage() {
{/* Layout */}
<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">
Work top-down through the major sections. Each row shows your
current pick for that part type, with price and a direct buy linkor
a quick way to choose a part if you haven&apos;t picked one yet.
Work top-down through the major sections. Each row shows your current pick for that part type,
with price and a direct buy linkor a quick way to choose a part if you haven&apos;t picked one yet.
</p>
{loading && (
@@ -662,8 +912,7 @@ export default function GunbuilderPage() {
)}
{error && !loading && (
<p className="text-sm text-red-400">
{error} check that the Ballistic API is running and CORS is
configured.
{error} check that the Ballistic API is running and CORS is configured.
</p>
)}
@@ -674,9 +923,7 @@ export default function GunbuilderPage() {
group.categoryIds.includes(c.id as CategoryId)
);
if (groupCategories.length === 0) {
return null;
}
if (groupCategories.length === 0) return null;
return (
<div key={group.id} className="space-y-3">
@@ -717,14 +964,17 @@ export default function GunbuilderPage() {
</th>
</tr>
</thead>
<tbody>
{groupCategories.map((category) => {
const categoryParts =
partsByCategory[category.id] ?? [];
const categoryParts = partsByCategory[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;
return (
@@ -732,7 +982,7 @@ export default function GunbuilderPage() {
key={category.id}
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">
<div className="font-medium text-zinc-100">
{category.name}
@@ -764,31 +1014,33 @@ export default function GunbuilderPage() {
: "—"}
</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">
{/* 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>
{/* 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">
<div className="flex justify-end gap-2">
{selectedPart && selectedPart.url ? (
<>
<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"
aria-label="Change part"
title="Change part"
>
<Pencil className="h-3.5 w-3.5 text-zinc-300" />
</Link>
<a
href={selectedPart.url}
target="_blank"
@@ -803,7 +1055,7 @@ export default function GunbuilderPage() {
) : hasParts ? (
<Link
href={{
pathname: `/builder/${category.id}`,
pathname: `/parts/${category.id}`,
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"
@@ -821,7 +1073,7 @@ export default function GunbuilderPage() {
d="M12 4v16m8-8H4"
/>
</svg>
<span>Choose a Part</span>
Choose
</Link>
) : (
<span className="text-[0.7rem] text-zinc-600">
@@ -842,43 +1094,7 @@ export default function GunbuilderPage() {
</div>
)}
</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&apos;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&apos;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>
</main>
);
}
}
+9 -4
View File
@@ -1,11 +1,16 @@
import { TopNav } from "@/components/TopNav";
// app/(builder)/layout.tsx
import type { ReactNode } from "react";
import { AuthProvider } from "@/context/AuthContext";
import { BuilderNav } from "@/components/BuilderNav";
export default function BuilderLayout({ children }: { children: ReactNode }) {
return (
<div className="min-h-screen bg-black text-zinc-50">
<TopNav />
{children}
<div className="min-h-screen bg-neutral-950 text-zinc-50">
<AuthProvider>
<BuilderNav />
<main className="min-h-screen">{children}</main>
</AuthProvider>
</div>
);
}
@@ -26,18 +26,18 @@ type UiPart = Part & {
inStock?: boolean;
};
const PLATFORMS = ["AR-15", "AR-10"];
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>>; // categoryId -> partId
const STORAGE_KEY = "gunbuilder-build-state";
// sort options
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
const slugify = (str: string) =>
str
@@ -45,10 +45,13 @@ const slugify = (str: string) =>
.replace(/[^a-z0-9]+/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[] {
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 [];
return Array.from(new Set(roles.map(normalizeRole)));
}
@@ -83,8 +86,9 @@ export default function CategoryPage() {
const [currentPage, setCurrentPage] = useState<number>(1);
const PAGE_SIZE = 24;
// build state for this page, synced with localStorage
const [build, setBuild] = useState<BuildState>(() => {
// build state for this page (read-only), hydrated from localStorage
// 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 {};
try {
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 [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(
() => CATEGORIES.find((c) => c.id === categoryId),
[categoryId]
@@ -156,41 +171,22 @@ export default function CategoryPage() {
return () => controller.abort();
}, [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
const handleTogglePart = (categoryId: CategoryId, partId: string) => {
const isSelected = build[categoryId] === partId;
const qp = new URLSearchParams();
qp.set("platform", platform || "AR-15");
if (isSelected) {
// Remove from build
setBuild((prev) => {
const updated: BuildState = { ...prev };
delete updated[categoryId];
return updated;
});
// Tell the main builder to remove this category selection
qp.set("remove", String(categoryId));
router.push(`/builder?${qp.toString()}`);
return;
}
// Add to build and navigate back to main builder page.
// Pass selection via URL so `/builder` can apply it.
setBuild((prev) => ({
...prev,
[categoryId]: partId,
}));
const qp = new URLSearchParams();
qp.set("platform", platform || "AR-15");
// Tell the main builder to add/replace this category selection
qp.set("select", `${categoryId}:${partId}`);
router.push(`/builder?${qp.toString()}`);
};
@@ -202,6 +198,7 @@ export default function CategoryPage() {
.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 };
@@ -268,9 +265,7 @@ export default function CategoryPage() {
result = result.filter((p) => {
const name = p.name.toLowerCase();
const brand = p.brand.toLowerCase();
return (
name.includes(normalizedQuery) || brand.includes(normalizedQuery)
);
return name.includes(normalizedQuery) || brand.includes(normalizedQuery);
});
}
@@ -299,13 +294,11 @@ export default function CategoryPage() {
}
return result;
}, [parts, brandFilter, sortBy, build, categoryId, searchQuery]);
}, [parts, brandFilter, sortBy, build, categoryId, searchQuery, priceBounds, priceRange, inStockOnly]);
const totalPages = useMemo(
() =>
filteredParts.length === 0
? 1
: Math.ceil(filteredParts.length / PAGE_SIZE),
filteredParts.length === 0 ? 1 : Math.ceil(filteredParts.length / PAGE_SIZE),
[filteredParts, PAGE_SIZE]
);
@@ -318,30 +311,33 @@ export default function CategoryPage() {
useEffect(() => {
// whenever the category or filters change, jump back to page 1
setCurrentPage(1);
}, [
categoryId,
brandFilter,
sortBy,
searchQuery,
priceRange,
inStockOnly,
platform,
]);
}, [categoryId, brandFilter, sortBy, searchQuery, priceRange, inStockOnly, platform]);
useEffect(() => {
const nextPlatform = (searchParams.get("platform") as string) || "AR-15";
setPlatform(nextPlatform);
}, [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(() => {
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
);
const end = Math.min(start + paginatedParts.length - 1, filteredParts.length);
return { start, end };
}, [filteredParts.length, currentPage, paginatedParts.length, PAGE_SIZE]);
@@ -354,7 +350,7 @@ export default function CategoryPage() {
Category Not Found
</h1>
<Link
href="/builder"
href={{ pathname: "/builder", query: platform ? { platform } : {} }}
className="text-amber-300 hover:text-amber-200 underline"
>
Return to Builder
@@ -371,7 +367,7 @@ export default function CategoryPage() {
{/* Header */}
<header className="mb-6">
<Link
href="/builder"
href={{ pathname: "/builder", query: platform ? { platform } : {} }}
className="text-xs text-zinc-400 hover:text-zinc-300 mb-4 inline-block"
>
Back to The Armory
@@ -385,8 +381,8 @@ export default function CategoryPage() {
{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.
Browse all available {category.name.toLowerCase()} options for your
build, pulled live from your Ballistic backend.
</p>
</div>
{!loading && !error && parts.length > 0 && (
@@ -445,10 +441,7 @@ export default function CategoryPage() {
<button
type="button"
onClick={() =>
setPriceRange({
min: priceBounds.min,
max: priceBounds.max,
})
setPriceRange({ min: priceBounds.min, max: priceBounds.max })
}
className="text-[10px] text-zinc-400 hover:text-zinc-200"
>
@@ -485,10 +478,7 @@ export default function CategoryPage() {
onChange={(e) => {
const value = Number(e.target.value);
setPriceRange((prev) => ({
min: Math.min(
value,
prev.max ?? priceBounds.max ?? value
),
min: Math.min(value, 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);
setPriceRange((prev) => ({
min: prev.min ?? priceBounds.min ?? value,
max: Math.max(
value,
prev.min ?? priceBounds.min ?? value
),
max: Math.max(value, prev.min ?? priceBounds.min ?? value),
}));
}}
className="w-full"
@@ -608,10 +595,7 @@ export default function CategoryPage() {
</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"
>
<label htmlFor="part-search" className="text-xs text-zinc-500">
Search
</label>
<div className="relative">
@@ -635,17 +619,22 @@ export default function CategoryPage() {
)}
</div>
</div>
<div className="flex items-center gap-2">
<label
htmlFor="platform-select"
className="text-xs text-zinc-500"
>
<label htmlFor="platform-select" className="text-xs text-zinc-500">
Platform
</label>
<select
id="platform-select"
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"
>
{PLATFORMS.map((p) => (
@@ -655,19 +644,15 @@ export default function CategoryPage() {
))}
</select>
</div>
<div className="flex items-center gap-2">
<label
htmlFor="sort-by"
className="text-xs text-zinc-500"
>
<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)
}
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>
@@ -726,9 +711,9 @@ export default function CategoryPage() {
<div className="mt-2 flex gap-2">
<Link
href={{
pathname: `/builder/${categoryId}/${
part.id
}-${slugify(part.name)}`,
pathname: `/builder/${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"
@@ -737,9 +722,7 @@ export default function CategoryPage() {
</Link>
<button
type="button"
onClick={() =>
handleTogglePart(categoryId, part.id)
}
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"
@@ -796,9 +779,9 @@ export default function CategoryPage() {
<div className="flex justify-end gap-2">
<Link
href={{
pathname: `/builder/${categoryId}/${
part.id
}-${slugify(part.name)}`,
pathname: `/builder/${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"
@@ -807,18 +790,14 @@ export default function CategoryPage() {
</Link>
<button
type="button"
onClick={() =>
handleTogglePart(categoryId, part.id)
}
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"}
{isSelected ? "Remove from Build" : "Add to Build"}
</button>
</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>
Page{" "}
<span className="font-semibold text-zinc-200">
{currentPage}
</span>{" "}
<span className="font-semibold text-zinc-200">{currentPage}</span>{" "}
of{" "}
<span className="font-semibold text-zinc-200">
{totalPages}
</span>
<span className="font-semibold text-zinc-200">{totalPages}</span>
</div>
<div className="flex gap-2">
<button
@@ -853,9 +828,7 @@ export default function CategoryPage() {
</button>
<button
type="button"
onClick={() =>
setCurrentPage((p) => Math.min(totalPages, p + 1))
}
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"
>
@@ -870,4 +843,4 @@ export default function CategoryPage() {
</div>
</main>
);
}
}
+802
View File
@@ -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
View File
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
@@ -13,123 +13,58 @@ type PendingBucket = {
productCount: number;
};
const PART_ROLE_OPTIONS: string[] = [
// LOWER receivers
"LOWER_RECEIVER_STRIPPED",
"LOWER_RECEIVER_COMPLETE",
"LOWER_RECEIVER_COMPLETE_PISTOL",
"LOWER_RECEIVER_COMPLETE_RIFLE",
"LOWER_RECEIVER_80",
"LOWER_RECEIVER_BILLET",
"LOWER_RECEIVER_FORGED",
"LOWER_RECEIVER_POLYMER",
// LOWER internals & controls
"LOWER_PARTS_KIT",
"LOWER_PARTS_KIT_ENHANCED",
/**
* Canonical part roles (kebab-case) — should match what the importer normalizes to.
* Keep this list tight + intentional so mappings don't create junk roles in the DB.
*/
const PART_ROLE_OPTIONS = [
// Assemblies / receivers
"upper-receiver",
"complete-upper",
"lower-receiver",
"complete-lower",
"TRIGGER",
"TRIGGER_OTHER",
"TRIGGER_COMBAT",
"TRIGGER_MATCH",
"TRIGGER_DROP_IN",
"TRIGGER_SINGLE_STAGE",
"TRIGGER_TWO_STAGE",
"TRIGGER_GUARD",
"PISTOL_GRIP",
"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",
// Upper sub-parts
"bcg",
"barrel",
"gas-block",
"gas-tube",
"muzzle-device",
"suppressor",
"handguard",
"charging-handle",
// 👇 generic / backup muzzle roles
"MUZZLE_DEVICE",
"MUZZLE_DEVICE_OTHER",
"FLASH_HIDER",
"MUZZLE_DEVICE_QD_MOUNT",
"SUPPRESSOR_DIRECT_THREAD",
"SUPPRESSOR_QD",
"SUPPRESSOR_RIFLE",
"SUPPRESSOR_PISTOL",
"CHARGING_HANDLE",
"CHARGING_HANDLE_AMBI",
"CHARGING_HANDLE_GAS_BUSTER",
// Sights / optics
"SIGHTS_BACKUP",
"SIGHTS_IRON_FIXED",
"SIGHTS_IRON_FLIP",
"SIGHTS_OFFSET",
"OPTIC_REDDOT",
"OPTIC_LPVO",
"OPTIC_HOLOGRAPHIC",
"OPTIC_PRISM",
"OPTIC_MAGNIFIER",
// Accessories
"MAGAZINE",
"SLING",
"BIPOD",
"MOUNT_SCOPE",
"MOUNT_OFFSET",
"LIGHT_WEAPON",
"LASER_VISIBLE",
"LASER_IR",
"TOOL",
];
// Lower sub-parts
"lower-parts",
"trigger",
"grip",
"safety",
"buffer",
"stock",
// Sights / optics
"sights",
"optic",
// Accessories / gear
"magazine",
"weapon-light",
"foregrip",
"bipod",
"sling",
"rail-accessory",
"tools",
] as const;
type PartRole = (typeof PART_ROLE_OPTIONS)[number];
function toLabel(role: string) {
// "complete-upper" -> "Complete Upper"
return role
.split("-")
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(" ");
}
export default function MappingAdminPage() {
const [rows, setRows] = useState<PendingBucket[]>([]);
@@ -137,17 +72,33 @@ export default function MappingAdminPage() {
const [savingKey, setSavingKey] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const options = useMemo(
() =>
PART_ROLE_OPTIONS.map((value) => ({
value,
label: toLabel(value),
})),
[]
);
useEffect(() => {
async function load() {
try {
setLoading(true);
setError(null);
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) {
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();
setRows(data);
} catch (e: any) {
@@ -162,9 +113,14 @@ export default function MappingAdminPage() {
}, []);
const handleChangeRole = (idx: number, value: string) => {
// Always store normalized kebab-case (defensive)
const normalized = value
? value.trim().toLowerCase().replace(/_/g, "-")
: "";
setRows((prev) => {
const next = [...prev];
next[idx] = { ...next[idx], mappedPartRole: value || null };
next[idx] = { ...next[idx], mappedPartRole: normalized || null };
return next;
});
};
@@ -172,6 +128,20 @@ export default function MappingAdminPage() {
const handleSave = async (row: PendingBucket) => {
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}`;
try {
setSavingKey(key);
@@ -183,12 +153,15 @@ export default function MappingAdminPage() {
body: JSON.stringify({
merchantId: row.merchantId,
rawCategoryKey: row.rawCategoryKey,
mappedPartRole: row.mappedPartRole,
mappedPartRole: mapped,
}),
});
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
@@ -241,8 +214,8 @@ export default function MappingAdminPage() {
Pending Buckets
</h2>
<span className="text-xs text-zinc-400">
{rows.length} buckets {rows.reduce((s, r) => s + r.productCount, 0)}{" "}
products
{rows.length} buckets {" "}
{rows.reduce((s, r) => s + r.productCount, 0)} products
</span>
</div>
@@ -283,7 +256,9 @@ export default function MappingAdminPage() {
<td className="px-2 py-1 text-zinc-300">
{row.rawCategoryKey}
</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">
<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"
@@ -293,9 +268,9 @@ export default function MappingAdminPage() {
}
>
<option value="">Select part role</option>
{PART_ROLE_OPTIONS.map((pr) => (
<option key={pr} value={pr}>
{pr}
{options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label} ({opt.value})
</option>
))}
</select>
+51 -75
View File
@@ -34,52 +34,50 @@ export default function MerchantsAdminPage() {
// --- load merchants ---
useEffect(() => {
let cancelled = false;
const controller = new AbortController();
async function loadMerchants() {
try {
console.log("Loading merchants from:", `${API_BASE_URL}/admin/merchants`);
const res = await fetch(`${API_BASE_URL}/admin/merchants`, {
setLoading(true);
setError(null);
const url = `${API_BASE_URL}/api/admin/merchants`;
console.log("Loading merchants from:", url);
const res = await fetch(url, {
method: "GET",
headers: {
Accept: "application/json",
},
headers: { Accept: "application/json" },
signal: controller.signal,
});
console.log("Merchants response status:", res.status);
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`HTTP ${res.status} ${res.statusText} ${text}`);
}
const data: MerchantAdminDto[] = await res.json();
setMerchants(data);
setError(null);
} catch (err: any) {
if (err?.name === "AbortError") return;
console.error("Error loading merchants", err);
setError(err.message ?? "Failed to load merchants");
setError(err?.message ?? "Failed to load merchants");
} finally {
setLoading(false);
}
}
loadMerchants();
return () => {
cancelled = true;
};
loadMerchants();
return () => controller.abort();
}, []);
// --- local field editing ---
const updateMerchantField = <K extends keyof MerchantAdminDto>(
id: number,
field: K,
value: MerchantAdminDto[K],
value: MerchantAdminDto[K]
) => {
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);
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",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -108,20 +106,16 @@ export default function MerchantsAdminPage() {
if (!res.ok) {
const text = await res.text().catch(() => "");
console.error("Merchant save failed", res.status, text);
throw new Error(
`Save failed (${res.status})${text ? `: ${text}` : ""}`,
);
throw new Error(`Save failed (${res.status})${text ? `: ${text}` : ""}`);
}
const updated: MerchantAdminDto = await res.json();
setMerchants((prev) =>
prev.map((m) => (m.id === id ? updated : m)),
);
setMerchants((prev) => prev.map((m) => (m.id === id ? updated : m)));
setBanner("Merchant saved successfully.");
} catch (e: any) {
console.error("Error saving merchant", e);
setError(e.message ?? "Failed to save merchant");
setError(e?.message ?? "Failed to save merchant");
} finally {
setSavingId(null);
setTimeout(() => setBanner(null), 3000);
@@ -135,29 +129,27 @@ export default function MerchantsAdminPage() {
setError(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",
});
if (!res.ok) {
const text = await res.text().catch(() => "");
console.error("Full import failed", res.status, text);
throw new Error(
`Import failed (${res.status})${text ? `: ${text}` : ""}`,
);
throw new Error(`Import failed (${res.status})${text ? `: ${text}` : ""}`);
}
setBanner("Full import started successfully.");
} catch (e: any) {
console.error("Error starting full import", e);
setError(e.message ?? "Failed to start full import");
setError(e?.message ?? "Failed to start full import");
} finally {
setImportingId(null);
setTimeout(() => setBanner(null), 3000);
}
};
// --- run offer sync (matches /{merchantId}/offers-only) ---
// --- run offer sync ---
const handleRunOfferSync = async (id: number) => {
try {
setOfferSyncId(id);
@@ -165,24 +157,22 @@ export default function MerchantsAdminPage() {
setBanner(null);
const res = await fetch(
`${API_BASE_URL}/admin/imports/${id}/offers-only`,
{
method: "POST",
},
`${API_BASE_URL}/api/admin/imports/${id}/offers-only`,
{ method: "POST" }
);
if (!res.ok) {
const text = await res.text().catch(() => "");
console.error("Offer sync failed", res.status, text);
throw new Error(
`Offer sync failed (${res.status})${text ? `: ${text}` : ""}`,
`Offer sync failed (${res.status})${text ? `: ${text}` : ""}`
);
}
setBanner("Offer sync started successfully.");
} catch (e: any) {
console.error("Error starting offer sync", e);
setError(e.message ?? "Failed to start offer sync");
setError(e?.message ?? "Failed to start offer sync");
} finally {
setOfferSyncId(null);
setTimeout(() => setBanner(null), 3000);
@@ -202,9 +192,9 @@ export default function MerchantsAdminPage() {
Merchant Feeds <span className="text-amber-300">Admin</span>
</h1>
<p className="mt-2 text-sm text-zinc-400 max-w-2xl">
Manage your AvantLink merchants, product feed URLs, and offer
sync settings. Use this to onboard new merchants and keep feeds
fresh without touching SQL.
Manage your AvantLink merchants, product feed URLs, and offer sync
settings. Use this to onboard new merchants and keep feeds fresh
without touching SQL.
</p>
</div>
</header>
@@ -244,6 +234,7 @@ export default function MerchantsAdminPage() {
<th className="py-2 pl-3 text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-800">
{merchants.map((m) => (
<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"
/>
</td>
<td className="py-2 px-3">
<input
type="text"
value={m.avantlinkMid}
onChange={(e) =>
updateMerchantField(
m.id,
"avantlinkMid",
e.target.value,
)
updateMerchantField(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"
/>
</td>
<td className="py-2 px-3">
<input
type="text"
value={m.feedUrl ?? ""}
onChange={(e) =>
updateMerchantField(
m.id,
"feedUrl",
e.target.value,
)
updateMerchantField(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"
/>
</td>
<td className="py-2 px-3">
<input
type="text"
value={m.offerFeedUrl ?? ""}
onChange={(e) =>
updateMerchantField(
m.id,
"offerFeedUrl",
e.target.value || null,
)
updateMerchantField(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"
/>
</td>
<td className="py-2 px-3">
<label className="inline-flex items-center gap-2 text-xs text-zinc-300">
<input
type="checkbox"
checked={m.isActive}
onChange={(e) =>
updateMerchantField(
m.id,
"isActive",
e.target.checked,
)
updateMerchantField(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"
/>
Active
</label>
</td>
<td className="py-2 px-3 text-xs text-zinc-400 whitespace-nowrap">
{formatDate(m.lastFullImportAt)}
</td>
<td className="py-2 px-3 text-xs text-zinc-400 whitespace-nowrap">
{formatDate(m.lastOfferSyncAt)}
</td>
<td className="py-2 pl-3">
<div className="flex flex-col items-end gap-1">
<button
@@ -336,33 +318,27 @@ export default function MerchantsAdminPage() {
>
{savingId === m.id ? "Saving…" : "Save"}
</button>
<button
type="button"
onClick={() => handleRunFullImport(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 ${
importingId === m.id
? "opacity-60 cursor-wait"
: ""
importingId === m.id ? "opacity-60 cursor-wait" : ""
}`}
>
{importingId === m.id
? "Importing…"
: "Run Full Import"}
{importingId === m.id ? "Importing…" : "Run Full Import"}
</button>
<button
type="button"
onClick={() => handleRunOfferSync(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 ${
offerSyncId === m.id
? "opacity-60 cursor-wait"
: ""
offerSyncId === m.id ? "opacity-60 cursor-wait" : ""
}`}
>
{offerSyncId === m.id
? "Syncing Offers…"
: "Run Offer Sync"}
{offerSyncId === m.id ? "Syncing Offers…" : "Run Offer Sync"}
</button>
</div>
</td>
+3 -3
View File
@@ -24,7 +24,7 @@ export function BuilderNav({ activeCategoryId }: BuilderNavProps) {
const currentCategory =
activeCategoryId ?? searchParams.get("category") ?? undefined;
const baseHref = "/builder";
const baseHref = "/parts";
const renderDropdown = (label: string, items: Category[]) => {
if (!items.length) return null;
@@ -71,10 +71,10 @@ export function BuilderNav({ activeCategoryId }: BuilderNavProps) {
return (
<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 */}
<Link
href={baseHref}
href={'/builder'}
className="font-semibold text-neutral-100 hover:text-white tracking-[0.25em] uppercase text-[11px]"
>
Builder
-87
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
"use client";
import type { PriceHistoryPoint } from "@/app/(builder)/builder/[categoryId]/[partId]/data";
import type { PriceHistoryPoint } from "@/app/(builder)/parts/[category]/[partId]/data";
interface PricingHistoryGraphProps {
data: PriceHistoryPoint[];
+1 -1
View File
@@ -1,6 +1,6 @@
"use client";
import type { RetailerOffer } from "@/app/(builder)/builder/[categoryId]/[partId]/data";
import type { RetailerOffer } from "@/app/(builder)/parts/[category]/[partId]/data";
interface RetailersListProps {
retailers: RetailerOffer[];
+32 -7
View File
@@ -1,11 +1,26 @@
import type { Category, CategoryId } from "@/types/gunbuilder";
import type { Category } from "@/types/gunbuilder";
import { CATEGORY_SLUGS } from "@/types/gunbuilder";
export const CATEGORIES: Category[] = [
// 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-parts", name: "Lower Parts Kit", group: "lower", slug: CATEGORY_SLUGS["lower-parts"] },
{
id: "lower-receiver",
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: "grip", name: "Pistol Grip", group: "lower", slug: CATEGORY_SLUGS.grip },
{ 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 },
// 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: "handguard", name: "Handguards / Rails", group: "upper", slug: CATEGORY_SLUGS.handguard },
{ 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: "rail-accessory", name: "Rail Accessories", group: "accessories", slug: CATEGORY_SLUGS["rail-accessory"] },
{ id: "tools", name: "Tools & Maintenance", group: "accessories", slug: CATEGORY_SLUGS.tools },
];
];
+56 -10
View File
@@ -1,11 +1,27 @@
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.
*
* 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[]>> = {
// ===== 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"],
barrel: ["barrel"],
"gas-block": ["gas-block"],
@@ -17,7 +33,10 @@ export const CATEGORY_TO_PART_ROLES: Partial<Record<CategoryId, string[]>> = {
bcg: ["bcg", "bolt-carrier-group"],
// ===== 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"],
"complete-lower": ["complete-lower"],
"lower-parts": ["lower-parts-kit", "lower-parts"],
trigger: ["trigger", "trigger-kit"],
@@ -32,24 +51,51 @@ export const CATEGORY_TO_PART_ROLES: Partial<Record<CategoryId, string[]>> = {
// ===== ACCESSORIES =====
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"],
bipod: ["bipod"],
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"],
tools: ["tool", "armorer-tool", "armorer-wrench", "cleaning-kit", "bore-snake", "vise-block", "torque-wrench"],
"rail-accessory": [
"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(
CATEGORY_TO_PART_ROLES
).reduce((acc, [categoryId, roles]) => {
for (const role of roles) {
// store the original string
acc[role] = categoryId as CategoryId;
const unique = Array.from(new Set(roles ?? []));
// also store a normalized version (handles ENUM_CASE and snake_case)
const normalized = role.trim().toLowerCase().replace(/_/g, "-");
acc[normalized] = categoryId as CategoryId;
for (const role of unique) {
acc[role] = categoryId as CategoryId;
acc[normalizePartRole(role)] = categoryId as CategoryId;
}
return acc;
}, {} as Record<string, CategoryId>);
+4 -4
View File
@@ -11,7 +11,7 @@ export type CategoryGroup = "lower" | "upper" | "accessories";
*/
export type CategoryId =
// ===== LOWER =====
| "lower"
| "lower-receiver"
| "complete-lower"
| "lower-parts"
| "trigger"
@@ -21,7 +21,7 @@ export type CategoryId =
| "stock"
// ===== UPPER =====
| "upper"
| "upper-receiver"
| "complete-upper"
| "barrel"
| "handguard"
@@ -84,7 +84,7 @@ export interface Part {
*/
export const CATEGORY_SLUGS = {
// LOWER
lower: "lower",
"lower-receiver": "lower-receiver",
"complete-lower": "complete-lower",
"lower-parts": "lower-parts",
trigger: "trigger",
@@ -94,7 +94,7 @@ export const CATEGORY_SLUGS = {
stock: "stock",
// UPPER
upper: "upper",
"upper-receiver": "upper-receiver",
"complete-upper": "complete-upper",
barrel: "barrel",
handguard: "handguard",