part category grouping UI
This commit is contained in:
+131
-31
@@ -8,7 +8,7 @@ import type { CategoryId, Part } from "@/types/gunbuilder";
|
||||
import { CategoryColumn } from "@/components/CategoryColumn";
|
||||
|
||||
type GunbuilderProductFromApi = {
|
||||
id: number; // backend numeric id
|
||||
id: number; // backend numeric id
|
||||
name: string;
|
||||
brand: string;
|
||||
platform: string;
|
||||
@@ -39,6 +39,40 @@ type BuildState = Partial<Record<CategoryId, string>>; // categoryId -> partId
|
||||
|
||||
const STORAGE_KEY = "gunbuilder-build-state";
|
||||
|
||||
// Group categories into logical sections.
|
||||
//
|
||||
// IMPORTANT:
|
||||
// These IDs must match the ids you have in CATEGORIES.
|
||||
// Anything that doesn't exist will just be skipped.
|
||||
const CATEGORY_GROUPS: {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
categoryIds: CategoryId[];
|
||||
}[] = [
|
||||
{
|
||||
id: "lower-group",
|
||||
label: "Lower Receiver Parts",
|
||||
description:
|
||||
"Everything from the serialized lower to small parts and core controls.",
|
||||
categoryIds: ["lower", "lowerParts", "stock", "buffer"] as CategoryId[],
|
||||
},
|
||||
{
|
||||
id: "upper-group",
|
||||
label: "Upper Receiver Parts",
|
||||
description:
|
||||
"Barrel, upper, handguard, and the parts that keep the rifle cycling.",
|
||||
categoryIds: [
|
||||
"upper",
|
||||
"barrel",
|
||||
"handguard",
|
||||
"chargingHandle",
|
||||
"sights",
|
||||
"optic",
|
||||
] as CategoryId[],
|
||||
},
|
||||
];
|
||||
|
||||
export default function GunbuilderPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
@@ -87,7 +121,7 @@ export default function GunbuilderPage() {
|
||||
}
|
||||
|
||||
return {
|
||||
id: String(p.id), // <<< ALWAYS string
|
||||
id: String(p.id), // ALWAYS string
|
||||
categoryId,
|
||||
name: p.name,
|
||||
brand: p.brand,
|
||||
@@ -124,7 +158,9 @@ export default function GunbuilderPage() {
|
||||
...prev,
|
||||
[categoryId as CategoryId]: partId,
|
||||
};
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
|
||||
@@ -170,6 +206,15 @@ export default function GunbuilderPage() {
|
||||
}));
|
||||
};
|
||||
|
||||
// Use group ordering for the summary as well
|
||||
const summaryCategoryOrder: CategoryId[] = useMemo(
|
||||
() =>
|
||||
CATEGORY_GROUPS.flatMap((group) => group.categoryIds).filter(
|
||||
(id) => CATEGORIES.some((c) => c.id === id),
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-black text-zinc-50">
|
||||
<div className="border-b border-amber-500/20 bg-amber-500/5">
|
||||
@@ -179,7 +224,8 @@ export default function GunbuilderPage() {
|
||||
Early Access Beta
|
||||
</span>
|
||||
<span className="hidden text-amber-100/90 md:inline">
|
||||
You're using an early-access prototype of The Armory. Data, pricing, and available parts are still evolving.
|
||||
You're using an early-access prototype of The Armory. Data,
|
||||
pricing, and available parts are still evolving.
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-amber-100/90 md:hidden">
|
||||
@@ -198,7 +244,10 @@ export default function GunbuilderPage() {
|
||||
The Armory: <span className="text-amber-300">Early Access</span>
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-zinc-400 max-w-xl">
|
||||
Explore components from trusted brands, choose one part per category, track live prices, and watch your total build cost update as you go. This early-access builder keeps your setup saved locally so you can come back and refine it anytime.
|
||||
Explore components from trusted brands, choose one part per
|
||||
category, track live prices, and watch your total build cost
|
||||
update as you go. This early-access builder keeps your setup saved
|
||||
locally so you can come back and refine it anytime.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -218,7 +267,9 @@ export default function GunbuilderPage() {
|
||||
{/* Categories/parts */}
|
||||
<section className="rounded-lg border border-zinc-800 bg-zinc-950/60 p-3 md:p-4">
|
||||
{loading && (
|
||||
<p className="text-sm text-zinc-500">Loading parts from backend…</p>
|
||||
<p className="text-sm text-zinc-500">
|
||||
Loading parts from backend…
|
||||
</p>
|
||||
)}
|
||||
{error && !loading && (
|
||||
<p className="text-sm text-red-400">
|
||||
@@ -227,22 +278,51 @@ export default function GunbuilderPage() {
|
||||
</p>
|
||||
)}
|
||||
{!loading && !error && (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{CATEGORIES.map((category) => (
|
||||
<div
|
||||
key={category.id}
|
||||
className="border border-zinc-800 rounded-md p-2 md:p-3 bg-zinc-950/60"
|
||||
>
|
||||
<CategoryColumn
|
||||
category={category}
|
||||
parts={partsByCategory[category.id]}
|
||||
selectedPartId={build[category.id]}
|
||||
onSelectPart={(partId) =>
|
||||
handleSelectPart(category.id, partId)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<div className="space-y-6">
|
||||
{CATEGORY_GROUPS.map((group) => {
|
||||
const groupCategories = CATEGORIES.filter((c) =>
|
||||
group.categoryIds.includes(c.id as CategoryId),
|
||||
);
|
||||
|
||||
if (groupCategories.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={group.id} className="space-y-3">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-zinc-100">
|
||||
{group.label}
|
||||
</h2>
|
||||
{group.description && (
|
||||
<p className="text-xs text-zinc-500 mt-1 max-w-xl">
|
||||
{group.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{groupCategories.map((category) => (
|
||||
<div
|
||||
key={category.id}
|
||||
className="border border-zinc-800 rounded-md p-2 md:p-3 bg-zinc-950/60"
|
||||
>
|
||||
<CategoryColumn
|
||||
category={category}
|
||||
parts={partsByCategory[category.id]}
|
||||
selectedPartId={build[category.id]}
|
||||
onSelectPart={(partId) =>
|
||||
handleSelectPart(category.id, partId)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
@@ -261,11 +341,16 @@ export default function GunbuilderPage() {
|
||||
)}
|
||||
|
||||
<ul className="space-y-2">
|
||||
{CATEGORIES.map((cat) => {
|
||||
{summaryCategoryOrder.map((categoryId) => {
|
||||
const cat = CATEGORIES.find((c) => c.id === categoryId);
|
||||
if (!cat) return null;
|
||||
|
||||
const selectedPartId = build[cat.id];
|
||||
const part = parts.find(
|
||||
(p) => p.id === selectedPartId && p.categoryId === cat.id,
|
||||
(p) =>
|
||||
p.id === selectedPartId && p.categoryId === cat.id,
|
||||
);
|
||||
|
||||
return (
|
||||
<li key={cat.id}>
|
||||
<div className="text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500">
|
||||
@@ -310,7 +395,9 @@ export default function GunbuilderPage() {
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setBuild({});
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
}}
|
||||
disabled={selectedParts.length === 0}
|
||||
className={`w-full 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 ${
|
||||
@@ -327,27 +414,40 @@ export default function GunbuilderPage() {
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
{/* What's New */}
|
||||
<section className="mt-8 rounded-lg border border-zinc-800 bg-zinc-950/80 p-3 md:p-4">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-[0.16em] text-amber-300">
|
||||
What's New in Early Access
|
||||
</h2>
|
||||
<ul className="mt-2 space-y-1 text-sm text-zinc-400">
|
||||
<li>• Live parts and pricing pulled from multiple merchants.</li>
|
||||
<li>• Category-based layout so you can see at a glance which parts 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>
|
||||
<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">
|
||||
Whats on our roadmap
|
||||
What's on our roadmap
|
||||
</h2>
|
||||
<ul className="mt-2 space-y-1 text-sm text-zinc-400">
|
||||
<li>• Platform filters (AR-15, AR-9, AR-10)</li>
|
||||
<li>• More part categories</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>
|
||||
<li>• AR9/AR10 support and more</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user