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,116 +0,0 @@
// app/builder/[categoryId]/[partId]/data.ts
// Simulated data functions - Replace these with API calls when ready
// Example: export async function getPricingHistory(partId: string) { ... }
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
export interface PriceHistoryPoint {
date: string; // ISO date string
price: number;
}
export interface RetailerOffer {
id: string;
retailerName: string;
price: number;
originalPrice?: number;
inStock: boolean;
affiliateUrl: string;
lastUpdated: string; // ISO date string
}
/**
* Get pricing history for a part
* TODO: Replace with API call: GET /api/parts/{partId}/pricing-history
*
* For now, we keep this simulated.
*/
export function getPricingHistory(
partId: string,
basePrice: number,
): PriceHistoryPoint[] {
// Generate 30 days of price history with some variation
const days = 30;
const history: PriceHistoryPoint[] = [];
const today = new Date();
for (let i = days - 1; i >= 0; i--) {
const date = new Date(today);
date.setDate(date.getDate() - i);
// Simulate price fluctuations (±15% variation)
const variation = (Math.random() - 0.5) * 0.3; // -15% to +15%
const price = basePrice * (1 + variation);
history.push({
date: date.toISOString().split("T")[0],
price: Math.round(price * 100) / 100,
});
}
return history;
}
/**
* Get retailer offers for a part
* Real implementation using Ballistic backend:
* GET /api/products/{productId}/offers
*/
export async function getRetailerOffers(
productId: string,
): Promise<RetailerOffer[]> {
const res = await fetch(
`${API_BASE_URL}/api/products/${productId}/offers`,
{
// This will be called from the client detail page,
// so we *don't* use cache here.
cache: "no-store",
},
);
if (!res.ok) {
throw new Error(
`Failed to load retailer offers (${res.status}) for product ${productId}`,
);
}
// Expected backend payload (example):
// [
// {
// "id": "uuid-or-int",
// "merchantName": "Aero Precision",
// "price": 189.99,
// "originalPrice": 210.0,
// "inStock": true,
// "buyUrl": "https://classic.avantlink.com/click.php?...",
// "lastUpdated": "2025-11-30T15:22:00Z"
// },
// ...
// ]
const data: Array<{
id: string | number;
merchantName: string;
price: number;
originalPrice?: number | null;
inStock: boolean;
buyUrl: string;
lastUpdated: string;
}> = await res.json();
const offers: RetailerOffer[] = data
.map((o) => ({
id: String(o.id),
retailerName: o.merchantName,
price: o.price,
originalPrice: o.originalPrice ?? undefined,
inStock: o.inStock,
affiliateUrl: o.buyUrl,
lastUpdated: o.lastUpdated,
}))
// Sort by lowest price first
.sort((a, b) => a.price - b.price);
return offers;
}
@@ -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>
);
}
-873
View File
@@ -1,873 +0,0 @@
"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 & {
// local-only stock flag for filtering
inStock?: boolean;
};
const PLATFORMS = ["AR-15", "AR-10"];
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
// 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
.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.categoryId 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);
// filter + sort state
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;
// build state for this page, synced with localStorage
const [build, setBuild] = 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);
const search = new URLSearchParams();
search.set("platform", platform);
// ✅ Scope results to this category's backend roles
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), // normalize to string for BuildState
categoryId,
name: p.name,
brand: p.brand,
price: p.price ?? 0,
imageUrl: p.imageUrl ?? undefined, // match backend field name
url: 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);
}
}
fetchCategoryParts();
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;
if (isSelected) {
// Remove from build
setBuild((prev) => {
const updated: BuildState = { ...prev };
delete updated[categoryId];
return updated;
});
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");
qp.set("select", `${categoryId}:${partId}`);
router.push(`/builder?${qp.toString()}`);
};
// available brands for filter
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]);
// initialize / clamp priceRange whenever bounds change
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];
// Price range filter
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;
});
}
// Brand filter
if (brandFilter.length > 0) {
result = result.filter((p) => brandFilter.includes(p.brand));
}
// In-stock filter
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:
// leave in backend order
break;
}
// Pin the currently selected part (for this category) to the top, if any
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]);
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(() => {
// whenever the category or filters change, jump back to page 1
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="/builder"
className="text-amber-300 hover:text-amber-200 underline"
>
Return to Builder
</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 */}
<header className="mb-6">
<Link
href="/builder"
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">
Shadow Standard
</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"
}`}
aria-label="Card view"
>
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"
}`}
aria-label="List view"
>
List
</button>
</div>
)}
</div>
</header>
{/* Parts Grid/List */}
<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">
{/* Left sidebar: filters */}
{!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>
{/* Price range */}
<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>
) : null}
</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>
{/* Brand multi-select */}
<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>
{/* In-stock toggle */}
<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>
)}
{/* Right side: toolbar + results */}
<div className="flex-1">
{/* Top toolbar: count + search + sort */}
{!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>
)}
{/* Results / states */}
{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} check that the Ballistic API is running.
</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>
{part.notes && (
<p className="mt-1 line-clamp-2 text-xs text-zinc-400">
{part.notes}
</p>
)}
</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: `/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"
>
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>
) : (
<>
{/* Header row for list view */}
<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>
{part.notes && (
<p className="mt-1 line-clamp-1 text-xs text-zinc-400">
{part.notes}
</p>
)}
</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: `/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"
>
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>
</>
)}
{/* Pagination controls */}
{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>
);
}
+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>
);
}
}