"use client"; import { useMemo, useState, useEffect } from "react"; import Link from "next/link"; import { useSearchParams, useRouter } from "next/navigation"; import { CATEGORIES } from "@/data/gunbuilderParts"; import type { CategoryId, Part } from "@/types/gunbuilder"; import { CategoryColumn } from "@/components/CategoryColumn"; type GunbuilderProductFromApi = { id: number; // backend numeric id name: string; brand: string; platform: string; partRole: string; price: number | null; mainImageUrl: string | null; buyUrl: string | null; }; const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080"; // Map backend partRole to your existing CategoryId values. const PART_ROLE_TO_CATEGORY: Record = { "upper-receiver": "upper" as CategoryId, barrel: "barrel" as CategoryId, handguard: "handguard" as CategoryId, "charging-handle": "chargingHandle" as CategoryId, "buffer-kit": "buffer" as CategoryId, "lower-parts-kit": "lowerParts" as CategoryId, sight: "sights" as CategoryId, "lower-receiver": "lower" as CategoryId, optic: "optic" as CategoryId, stock: "stock" as CategoryId, }; type BuildState = Partial>; // 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(); const [parts, setParts] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [build, setBuild] = useState(() => { if (typeof window !== "undefined") { const stored = localStorage.getItem(STORAGE_KEY); if (stored) { try { return JSON.parse(stored); } catch { return {}; } } } return {}; }); useEffect(() => { const controller = new AbortController(); async function fetchProducts() { try { setLoading(true); setError(null); const res = await fetch( `${API_BASE_URL}/api/products/gunbuilder?platform=AR-15`, { signal: controller.signal }, ); if (!res.ok) { throw new Error(`Failed to load products (${res.status})`); } const data: GunbuilderProductFromApi[] = await res.json(); const normalized: Part[] = data .map((p): Part | null => { const categoryId = PART_ROLE_TO_CATEGORY[p.partRole]; if (!categoryId) { // Skip any parts we don't know how to map yet return null; } return { id: String(p.id), // ALWAYS string categoryId, name: p.name, brand: p.brand, price: p.price ?? 0, imageUrl: p.mainImageUrl ?? undefined, url: p.buyUrl ?? undefined, notes: undefined, }; }) .filter((p): p is Part => p !== null); setParts(normalized); } catch (err: any) { if (err.name === "AbortError") return; setError(err.message ?? "Failed to load products"); } finally { setLoading(false); } } fetchProducts(); return () => controller.abort(); }, []); // Handle URL query parameter for part selection (?select=upper:165) useEffect(() => { const selectParam = searchParams.get("select"); 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; }); router.replace("/gunbuilder", { scroll: false }); } } }, [searchParams, router]); // Persist build state to localStorage whenever it changes useEffect(() => { if (typeof window !== "undefined") { localStorage.setItem(STORAGE_KEY, JSON.stringify(build)); } }, [build]); const partsByCategory: Record = useMemo(() => { const grouped = {} as Record; 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 totalPrice = useMemo( () => selectedParts.reduce((sum, p) => sum + p.price, 0), [selectedParts], ); const handleSelectPart = (categoryId: CategoryId, partId: string) => { setBuild((prev) => ({ ...prev, [categoryId]: partId, })); }; // 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 (
Early Access Beta You're using an early-access prototype of The Armory. Data, pricing, and available parts are still evolving.
Early-access prototype. Data and pricing may change.
{/* Header */}

Shadow Standard

The Armory: Early Access

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.

Current build total
${totalPrice.toFixed(2)}
{selectedParts.length} / {CATEGORIES.length} categories filled
{/* Layout */}
{/* Categories/parts */}
{loading && (

Loading parts from backend…

)} {error && !loading && (

{error} — check that the Ballistic API is running and CORS is configured.

)} {!loading && !error && (
{CATEGORY_GROUPS.map((group) => { const groupCategories = CATEGORIES.filter((c) => group.categoryIds.includes(c.id as CategoryId), ); if (groupCategories.length === 0) { return null; } return (

{group.label}

{group.description && (

{group.description}

)}
{groupCategories.map((category) => (
handleSelectPart(category.id, partId) } />
))}
); })}
)}
{/* Build Summary */}
{/* What's New */}

What's New in Early Access

  • • Live parts and pricing pulled from multiple merchants.
  • • Grouped layout for lower and upper receiver parts so you can see at a glance which sections of your build are still missing.
  • • Running build total that updates automatically as you add or swap components.
  • • Local build persistence so your selections stick around between visits on this device.
{/* Roadmap */}

What's on our roadmap

  • • Platform filters (AR-15, AR-9, AR-10)
  • • More part categories and furniture
  • • Richer pricing/stock sync
  • • Smarter compatibility checks between components
  • • AR9/AR10 support and more
); }