fixing platform routing. not done. updated logo on builder

This commit is contained in:
2025-12-10 14:32:09 -05:00
parent 54c30b1d8a
commit b26dcb947e
8 changed files with 255 additions and 265 deletions
@@ -27,7 +27,10 @@ const API_BASE_URL =
export default function PartDetailPage() { export default function PartDetailPage() {
const params = useParams(); const params = useParams();
const categoryId = params.categoryId as CategoryId; const categoryId = params.categoryId as CategoryId;
const partId = params.partId as string; // this is the UUID we passed in the link
// 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 [product, setProduct] = useState<GunbuilderProductFromApi | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
+53 -8
View File
@@ -2,7 +2,7 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter, useSearchParams } from "next/navigation";
import { CATEGORIES } from "@/data/gunbuilderParts"; import { CATEGORIES } from "@/data/gunbuilderParts";
import type { CategoryId, Part } from "@/types/gunbuilder"; import type { CategoryId, Part } from "@/types/gunbuilder";
@@ -26,6 +26,8 @@ type UiPart = Part & {
inStock?: boolean; inStock?: boolean;
}; };
const PLATFORMS = ["AR-15", "AR-10"];
const API_BASE_URL = const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080"; process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
@@ -37,10 +39,18 @@ type SortOption = "relevance" | "price-asc" | "price-desc" | "brand-asc";
type BuildState = Partial<Record<CategoryId, string>>; type BuildState = Partial<Record<CategoryId, string>>;
const STORAGE_KEY = "gunbuilder-build-state"; const STORAGE_KEY = "gunbuilder-build-state";
// support for url id-slug
const slugify = (str: string) =>
str
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "");
export default function CategoryPage() { export default function CategoryPage() {
const params = useParams(); const params = useParams();
const categoryId = params.categoryId as CategoryId; const categoryId = params.categoryId as CategoryId;
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams();
const [viewMode, setViewMode] = useState<ViewMode>("list"); const [viewMode, setViewMode] = useState<ViewMode>("list");
const [parts, setParts] = useState<UiPart[]>([]); const [parts, setParts] = useState<UiPart[]>([]);
@@ -70,6 +80,10 @@ export default function CategoryPage() {
} }
}); });
const initialPlatform =
(searchParams.get("platform") as string) || "AR-15";
const [platform, setPlatform] = useState<string>(initialPlatform);
const category = useMemo( const category = useMemo(
() => CATEGORIES.find((c) => c.id === categoryId), () => CATEGORIES.find((c) => c.id === categoryId),
[categoryId], [categoryId],
@@ -86,10 +100,9 @@ export default function CategoryPage() {
setError(null); setError(null);
const search = new URLSearchParams(); const search = new URLSearchParams();
search.set("platform", "AR-15"); search.set("platform", platform);
search.set("categorySlug", categoryId);
const url = `${API_BASE_URL}/api/gunbuilder/products/by-category?${search.toString()}`; const url = `${API_BASE_URL}/api/products?${search.toString()}`;
const res = await fetch(url, { signal: controller.signal }); const res = await fetch(url, { signal: controller.signal });
@@ -123,7 +136,7 @@ export default function CategoryPage() {
fetchCategoryParts(); fetchCategoryParts();
return () => controller.abort(); return () => controller.abort();
}, [categoryId]); }, [categoryId, platform]);
// persist build to localStorage whenever it changes // persist build to localStorage whenever it changes
useEffect(() => { useEffect(() => {
@@ -278,7 +291,13 @@ export default function CategoryPage() {
useEffect(() => { useEffect(() => {
// whenever the category or filters change, jump back to page 1 // whenever the category or filters change, jump back to page 1
setCurrentPage(1); setCurrentPage(1);
}, [categoryId, brandFilter, sortBy, searchQuery, priceRange, inStockOnly]); }, [categoryId, brandFilter, sortBy, searchQuery, priceRange, inStockOnly, platform]);
useEffect(() => {
const nextPlatform =
(searchParams.get("platform") as string) || "AR-15";
setPlatform(nextPlatform);
}, [searchParams]);
const visibleRange = useMemo(() => { const visibleRange = useMemo(() => {
if (filteredParts.length === 0) { if (filteredParts.length === 0) {
@@ -588,6 +607,26 @@ export default function CategoryPage() {
)} )}
</div> </div>
</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"> <div className="flex items-center gap-2">
<label <label
htmlFor="sort-by" htmlFor="sort-by"
@@ -660,7 +699,10 @@ export default function CategoryPage() {
</div> </div>
<div className="mt-2 flex gap-2"> <div className="mt-2 flex gap-2">
<Link <Link
href={`/builder/${categoryId}/${part.id}`} href={{
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" 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 View Details
@@ -723,7 +765,10 @@ export default function CategoryPage() {
</div> </div>
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<Link <Link
href={`/builder/${categoryId}/${part.id}`} href={{
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" 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 View Details
-32
View File
@@ -24,35 +24,3 @@ export default function BuilderLayout({ children }: { children: ReactNode }) {
); );
} }
//
// Original homepage. will delete after testing
//
// import "./globals.css";
// import type { ReactNode } from "react";
// import { AuthProvider } from "@/context/AuthContext";
// import { TopNav } from "@/components/TopNav";
// 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 RootLayout({ children }: { children: ReactNode }) {
// return (
// <html lang="en">
// <body className="bg-black text-zinc-100">
// <AuthProvider>
// <TopNav />
// <BuilderNav />
// <main className="min-h-screen">{children}</main>
// </AuthProvider>
// </body>
// </html>
// );
// }
+69 -40
View File
@@ -20,6 +20,8 @@ type GunbuilderProductFromApi = {
buyUrl: string | null; buyUrl: string | null;
}; };
const PLATFORMS = ["AR-15", "AR-10", "AR-9"] as const;
const API_BASE_URL = const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080"; process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
@@ -85,6 +87,7 @@ export default function GunbuilderPage() {
const [parts, setParts] = useState<Part[]>([]); const [parts, setParts] = useState<Part[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [platform, setPlatform] = useState<(typeof PLATFORMS)[number]>("AR-15");
const [build, setBuild] = useState<BuildState>(() => { const [build, setBuild] = useState<BuildState>(() => {
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
const stored = localStorage.getItem(STORAGE_KEY); const stored = localStorage.getItem(STORAGE_KEY);
@@ -110,8 +113,10 @@ export default function GunbuilderPage() {
setError(null); setError(null);
const res = await fetch( const res = await fetch(
`${API_BASE_URL}/api/products/gunbuilder?platform=AR-15`, `${API_BASE_URL}/api/products?platform=${encodeURIComponent(
{ signal: controller.signal }, platform
)}`,
{ signal: controller.signal }
); );
if (!res.ok) { if (!res.ok) {
@@ -144,7 +149,6 @@ export default function GunbuilderPage() {
}) })
.filter((p): p is Part => p !== null); .filter((p): p is Part => p !== null);
setParts(normalized); setParts(normalized);
} catch (err: any) { } catch (err: any) {
if (err.name === "AbortError") return; if (err.name === "AbortError") return;
@@ -157,7 +161,16 @@ export default function GunbuilderPage() {
fetchProducts(); fetchProducts();
return () => controller.abort(); return () => controller.abort();
}, []); }, [platform]);
useEffect(() => {
// When the platform changes, reset the current build so we don't
// show stale part selections from a different platform.
setBuild({});
if (typeof window !== "undefined") {
localStorage.removeItem(STORAGE_KEY);
}
}, [platform]);
// Handle URL query parameter for part selection (?select=upper:165) // Handle URL query parameter for part selection (?select=upper:165)
useEffect(() => { useEffect(() => {
@@ -191,9 +204,7 @@ export default function GunbuilderPage() {
const partsByCategory: Record<CategoryId, Part[]> = useMemo(() => { const partsByCategory: Record<CategoryId, Part[]> = useMemo(() => {
const grouped = {} as Record<CategoryId, Part[]>; const grouped = {} as Record<CategoryId, Part[]>;
for (const category of CATEGORIES) { for (const category of CATEGORIES) {
grouped[category.id] = parts.filter( grouped[category.id] = parts.filter((p) => p.categoryId === category.id);
(p) => p.categoryId === category.id,
);
} }
return grouped; return grouped;
}, [parts]); }, [parts]);
@@ -201,7 +212,7 @@ export default function GunbuilderPage() {
const selectedParts: Part[] = useMemo(() => { const selectedParts: Part[] = useMemo(() => {
return Object.entries(build) return Object.entries(build)
.map(([categoryId, partId]) => .map(([categoryId, partId]) =>
parts.find((p) => p.id === partId && p.categoryId === categoryId), parts.find((p) => p.id === partId && p.categoryId === categoryId)
) )
.filter(Boolean) as Part[]; .filter(Boolean) as Part[];
}, [build, parts]); }, [build, parts]);
@@ -212,14 +223,14 @@ export default function GunbuilderPage() {
Object.entries(build).map(([categoryId, partId]) => [ Object.entries(build).map(([categoryId, partId]) => [
categoryId as CategoryId, categoryId as CategoryId,
partId ? true : false, partId ? true : false,
]), ])
) as Record<CategoryId, unknown>, ) as Record<CategoryId, unknown>,
[build], [build]
); );
const totalPrice = useMemo( const totalPrice = useMemo(
() => selectedParts.reduce((sum, p) => sum + p.price, 0), () => selectedParts.reduce((sum, p) => sum + p.price, 0),
[selectedParts], [selectedParts]
); );
useEffect(() => { useEffect(() => {
@@ -236,7 +247,7 @@ export default function GunbuilderPage() {
const encoded = window.btoa(payload); const encoded = window.btoa(payload);
const origin = window.location?.origin ?? ""; const origin = window.location?.origin ?? "";
const url = `${origin}/builder/build?build=${encodeURIComponent( const url = `${origin}/builder/build?build=${encodeURIComponent(
encoded, encoded
)}`; )}`;
setShareUrl(url); setShareUrl(url);
} catch { } catch {
@@ -254,10 +265,10 @@ export default function GunbuilderPage() {
// Use group ordering for the summary as well // Use group ordering for the summary as well
const summaryCategoryOrder: CategoryId[] = useMemo( const summaryCategoryOrder: CategoryId[] = useMemo(
() => () =>
CATEGORY_GROUPS.flatMap((group) => group.categoryIds).filter( CATEGORY_GROUPS.flatMap((group) => group.categoryIds).filter((id) =>
(id) => CATEGORIES.some((c) => c.id === id), CATEGORIES.some((c) => c.id === id)
), ),
[], []
); );
const handleShare = async () => { const handleShare = async () => {
@@ -278,14 +289,14 @@ export default function GunbuilderPage() {
const selectedPartId = build[cat.id]; const selectedPartId = build[cat.id];
const part = parts.find( const part = parts.find(
(p) => p.id === selectedPartId && p.categoryId === cat.id, (p) => p.id === selectedPartId && p.categoryId === cat.id
); );
if (part) { if (part) {
lines.push( lines.push(
`${cat.name}: ${part.brand}${part.name} ($${part.price.toFixed( `${cat.name}: ${part.brand}${part.name} ($${part.price.toFixed(
2, 2
)})`, )})`
); );
} else { } else {
lines.push(`${cat.name}: [Not selected]`); lines.push(`${cat.name}: [Not selected]`);
@@ -299,7 +310,7 @@ export default function GunbuilderPage() {
setShareStatus("Build summary copied to clipboard."); setShareStatus("Build summary copied to clipboard.");
} catch { } catch {
setShareStatus( setShareStatus(
"Could not access clipboard. You can copy from the build summary page.", "Could not access clipboard. You can copy from the build summary page."
); );
} }
}; };
@@ -334,7 +345,9 @@ export default function GunbuilderPage() {
setShareStatus("Share dialog opened."); setShareStatus("Share dialog opened.");
} catch { } catch {
// User canceled or share failed; keep it quiet but let them know // User canceled or share failed; keep it quiet but let them know
setShareStatus("Share canceled or unavailable. You can copy the link instead."); setShareStatus(
"Share canceled or unavailable. You can copy the link instead."
);
} }
} else { } else {
// Fallback to copying the link // Fallback to copying the link
@@ -344,7 +357,6 @@ export default function GunbuilderPage() {
return ( return (
<main className="min-h-screen bg-black text-zinc-50"> <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="mx-auto max-w-6xl px-4 py-6 lg:py-10">
{/* Header */} {/* Header */}
<header className="mb-6"> <header className="mb-6">
@@ -361,10 +373,31 @@ export default function GunbuilderPage() {
update as you go. This early-access builder keeps your setup saved update as you go. This early-access builder keeps your setup saved
locally so you can come back and refine it anytime. locally so you can come back and refine it anytime.
</p> </p>
<div className="mt-4 flex flex-wrap items-center gap-3">
<label className="text-xs font-medium text-zinc-400 flex items-center gap-2">
Platform
<select
value={platform}
onChange={(e) =>
setPlatform(e.target.value as (typeof PLATFORMS)[number])
}
className="rounded-md border border-zinc-700 bg-zinc-900 px-2 py-1 text-xs text-zinc-100 focus:outline-none focus:ring-1 focus:ring-amber-400"
>
{PLATFORMS.map((p) => (
<option key={p} value={p}>
{p}
</option>
))}
</select>
</label>
<p className="text-[0.7rem] text-zinc-500">
Parts list updates automatically when you change platforms.
</p>
</div>
</div> </div>
</header> </header>
{/* Build summary panel */} {/* Build summary panel */}
<section className="mb-6 rounded-lg border border-zinc-800 bg-zinc-950/80 p-3 md:p-4 flex flex-col gap-3 md:flex-row md:items-start md:justify-between"> <section className="mb-6 rounded-lg border border-zinc-800 bg-zinc-950/80 p-3 md:p-4 flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
{/* Left: title + totals + primary actions */} {/* Left: title + totals + primary actions */}
@@ -416,7 +449,9 @@ export default function GunbuilderPage() {
}} }}
disabled={selectedParts.length === 0} disabled={selectedParts.length === 0}
className={`w-full sm:w-auto rounded-md border border-zinc-700 bg-zinc-900/50 px-3 py-2 text-sm font-medium text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600 transition-colors ${ className={`w-full sm:w-auto rounded-md border border-zinc-700 bg-zinc-900/50 px-3 py-2 text-sm font-medium text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600 transition-colors ${
selectedParts.length === 0 ? "opacity-40 cursor-not-allowed" : "" selectedParts.length === 0
? "opacity-40 cursor-not-allowed"
: ""
}`} }`}
> >
Clear Build Clear Build
@@ -432,8 +467,8 @@ export default function GunbuilderPage() {
Share Your Build Share Your Build
</h3> </h3>
<p className="mt-1 text-[0.7rem] text-zinc-500"> <p className="mt-1 text-[0.7rem] text-zinc-500">
Share this link to let others view your build or bookmark it to come Share this link to let others view your build or bookmark it
back later. to come back later.
</p> </p>
<div className="mt-2 flex flex-col gap-2 md:flex-row md:items-center"> <div className="mt-2 flex flex-col gap-2 md:flex-row md:items-center">
<div className="flex-1"> <div className="flex-1">
@@ -468,9 +503,7 @@ export default function GunbuilderPage() {
)} )}
{shareStatus && ( {shareStatus && (
<p className="mt-1 text-[0.7rem] text-zinc-500"> <p className="mt-1 text-[0.7rem] text-zinc-500">{shareStatus}</p>
{shareStatus}
</p>
)} )}
</div> </div>
</section> </section>
@@ -511,15 +544,12 @@ export default function GunbuilderPage() {
<section className="rounded-lg border border-zinc-800 bg-zinc-950/60 p-3 md:p-4"> <section className="rounded-lg border border-zinc-800 bg-zinc-950/60 p-3 md:p-4">
<p className="mb-4 text-xs text-zinc-500"> <p className="mb-4 text-xs text-zinc-500">
Work top-down through the major sections. Each row shows your Work top-down through the major sections. Each row shows your
current pick for that part type, with price and a direct buy current pick for that part type, with price and a direct buy linkor
linkor a quick way to choose a part if you haven&apos;t picked a quick way to choose a part if you haven&apos;t picked one yet.
one yet.
</p> </p>
{loading && ( {loading && (
<p className="text-sm text-zinc-500"> <p className="text-sm text-zinc-500">Loading parts from backend</p>
Loading parts from backend
</p>
)} )}
{error && !loading && ( {error && !loading && (
<p className="text-sm text-red-400"> <p className="text-sm text-red-400">
@@ -532,7 +562,7 @@ export default function GunbuilderPage() {
<div className="space-y-6"> <div className="space-y-6">
{CATEGORY_GROUPS.map((group) => { {CATEGORY_GROUPS.map((group) => {
const groupCategories = CATEGORIES.filter((c) => const groupCategories = CATEGORIES.filter((c) =>
group.categoryIds.includes(c.id as CategoryId), group.categoryIds.includes(c.id as CategoryId)
); );
if (groupCategories.length === 0) { if (groupCategories.length === 0) {
@@ -584,7 +614,7 @@ export default function GunbuilderPage() {
partsByCategory[category.id] ?? []; partsByCategory[category.id] ?? [];
const selectedPartId = build[category.id]; const selectedPartId = build[category.id];
const selectedPart = categoryParts.find( const selectedPart = categoryParts.find(
(p) => p.id === selectedPartId, (p) => p.id === selectedPartId
); );
const hasParts = categoryParts.length > 0; const hasParts = categoryParts.length > 0;
@@ -663,10 +693,9 @@ export default function GunbuilderPage() {
</> </>
) : hasParts ? ( ) : hasParts ? (
<Link <Link
href={`/builder/${category.id}`} href={{
className="inline-flex rounded-md border border-zinc-700 bg-zinc-900/70 px-2.5 py-1 text-[0.7rem] font-medium text-zinc-200 hover:bg-zinc-800 hover:border-zinc-600 transition-colors" pathname: `/builder/${category.id}`,
onClick={() => { query: { platform },
// If you want to pre-select something later, you can handle here.
}} }}
> >
Choose a Part Choose a Part
-1
View File
@@ -96,7 +96,6 @@ export default function HomePage() {
{/* Top Bar / Logo */} {/* Top Bar / Logo */}
<header className="flex items-center justify-between gap-4"> <header className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{/* Logo slot replace src with your actual logo file */}
<div className="flex items-center"> <div className="flex items-center">
<Image <Image
src="/battl/battl-logo-mark-2.svg" src="/battl/battl-logo-mark-2.svg"
+10 -1
View File
@@ -2,6 +2,8 @@
"use client"; "use client";
import Link from "next/link"; import Link from "next/link";
import Image from "next/image";
import { useAuth } from "@/context/AuthContext"; import { useAuth } from "@/context/AuthContext";
export function TopNav() { export function TopNav() {
@@ -19,7 +21,14 @@ export function TopNav() {
href="/" href="/"
className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-400" className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-400"
> >
Battl Builder
<Image
src="/battl/battl-logo-mark-2.svg"
alt="Battl Builders Logo"
width={260} // adjust to taste
height={48} // adjust to taste
className="block"
/>
</Link> </Link>
{/* Right side actions */} {/* Right side actions */}
+64 -159
View File
@@ -1,163 +1,68 @@
import type { Category, Part } from "@/types/gunbuilder"; import type { Category } from "@/types/gunbuilder";
export const CATEGORY_SLUGS = {
lower: "lower",
"complete-lower": "complete-lower",
"lower-parts": "lower-parts",
trigger: "trigger",
grip: "grip",
safety: "safety",
buffer: "buffer",
stock: "stock",
upper: "upper",
"complete-upper": "complete-upper",
barrel: "barrel",
handguard: "handguard",
"gas-block": "gas-block",
"gas-tube": "gas-tube",
"muzzle-device": "muzzle-device",
bcg: "bcg",
sights: "sights",
"charging-handle": "charging-handle",
suppressor: "suppressor",
optic: "optic",
magazine: "magazine",
"weapon-light": "weapon-light",
foregrip: "foregrip",
bipod: "bipod",
sling: "sling",
"rail-accessory": "rail-accessory",
tools: "tools",
} as const;
export const CATEGORIES: Category[] = [ export const CATEGORIES: Category[] = [
// LOWER // Lower group
{ { id: "lower", name: "Stripped Lowers", group: "lower", slug: CATEGORY_SLUGS.lower },
id: "lower", { id: "complete-lower", name: "Complete Lower", group: "lower", slug: CATEGORY_SLUGS["complete-lower"] },
name: "Stripped Lowers", { id: "lower-parts", name: "Lower Parts Kit", group: "lower", slug: CATEGORY_SLUGS["lower-parts"] },
description: "Serialized lower the foundation of the build.", { id: "trigger", name: "Trigger / Fire Control", group: "lower", slug: CATEGORY_SLUGS.trigger },
group: "lower", { id: "grip", name: "Pistol Grip", group: "lower", slug: CATEGORY_SLUGS.grip },
}, { id: "safety", name: "Safety / Selector", group: "lower", slug: CATEGORY_SLUGS.safety },
{ { id: "buffer", name: "Buffer System", group: "lower", slug: CATEGORY_SLUGS.buffer },
id: "completeLower", { id: "stock", name: "Stock / Brace", group: "lower", slug: CATEGORY_SLUGS.stock },
name: "Complete Lowers",
description: "Factory-built lower with stock, buffer system, and controls.",
group: "lower",
},
{
id: "lowerParts",
name: "Lower Parts Kits",
description: "Pins, springs, and controls for the lower.",
group: "lower",
},
{
id: "trigger",
name: "Trigger / Fire Control",
description: "Single-stage, two-stage, and match triggers.",
group: "lower",
},
{
id: "grip",
name: "Pistol Grip",
description: "Grips that shape how the rifle handles.",
group: "lower",
},
{
id: "safety",
name: "Safety / Selector",
description: "Ambi selectors and control upgrades.",
group: "lower",
},
{
id: "buffer",
name: "Buffer System",
description: "Buffer tube, buffer, spring, and related parts.",
group: "lower",
},
{
id: "stock",
name: "Stock / Brace",
description: "Adjustable, fixed, and minimalist stocks.",
group: "lower",
},
// UPPER // Upper group
{ { id: "upper", name: "Stripped Upper", group: "upper", slug: CATEGORY_SLUGS.upper },
id: "upper", { id: "complete-upper", name: "Complete Upper", group: "upper", slug: CATEGORY_SLUGS["complete-upper"] },
name: "Stripped Uppers", { id: "barrel", name: "Barrels", group: "upper", slug: CATEGORY_SLUGS.barrel },
description: "The core of the top half of your build.", { id: "handguard", name: "Handguards / Rails", group: "upper", slug: CATEGORY_SLUGS.handguard },
group: "upper", { id: "gas-block", name: "Gas Block", group: "upper", slug: CATEGORY_SLUGS["gas-block"] },
}, { id: "gas-tube", name: "Gas Tube", group: "upper", slug: CATEGORY_SLUGS["gas-tube"] },
{ { id: "muzzle-device", name: "Muzzle Device", group: "upper", slug: CATEGORY_SLUGS["muzzle-device"] },
id: "completeUpper", { id: "sights", name: "Iron Sights", group: "upper", slug: CATEGORY_SLUGS.sights },
name: "Complete Uppers", { id: "bcg", name: "Bolt Carrier Group", group: "upper", slug: CATEGORY_SLUGS.bcg },
description: "Pre-assembled uppers ready to pin and shoot.", { id: "charging-handle", name: "Charging Handle", group: "upper", slug: CATEGORY_SLUGS["charging-handle"] },
group: "upper", { id: "suppressor", name: "Suppressors", group: "upper", slug: CATEGORY_SLUGS.suppressor },
}, { id: "optic", name: "Optics", group: "upper", slug: CATEGORY_SLUGS.optic },
{
id: "bcg", // Accessories
name: "Bolt Carriers", { id: "magazine", name: "Magazines", group: "accessories", slug: CATEGORY_SLUGS.magazine },
description: "The heartbeat of the rifles cycling.", { id: "weapon-light", name: "Weapon Lights & Lasers", group: "accessories", slug: CATEGORY_SLUGS["weapon-light"] },
group: "upper", { id: "foregrip", name: "Vertical / Angled Grips", group: "accessories", slug: CATEGORY_SLUGS.foregrip },
}, { id: "bipod", name: "Bipods", group: "accessories", slug: CATEGORY_SLUGS.bipod },
{ { id: "sling", name: "Slings & Mounts", group: "accessories", slug: CATEGORY_SLUGS.sling },
id: "barrel", { id: "rail-accessory", name: "Rail Accessories", group: "accessories", slug: CATEGORY_SLUGS["rail-accessory"] },
name: "Barrels", { id: "tools", name: "Tools & Maintenance", group: "accessories", slug: CATEGORY_SLUGS.tools },
description: "Different lengths, profiles, and calibers.",
group: "upper",
},
{
id: "gasBlock",
name: "Gas Blocks",
description: "Standard and adjustable gas blocks.",
group: "upper",
},
{
id: "gasTube",
name: "Gas Tubes",
description: "Carbine, mid, rifle, and more.",
group: "upper",
},
{
id: "muzzleDevice",
name: "Muzzle Devices",
description: "Brakes, comps, and flash hiders.",
group: "upper",
},
{
id: "suppressor",
name: "Suppressors",
description: "Hearing-safe setups and hosts.",
group: "upper",
},
{
id: "handguard",
name: "Handguards / Rails",
description: "M-LOK rails and front-end furniture.",
group: "upper",
},
{
id: "chargingHandle",
name: "Charging Handles",
description: "Standard and ambi charging handles.",
group: "upper",
},
{
id: "sights",
name: "Iron Sights",
description: "Backup and primary irons.",
group: "upper",
},
{
id: "optic",
name: "Optics",
description: "LPVOs, red dots, and magnifiers.",
group: "upper",
},
// ACCESSORIES GROUP
{
id: "magazine",
name: "Magazines",
group: "accessories",
},
{
id: "weaponLight",
name: "Weapon Lights & Lasers",
group: "accessories",
},
{
id: "foregrip",
name: "Vertical / Angled Grips",
group: "accessories",
},
{
id: "bipod",
name: "Bipods",
group: "accessories",
},
{
id: "sling",
name: "Slings & Mounts",
group: "accessories",
},
{
id: "railAccessory",
name: "Rail Sections & Covers",
group: "accessories",
},
{
id: "tools",
name: "Tools & Maintenance",
group: "accessories",
},
]; ];
+32
View File
@@ -50,3 +50,35 @@ export interface Part {
imageUrl?: string; imageUrl?: string;
notes?: string; notes?: string;
} }
export const CATEGORY_SLUGS: Record<CategoryId, string> = {
lower: "lower",
completeLower: "complete-lower",
lowerParts: "lower-parts",
trigger: "trigger",
grip: "grip",
safety: "safety",
buffer: "buffer",
stock: "stock",
upper: "upper",
completeUpper: "complete-upper",
barrel: "barrel",
handguard: "handguard",
gasBlock: "gas-block",
gasTube: "gas-tube",
muzzleDevice: "muzzle-device",
sights: "sights",
bcg: "bcg",
chargingHandle: "charging-handle",
suppressor: "suppressor",
optic: "optic",
magazine: "magazine",
weaponLight: "weapon-light",
foregrip: "foregrip",
bipod: "bipod",
sling: "sling",
railAccessory: "rail-accessory",
tools: "tools",
};