5 Commits

Author SHA1 Message Date
dstrawsb be03424e26 fixed the name change from gunbuilder to builder and added the TM for trademark 2025-12-05 23:32:23 -05:00
dstrawsb fa710d35a1 fixed conflict 2025-12-05 22:28:40 -05:00
dstrawsb 6863b3618c fixed merge config 2025-12-05 21:01:10 -05:00
dstrawsb b4177883f8 deleteing .next 2025-12-04 10:12:03 -05:00
dstrawsb 48e677bcfb deleting .next 2025-12-04 10:10:22 -05:00
22 changed files with 140 additions and 1199 deletions
+8
View File
@@ -0,0 +1,8 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
+6
View File
@@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
</profile>
</component>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/shadow-gunbuilder-ai-proto.iml" filepath="$PROJECT_DIR$/.idea/shadow-gunbuilder-ai-proto.iml" />
</modules>
</component>
</project>
+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="JAVA_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="SqlDialectMappings">
<file url="file://$PROJECT_DIR$/schema-example.sql" dialect="PostgreSQL" />
</component>
</project>
-93
View File
@@ -1,93 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AutoImportSettings">
<option name="autoReloadType" value="SELECTIVE" />
</component>
<component name="ChangeListManager">
<list default="true" id="261566a2-3672-4898-b754-916bbdb2063b" name="Changes" comment="">
<change beforePath="$PROJECT_DIR$/next-env.d.ts" beforeDir="false" afterPath="$PROJECT_DIR$/next-env.d.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/tsconfig.json" beforeDir="false" afterPath="$PROJECT_DIR$/tsconfig.json" afterDir="false" />
</list>
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
<option name="LAST_RESOLUTION" value="IGNORE" />
</component>
<component name="Git.Settings">
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
</component>
<component name="ProjectColorInfo">{
&quot;associatedIndex&quot;: 1
}</component>
<component name="ProjectId" id="361l08WqyxLtDro87xvcOWn3FhH" />
<component name="ProjectViewState">
<option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" />
</component>
<component name="PropertiesComponent">{
&quot;keyToString&quot;: {
&quot;ModuleVcsDetector.initialDetectionPerformed&quot;: &quot;true&quot;,
&quot;RunOnceActivity.ShowReadmeOnStart&quot;: &quot;true&quot;,
&quot;RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252&quot;: &quot;true&quot;,
&quot;RunOnceActivity.git.unshallow&quot;: &quot;true&quot;,
&quot;git-widget-placeholder&quot;: &quot;main&quot;,
&quot;ignore.virus.scanning.warn.message&quot;: &quot;true&quot;,
&quot;js.debugger.nextJs.config.created.client&quot;: &quot;true&quot;,
&quot;js.debugger.nextJs.config.created.server&quot;: &quot;true&quot;,
&quot;kotlin-language-version-configured&quot;: &quot;true&quot;,
&quot;last_opened_file_path&quot;: &quot;//wsl.localhost/Arch/home/don/code/shadow-gunbuilder-ai-proto&quot;,
&quot;node.js.detected.package.eslint&quot;: &quot;true&quot;,
&quot;node.js.detected.package.tslint&quot;: &quot;true&quot;,
&quot;node.js.selected.package.eslint&quot;: &quot;(autodetect)&quot;,
&quot;node.js.selected.package.tslint&quot;: &quot;(autodetect)&quot;,
&quot;nodejs_package_manager_path&quot;: &quot;npm&quot;,
&quot;vue.rearranger.settings.migration&quot;: &quot;true&quot;
}
}</component>
<component name="RunManager" selected="npm.Next.js: server-side">
<configuration name="Next.js: debug client-side" type="JavascriptDebugType" uri="http://localhost:3000/">
<method v="2" />
</configuration>
<configuration name="Next.js: server-side" type="js.build_tools.npm">
<package-json value="$PROJECT_DIR$/package.json" />
<command value="run" />
<scripts>
<script value="dev" />
</scripts>
<node-interpreter value="project" />
<envs />
<method v="2" />
</configuration>
</component>
<component name="SharedIndexes">
<attachedChunks>
<set>
<option value="bundled-jdk-9823dce3aa75-bf35d07a577b-intellij.indexing.shared.core-IU-252.27397.103" />
<option value="bundled-js-predefined-d6986cc7102b-3aa1da707db6-JavaScript-IU-252.27397.103" />
</set>
</attachedChunks>
</component>
<component name="TaskManager">
<task active="true" id="Default" summary="Default task">
<changelist id="261566a2-3672-4898-b754-916bbdb2063b" name="Changes" comment="" />
<created>1764181733358</created>
<option name="number" value="Default" />
<option name="presentableId" value="Default" />
<updated>1764181733358</updated>
<workItem from="1764181734592" duration="78000" />
<workItem from="1764181824336" duration="9367000" />
</task>
<servers />
</component>
<component name="TypeScriptGeneratedFilesManager">
<option name="version" value="3" />
</component>
<component name="github-copilot-workspace">
<instructionFileLocations>
<option value=".github/instructions" />
</instructionFileLocations>
<promptFileLocations>
<option value=".github/prompts" />
</promptFileLocations>
</component>
</project>
+6 -6
View File
@@ -11,7 +11,7 @@ This is an internal tool built for speed, clarity, and capability.
# 🚀 What This Admin UI Does # 🚀 What This Admin UI Does
## Merchant Feeds ## Merchant Feeds
Path: `/gunbuilder/admin/merchants` Path: `//admin/merchants`
A clean dashboard for all merchants feeding the system. A clean dashboard for all merchants feeding the system.
@@ -26,7 +26,7 @@ Purpose: keep the catalog alive, current, and trustworthy.
--- ---
## Category Mapping ## Category Mapping
Path: `/gunbuilder/admin/category-mapping` Path: `/builder/admin/category-mapping`
This is where raw merchant chaos gets cleaned up. This is where raw merchant chaos gets cleaned up.
@@ -42,7 +42,7 @@ Once mapped, future imports classify parts automatically.
## Builder Integration ## Builder Integration
The admin UI powers the consumer-facing Builder at `/gunbuilder`. The admin UI powers the consumer-facing Builder at `/builder`.
Today: Today:
- Load compatible parts - Load compatible parts
@@ -80,13 +80,13 @@ pnpm install
Create `.env.local`: Create `.env.local`:
NEXT_PUBLIC_API_BASE_URL=http://localhost:8080 NEXT_PUBLIC_API_BASE_URL=http://localhost:8080
NEXT_PUBLIC_ADMIN_BASE_PATH=/gunbuilder/admin NEXT_PUBLIC_ADMIN_BASE_PATH=/builder/admin
## Start Dev Server ## Start Dev Server
npm run dev npm run dev
Then visit: Then visit:
http://localhost:3000/gunbuilder/admin http://localhost:3000/builder/admin
--- ---
@@ -103,7 +103,7 @@ If CORS fails, the admin cannot communicate with the API.
# 🧭 Project Structure # 🧭 Project Structure
app/ app/
gunbuilder/ builder/
admin/ admin/
merchants/ merchants/
category-mapping/ category-mapping/
-260
View File
@@ -1,260 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
const formatCount = (value: number | null | undefined) =>
(typeof value === "number" ? value : 0).toLocaleString();
type ImportSummary = {
totalProducts: number;
mappedProducts: number;
pendingProducts: number;
};
type RawByMerchantRow = {
merchantId: number;
merchantName: string;
platform: string;
status: string;
count: number;
};
type MerchantImportRow = {
merchantId: number;
merchantName: string;
totalProducts: number;
mappedProducts: number;
pendingProducts: number;
lastImportAt?: string | null;
};
export default function ImportStatusPage() {
const [summary, setSummary] = useState<ImportSummary | null>(null);
const [rows, setRows] = useState<MerchantImportRow[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [importingId, setImportingId] = useState<number | null>(null);
useEffect(() => {
async function load() {
try {
setLoading(true);
setError(null);
const [summaryRes, byMerchantRes] = await Promise.all([
fetch(`${API_BASE_URL}/api/admin/import-status/summary`),
fetch(`${API_BASE_URL}/api/admin/import-status/by-merchant`),
]);
if (!summaryRes.ok || !byMerchantRes.ok) {
throw new Error("Failed to load import status");
}
const summaryJson: ImportSummary = await summaryRes.json();
const byMerchantRaw: RawByMerchantRow[] = await byMerchantRes.json();
// Aggregate per merchant using merchantId as the key
const merchantMap = new Map<number, MerchantImportRow>();
for (const row of byMerchantRaw) {
const id = row.merchantId;
const name = row.merchantName;
const status = row.status;
const count = row.count ?? 0;
if (!merchantMap.has(id)) {
merchantMap.set(id, {
merchantId: id,
merchantName: name,
totalProducts: 0,
mappedProducts: 0,
pendingProducts: 0,
lastImportAt: null, // not provided by API yet
});
}
const agg = merchantMap.get(id)!;
agg.totalProducts += count;
if (status === "MAPPED") {
agg.mappedProducts += count;
} else if (status === "PENDING_MAPPING") {
agg.pendingProducts += count;
}
}
const byMerchantJson: MerchantImportRow[] = Array.from(
merchantMap.values()
);
setSummary(summaryJson);
setRows(byMerchantJson);
} catch (e: any) {
console.error(e);
setError(e.message ?? "Failed to load import status");
} finally {
setLoading(false);
}
}
load();
}, []);
async function handleReimport(merchantId: number) {
try {
setImportingId(merchantId);
const res = await fetch(
`${API_BASE_URL}/api/admin/merchants/${merchantId}/import`,
{
method: "POST",
}
);
if (!res.ok) {
throw new Error(`Re-import failed (${res.status})`);
}
// Optionally re-load data here if you want the table to refresh automatically
} catch (e: any) {
console.error(e);
alert(e.message ?? "Failed to trigger import");
} finally {
setImportingId(null);
}
}
return (
<main className="min-h-screen bg-black text-zinc-50">
<div className="mx-auto max-w-6xl px-4 py-8">
<header className="flex items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold tracking-tight">
Import Status
</h1>
<p className="mt-1 text-sm text-zinc-400">
Per-merchant feed health and mapping coverage.
</p>
</div>
<Link
href="/admin"
className="text-xs text-zinc-400 hover:text-zinc-200"
>
Back to Admin Home
</Link>
</header>
{loading && (
<p className="mt-6 text-sm text-zinc-400">Loading import status</p>
)}
{error && <p className="mt-6 text-sm text-red-400">Error: {error}</p>}
{summary && !loading && !error && (
<>
{/* Summary cards */}
<section className="mt-6 grid gap-4 md:grid-cols-3">
<div className="rounded-lg border border-zinc-800 bg-zinc-950/70 px-4 py-3">
<p className="text-xs uppercase tracking-[0.18em] text-zinc-500">
Total Products
</p>
<p className="mt-2 text-xl font-semibold">
{formatCount(summary.totalProducts)}
</p>
</div>
<div className="rounded-lg border border-zinc-800 bg-zinc-950/70 px-4 py-3">
<p className="text-xs uppercase tracking-[0.18em] text-zinc-500">
Mapped
</p>
<p className="mt-2 text-xl font-semibold text-emerald-400">
{formatCount(summary.mappedProducts)}
</p>
</div>
<div className="rounded-lg border border-zinc-800 bg-zinc-950/70 px-4 py-3">
<p className="text-xs uppercase tracking-[0.18em] text-zinc-500">
Pending Mapping
</p>
<p className="mt-2 text-xl font-semibold text-amber-300">
{formatCount(summary.pendingProducts)}
</p>
</div>
</section>
<section className="mt-8">
<h2 className="text-xs font-semibold uppercase tracking-[0.18em] text-zinc-500">
By Merchant
</h2>
<div className="mt-3 overflow-x-auto rounded-lg border border-zinc-800 bg-zinc-950/70">
<table className="min-w-full text-left text-sm">
<thead className="border-b border-zinc-800 bg-zinc-950/80 text-xs uppercase text-zinc-500">
<tr>
<th className="px-4 py-3">Merchant</th>
<th className="px-4 py-3 text-right">Total</th>
<th className="px-4 py-3 text-right">Mapped</th>
<th className="px-4 py-3 text-right">Pending</th>
<th className="px-4 py-3">Last Import</th>
<th className="px-4 py-3 text-right">Actions</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr
key={row.merchantId}
className="border-t border-zinc-900/80"
>
<td className="px-4 py-3">
<span className="font-medium text-zinc-50">
{row.merchantName}
</span>
</td>
<td className="px-4 py-3 text-right text-zinc-300">
{formatCount(row.totalProducts)}
</td>
<td className="px-4 py-3 text-right text-emerald-400">
{formatCount(row.mappedProducts)}
</td>
<td className="px-4 py-3 text-right text-amber-300">
{formatCount(row.pendingProducts)}
</td>
<td className="px-4 py-3 text-xs text-zinc-500">
{row.lastImportAt
? new Date(row.lastImportAt).toLocaleString()
: "—"}
</td>
<td className="px-4 py-3">
<div className="flex justify-end gap-2">
<Link
href={`/admin/mapping?merchantId=${row.merchantId}`}
className="rounded-md border border-zinc-700 bg-zinc-900 px-2.5 py-1 text-xs text-zinc-200 hover:border-amber-400/80 hover:bg-zinc-800"
>
Mapping
</Link>
<button
type="button"
onClick={() => handleReimport(row.merchantId)}
disabled={importingId === row.merchantId}
className="rounded-md border border-zinc-700 bg-zinc-900 px-2.5 py-1 text-xs text-zinc-200 hover:border-amber-400/80 hover:bg-zinc-800 disabled:cursor-not-allowed disabled:opacity-60"
>
{importingId === row.merchantId
? "Re-importing…"
: "Re-import"}
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
</>
)}
</div>
</main>
);
}
-323
View File
@@ -1,323 +0,0 @@
"use client";
import { useEffect, useState } from "react";
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
type PendingBucket = {
merchantId: number;
merchantName: string;
rawCategoryKey: string;
mappedPartRole: string | null;
productCount: number;
};
const PART_ROLE_OPTIONS: string[] = [
// LOWER receivers
"LOWER_RECEIVER_STRIPPED",
"LOWER_RECEIVER_COMPLETE",
"LOWER_RECEIVER_COMPLETE_PISTOL",
"LOWER_RECEIVER_COMPLETE_RIFLE",
"LOWER_RECEIVER_80",
"LOWER_RECEIVER_BILLET",
"LOWER_RECEIVER_FORGED",
"LOWER_RECEIVER_POLYMER",
// LOWER internals & controls
"LOWER_PARTS_KIT",
"LOWER_PARTS_KIT_ENHANCED",
"TRIGGER",
"TRIGGER_OTHER",
"TRIGGER_COMBAT",
"TRIGGER_MATCH",
"TRIGGER_DROP_IN",
"TRIGGER_SINGLE_STAGE",
"TRIGGER_TWO_STAGE",
"TRIGGER_GUARD",
"PISTOL_GRIP",
"PISTOL_GRIP_ERGO",
"PISTOL_GRIP_VERTICAL",
"SAFETY_SELECTOR",
"SAFETY_SELECTOR_AMBI",
"SAFETY_SELECTOR_45_DEG",
"MAG_RELEASE",
"TAKEDOWN_PINS",
"SPRINGS_PINS_MISC",
// LOWER buffer & stock
"BUFFER_KIT",
"BUFFER_TUBE",
"BUFFER_TUBE_MILSPEC",
"BUFFER_TUBE_COMMERCIAL",
"BUFFER_SPRING",
"BUFFER_WEIGHT",
"STOCK_ADJUSTABLE",
"STOCK_FIXED",
"STOCK_FOLDER",
"STOCK_PRECISION",
"BRACE_PISTOL",
// UPPER receivers & BCG
"UPPER_RECEIVER_STRIPPED",
"UPPER_RECEIVER_BILLET",
"UPPER_RECEIVER_MONOLITHIC",
"UPPER_RECEIVER_COMPLETE",
"UPPER_RECEIVER_COMPLETE_PISTOL",
"UPPER_RECEIVER_COMPLETE_RIFLE",
"BOLT_CARRIER_GROUP",
"BCG_COMPLETE",
"BCG_LIGHTWEIGHT",
"BCG_NICKEL_BORON",
// UPPER barrel & gas
"BARREL",
"BARREL_THREADED",
"BARREL_PENCIL",
"BARREL_MATCH",
"BARREL_HEAVY",
"BARREL_14_5_PINNED",
"GAS_BLOCK",
"GAS_BLOCK_ADJUSTABLE",
"GAS_BLOCK_LOW_PROFILE",
"GAS_TUBE_PISTOL",
"GAS_TUBE_CAR",
"GAS_TUBE_MID",
"GAS_TUBE_RIFLE",
// UPPER front end / muzzle
"HANDGUARD_MLOK",
"HANDGUARD_KEYMOD",
"HANDGUARD_QUAD",
"HANDGUARD_SLICK",
"MUZZLE_BRAKE",
"MUZZLE_COMPENSATOR",
// 👇 generic / backup muzzle roles
"MUZZLE_DEVICE",
"MUZZLE_DEVICE_OTHER",
"FLASH_HIDER",
"MUZZLE_DEVICE_QD_MOUNT",
"SUPPRESSOR_DIRECT_THREAD",
"SUPPRESSOR_QD",
"SUPPRESSOR_RIFLE",
"SUPPRESSOR_PISTOL",
"CHARGING_HANDLE",
"CHARGING_HANDLE_AMBI",
"CHARGING_HANDLE_GAS_BUSTER",
// Sights / optics
"SIGHTS_BACKUP",
"SIGHTS_IRON_FIXED",
"SIGHTS_IRON_FLIP",
"SIGHTS_OFFSET",
"OPTIC_REDDOT",
"OPTIC_LPVO",
"OPTIC_HOLOGRAPHIC",
"OPTIC_PRISM",
"OPTIC_MAGNIFIER",
// Accessories
"MAGAZINE",
"SLING",
"BIPOD",
"MOUNT_SCOPE",
"MOUNT_OFFSET",
"LIGHT_WEAPON",
"LASER_VISIBLE",
"LASER_IR",
"TOOL",
];
export default function MappingAdminPage() {
const [rows, setRows] = useState<PendingBucket[]>([]);
const [loading, setLoading] = useState(true);
const [savingKey, setSavingKey] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
async function load() {
try {
setLoading(true);
setError(null);
const res = await fetch(
`${API_BASE_URL}/api/admin/mapping/pending-buckets`
);
if (!res.ok) {
throw new Error(`Failed to load pending buckets (${res.status})`);
}
const data: PendingBucket[] = await res.json();
setRows(data);
} catch (e: any) {
console.error(e);
setError(e.message ?? "Failed to load pending buckets");
} finally {
setLoading(false);
}
}
load();
}, []);
const handleChangeRole = (idx: number, value: string) => {
setRows((prev) => {
const next = [...prev];
next[idx] = { ...next[idx], mappedPartRole: value || null };
return next;
});
};
const handleSave = async (row: PendingBucket) => {
if (!row.mappedPartRole) return;
const key = `${row.merchantId}-${row.rawCategoryKey}`;
try {
setSavingKey(key);
setError(null);
const res = await fetch(`${API_BASE_URL}/api/admin/mapping/apply`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
merchantId: row.merchantId,
rawCategoryKey: row.rawCategoryKey,
mappedPartRole: row.mappedPartRole,
}),
});
if (!res.ok) {
throw new Error(`Failed to save mapping (${res.status})`);
}
// After save, remove this bucket from the list
setRows((prev) =>
prev.filter(
(r) =>
!(
r.merchantId === row.merchantId &&
r.rawCategoryKey === row.rawCategoryKey
)
)
);
} catch (e: any) {
console.error(e);
setError(e.message ?? "Failed to save mapping");
} finally {
setSavingKey(null);
}
};
return (
<main className="min-h-screen bg-black text-zinc-50">
<div className="mx-auto max-w-6xl px-4 py-6">
<h1 className="text-xl font-semibold tracking-tight">
Bulk Mapping Pending Categories
</h1>
<p className="mt-1 text-sm text-zinc-400">
Bucketed by merchant + raw category. Map each bucket to a part role to
clean up the builder catalog.
</p>
{loading && (
<p className="mt-4 text-sm text-zinc-400">Loading pending buckets</p>
)}
{error && (
<p className="mt-4 text-sm text-red-400">Error: {error}</p>
)}
{!loading && rows.length === 0 && !error && (
<p className="mt-4 text-sm text-emerald-400">
No pending mapping buckets 🎉
</p>
)}
{!loading && rows.length > 0 && (
<section className="mt-4 rounded-lg border border-zinc-800 bg-zinc-950/80 p-3">
<div className="mb-2 flex items-center justify-between">
<h2 className="text-xs font-semibold uppercase tracking-[0.14em] text-zinc-500">
Pending Buckets
</h2>
<span className="text-xs text-zinc-400">
{rows.length} buckets {rows.reduce((s, r) => s + r.productCount, 0)}{" "}
products
</span>
</div>
<div className="mt-1 overflow-x-auto">
<table className="min-w-full text-xs">
<thead>
<tr className="border-b border-zinc-800 bg-zinc-900/60">
<th className="px-2 py-1 text-left text-zinc-400">
Merchant
</th>
<th className="px-2 py-1 text-left text-zinc-400">
Raw Category
</th>
<th className="px-2 py-1 text-left text-zinc-400">
Products
</th>
<th className="px-2 py-1 text-left text-zinc-400">
Part Role
</th>
<th className="px-2 py-1 text-right text-zinc-400">
Action
</th>
</tr>
</thead>
<tbody>
{rows.map((row, idx) => {
const rowKey = `${row.merchantId}-${row.rawCategoryKey}`;
const isSaving = savingKey === rowKey;
return (
<tr
key={rowKey}
className="border-b border-zinc-900 hover:bg-zinc-900/40"
>
<td className="px-2 py-1 text-zinc-100">
{row.merchantName}
</td>
<td className="px-2 py-1 text-zinc-300">
{row.rawCategoryKey}
</td>
<td className="px-2 py-1 text-zinc-200">{row.productCount}</td>
<td className="px-2 py-1 text-zinc-100">
<select
className="w-full rounded-md border border-zinc-700 bg-zinc-950/80 px-2 py-1 text-xs text-zinc-100 outline-none focus:border-amber-400"
value={row.mappedPartRole ?? ""}
onChange={(e) =>
handleChangeRole(idx, e.target.value)
}
>
<option value="">Select part role</option>
{PART_ROLE_OPTIONS.map((pr) => (
<option key={pr} value={pr}>
{pr}
</option>
))}
</select>
</td>
<td className="px-2 py-1 text-right">
<button
onClick={() => handleSave(row)}
disabled={!row.mappedPartRole || isSaving}
className="rounded-md bg-emerald-500 px-3 py-1 text-[0.7rem] font-semibold text-black disabled:cursor-not-allowed disabled:bg-zinc-700"
>
{isSaving ? "Saving…" : "Save"}
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</section>
)}
</div>
</main>
);
}
+56 -167
View File
@@ -1,178 +1,67 @@
"use client"; "use client";
import { useEffect, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useAuth } from "@/context/AuthContext";
const API_BASE_URL = export default function AdminHomePage() {
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080"; const { token } = useAuth();
type AdminOverview = { if (!token) {
totalProducts: number; return (
mappedProducts: number; <div className="p-6 text-sm text-red-500">
unmappedProducts: number; You must be logged in to view this page.
merchantCount: number; </div>
categoryMappingCount: number; );
}; }
export default function AdminLandingPage() { const cards = [
const [overview, setOverview] = useState<AdminOverview | null>(null); {
const [loading, setLoading] = useState(true); title: "Canonical categories",
const [error, setError] = useState<string | null>(null); href: "admin/categories",
description:
useEffect(() => { "Manage the core builder categories and their group/grouping + sort order.",
async function load() { },
try { {
setLoading(true); title: "Part role mappings",
setError(null); href: "/admin/part-role-mappings",
const res = await fetch(`${API_BASE_URL}/api/admin/dashboard/overview`); description:
if (!res.ok) { "Advanced: map logical builder part roles to canonical categories per platform.",
throw new Error(`Failed to load dashboard (${res.status})`); },
} {
const data: AdminOverview = await res.json(); title: "Merchant category mappings",
setOverview(data); href: "/admin/merchant-category-mappings",
} catch (e: any) { description:
console.error(e); "Map raw merchant feed categories to your canonical categories.",
setError(e.message ?? "Failed to load admin dashboard"); },
} finally { ];
setLoading(false);
}
}
load();
}, []);
return ( return (
<main className="min-h-screen bg-black text-zinc-50"> <div className="mx-auto max-w-5xl p-6 space-y-6">
<div className="mx-auto max-w-6xl px-4 py-8"> <div>
<header className="flex items-center justify-between gap-4"> <h1 className="text-lg font-semibold tracking-tight text-zinc-100">
<div> Admin
<h1 className="text-2xl font-semibold tracking-tight"> </h1>
Battl Builder Admin <p className="mt-1 text-xs text-zinc-400">
</h1> Internal tools to keep your builder data clean and consistent.
<p className="mt-1 text-sm text-zinc-400"> </p>
Control room for feeds, mappings, and catalog health.
</p>
</div>
</header>
{loading && (
<p className="mt-6 text-sm text-zinc-400">Loading dashboard</p>
)}
{error && (
<p className="mt-6 text-sm text-red-400">Error: {error}</p>
)}
{overview && !loading && !error && (
<>
{/* Stats row */}
<section className="mt-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div className="rounded-lg border border-zinc-800 bg-zinc-950/70 p-4">
<p className="text-xs uppercase tracking-[0.16em] text-zinc-500">
Total Products
</p>
<p className="mt-2 text-2xl font-semibold">
{overview.totalProducts.toLocaleString()}
</p>
</div>
<div className="rounded-lg border border-zinc-800 bg-zinc-950/70 p-4">
<p className="text-xs uppercase tracking-[0.16em] text-zinc-500">
Mapped Products
</p>
<p className="mt-2 text-2xl font-semibold text-emerald-400">
{overview.mappedProducts.toLocaleString()}
</p>
</div>
<div className="rounded-lg border border-zinc-800 bg-zinc-950/70 p-4">
<p className="text-xs uppercase tracking-[0.16em] text-zinc-500">
Unmapped Products
</p>
<p className="mt-2 text-2xl font-semibold text-amber-400">
{overview.unmappedProducts.toLocaleString()}
</p>
<p className="mt-1 text-[0.7rem] text-zinc-500">
Products still in <code>PENDING_MAPPING</code>.
</p>
</div>
<div className="rounded-lg border border-zinc-800 bg-zinc-950/70 p-4">
<p className="text-xs uppercase tracking-[0.16em] text-zinc-500">
Merchants / Mappings
</p>
<p className="mt-2 text-lg font-semibold">
{overview.merchantCount}{" "}
<span className="text-xs font-normal text-zinc-400">
merchants
</span>
</p>
<p className="text-sm text-zinc-400">
{overview.categoryMappingCount.toLocaleString()} mappings
</p>
</div>
</section>
{/* Nav cards */}
<section className="mt-10">
<h2 className="text-xs font-semibold uppercase tracking-[0.18em] text-zinc-500">
Admin Tools
</h2>
<div className="mt-3 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{/* Bulk Mapping */}
<Link
href="/admin/mapping"
className="group rounded-lg border border-zinc-800 bg-zinc-950/70 p-4 transition hover:border-amber-400/80 hover:bg-zinc-900/70"
>
<p className="text-sm font-semibold text-zinc-50">
Bulk Category Mapping
</p>
<p className="mt-1 text-xs text-zinc-400">
Bucket raw merchant categories and map them to part roles to
keep the catalog clean.
</p>
<p className="mt-3 text-xs text-amber-300">
{overview.unmappedProducts.toLocaleString()} products
pending mapping
</p>
</Link>
{/* Import Status */}
<Link
href="/admin/import-status"
className="group rounded-lg border border-zinc-800 bg-zinc-950/70 p-4 transition hover:border-amber-400/80 hover:bg-zinc-900/70"
>
<p className="text-sm font-semibold text-zinc-50">
Import Status
</p>
<p className="mt-1 text-xs text-zinc-400">
Inspect last feed runs, per-merchant health, and errors.
</p>
<p className="mt-3 text-xs text-zinc-500 group-hover:text-zinc-300">
View import summaries &nbsp;
</p>
</Link>
{/* Placeholder for future admin tools */}
<Link
href="/admin/products"
className="group rounded-lg border border-zinc-900 bg-zinc-950/40 p-4 transition hover:border-amber-400/80 hover:bg-zinc-900/70"
>
<p className="text-sm font-semibold text-zinc-50">
Catalog Explorer
</p>
<p className="mt-1 text-xs text-zinc-400">
(Future) Browse normalized products, offers, and compatibility.
</p>
<p className="mt-3 text-xs text-zinc-500 group-hover:text-zinc-300">
Coming soon
</p>
</Link>
</div>
</section>
</>
)}
</div> </div>
</main>
<div className="grid gap-4 sm:grid-cols-3">
{cards.map((card) => (
<Link
key={card.href}
href={card.href}
className="group rounded-lg border border-zinc-800 bg-zinc-950/70 p-4 text-left hover:border-emerald-500/70 hover:bg-zinc-900/80"
>
<h2 className="text-sm font-medium text-zinc-100 group-hover:text-white">
{card.title}
</h2>
<p className="mt-1 text-[11px] leading-snug text-zinc-400">
{card.description}
</p>
</Link>
))}
</div>
</div>
); );
} }
+1 -1
View File
@@ -1,4 +1,4 @@
// app/gunbuilder/[categoryId]/[partId]/data.ts // app/builder/[categoryId]/[partId]/data.ts
// Simulated data functions - Replace these with API calls when ready // Simulated data functions - Replace these with API calls when ready
// Example: export async function getPricingHistory(partId: string) { ... } // Example: export async function getPricingHistory(partId: string) { ... }
+7 -7
View File
@@ -131,10 +131,10 @@ export default function PartDetailPage() {
<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">
<h1 className="text-2xl font-semibold mb-4">Category Not Found</h1> <h1 className="text-2xl font-semibold mb-4">Category Not Found</h1>
<Link <Link
href="/gunbuilder" href="/builder"
className="text-amber-300 hover:text-amber-200 underline" className="text-amber-300 hover:text-amber-200 underline"
> >
Return to Gunbuilder Return to Builder
</Link> </Link>
</div> </div>
</main> </main>
@@ -147,14 +147,14 @@ export default function PartDetailPage() {
{/* Breadcrumbs */} {/* Breadcrumbs */}
<nav className="mb-4 text-xs text-zinc-500"> <nav className="mb-4 text-xs text-zinc-500">
<Link <Link
href="/gunbuilder" href="/builder"
className="hover:text-zinc-300 transition-colors" className="hover:text-zinc-300 transition-colors"
> >
Gunbuilder Builder
</Link> </Link>
<span className="mx-1">/</span> <span className="mx-1">/</span>
<Link <Link
href={`/gunbuilder/${categoryId}`} href={`/builder/${categoryId}`}
className="hover:text-zinc-300 transition-colors" className="hover:text-zinc-300 transition-colors"
> >
{category.name} {category.name}
@@ -176,7 +176,7 @@ export default function PartDetailPage() {
{error ?? "We couldnt find this product."} {error ?? "We couldnt find this product."}
</p> </p>
<Link <Link
href={`/gunbuilder/${categoryId}`} href={`/builder/${categoryId}`}
className="text-amber-300 hover:text-amber-200 underline text-sm" className="text-amber-300 hover:text-amber-200 underline text-sm"
> >
Back to {category.name} parts Back to {category.name} parts
@@ -352,7 +352,7 @@ export default function PartDetailPage() {
<div className="space-y-2"> <div className="space-y-2">
<Link <Link
href={`/gunbuilder?select=${categoryId}:${product.id}`} 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" 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 Add to Build
+15 -8
View File
@@ -4,19 +4,21 @@ 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 } from "next/navigation";
import { CATEGORIES } from "@/data/gunbuilderParts"; import { CATEGORIES } from "@/data/gunbuilderParts";
import { CATEGORY_TO_PART_ROLES } from "@/data/partRoleMappings";
import type { CategoryId, Part } from "@/types/gunbuilder"; import type { CategoryId, Part } from "@/types/gunbuilder";
type ViewMode = "card" | "list"; type ViewMode = "card" | "list";
type GunbuilderProductFromApi = { type GunbuilderProductFromApi = {
id: number; id: string;
name: string; name: string;
brand: string; brand: string;
platform: string; platform: string;
partRole: string; partRole: string;
price: number | null; price: number | null;
imageUrl: string | null; mainImageUrl: string | null;
buyUrl: string | null; buyUrl: string | null;
// optional stock flag if/when backend sends it
inStock?: boolean | null; inStock?: boolean | null;
}; };
@@ -81,14 +83,19 @@ export default function CategoryPage() {
async function fetchCategoryParts() { async function fetchCategoryParts() {
try { try {
setLoading(true); setLoading(true);
setError(null); setError(null);
// FIX: determine which backend partRoles map to this category
const roles = CATEGORY_TO_PART_ROLES[categoryId] ?? [];
const search = new URLSearchParams(); const search = new URLSearchParams();
search.set("platform", "AR-15"); search.set("platform", "AR-15");
search.set("categorySlug", categoryId); roles.forEach((r) => search.append("partRoles", r));
const url = `${API_BASE_URL}/api/gunbuilder/products/by-category?${search.toString()}`; const url = `${API_BASE_URL}/api/products/gunbuilder${
roles.length ? `?${search.toString()}` : ""
}`;
const res = await fetch(url, { signal: controller.signal }); const res = await fetch(url, { signal: controller.signal });
@@ -99,12 +106,12 @@ export default function CategoryPage() {
const data: GunbuilderProductFromApi[] = await res.json(); const data: GunbuilderProductFromApi[] = await res.json();
const normalized: UiPart[] = data.map((p) => ({ const normalized: UiPart[] = data.map((p) => ({
id: String(p.id), // normalize to string for BuildState id: p.id,
categoryId, categoryId,
name: p.name, name: p.name,
brand: p.brand, brand: p.brand,
price: p.price ?? 0, price: p.price ?? 0,
imageUrl: p.imageUrl ?? undefined, // match backend field name imageUrl: p.mainImageUrl ?? undefined,
url: p.buyUrl ?? undefined, url: p.buyUrl ?? undefined,
notes: undefined, notes: undefined,
inStock: p.inStock ?? true, inStock: p.inStock ?? true,
@@ -305,7 +312,7 @@ export default function CategoryPage() {
href="/builder" href="/builder"
className="text-amber-300 hover:text-amber-200 underline" className="text-amber-300 hover:text-amber-200 underline"
> >
Return to Gunbuilder Return to Builder
</Link> </Link>
</div> </div>
</div> </div>
@@ -150,7 +150,7 @@ export default function BuildDetailsPage() {
useEffect(() => { useEffect(() => {
if (typeof window !== "undefined" && Object.keys(build).length > 0) { if (typeof window !== "undefined" && Object.keys(build).length > 0) {
const encoded = encodeBuildState(build); const encoded = encodeBuildState(build);
const url = `${window.location.origin}/gunbuilder/build?build=${encoded}`; const url = `${window.location.origin}/builder/build?build=${encoded}`;
setShareUrl(url); setShareUrl(url);
} }
}, [build]); }, [build]);
@@ -212,10 +212,10 @@ export default function BuildDetailsPage() {
You need to select at least one part to view your build. You need to select at least one part to view your build.
</p> </p>
<Link <Link
href="/gunbuilder" href="/builder"
className="inline-block rounded-md border border-amber-400/60 bg-amber-400/10 px-4 py-2 text-sm font-medium text-amber-200 hover:bg-amber-400/15 transition-colors" className="inline-block rounded-md border border-amber-400/60 bg-amber-400/10 px-4 py-2 text-sm font-medium text-amber-200 hover:bg-amber-400/15 transition-colors"
> >
Go to Gunbuilder Go to Builder
</Link> </Link>
</div> </div>
</div> </div>
@@ -229,7 +229,7 @@ export default function BuildDetailsPage() {
{/* Header */} {/* Header */}
<header className="mb-6"> <header className="mb-6">
<Link <Link
href="/gunbuilder" href="/builder"
className="text-xs text-zinc-400 hover:text-zinc-300 mb-4 inline-block" className="text-xs text-zinc-400 hover:text-zinc-300 mb-4 inline-block"
> >
Back to The Armory Back to The Armory
@@ -368,7 +368,7 @@ export default function BuildDetailsPage() {
</a> </a>
)} )}
<Link <Link
href={`/gunbuilder/${category.id}/${part.id}`} href={`/builder/${category.id}/${part.id}`}
className="inline-flex items-center rounded-md border border-zinc-700 bg-zinc-900/50 px-4 py-2 text-sm font-medium text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600 transition-colors" className="inline-flex items-center rounded-md border border-zinc-700 bg-zinc-900/50 px-4 py-2 text-sm font-medium text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600 transition-colors"
> >
View Details View Details
@@ -398,7 +398,7 @@ export default function BuildDetailsPage() {
{/* Actions */} {/* Actions */}
<div className="mt-6 flex flex-col gap-3 sm:flex-row sm:justify-between"> <div className="mt-6 flex flex-col gap-3 sm:flex-row sm:justify-between">
<Link <Link
href="/gunbuilder" href="/builder"
className="rounded-md border border-zinc-700 bg-zinc-900/50 px-6 py-3 text-sm font-medium text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600 transition-colors text-center" className="rounded-md border border-zinc-700 bg-zinc-900/50 px-6 py-3 text-sm font-medium text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600 transition-colors text-center"
> >
Edit Build Edit Build
@@ -408,7 +408,7 @@ export default function BuildDetailsPage() {
type="button" type="button"
onClick={() => { onClick={() => {
localStorage.removeItem(STORAGE_KEY); localStorage.removeItem(STORAGE_KEY);
router.push("/gunbuilder"); router.push("/builder");
}} }}
className="rounded-md border border-zinc-700 bg-zinc-900/50 px-6 py-3 text-sm font-medium text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600 transition-colors" className="rounded-md border border-zinc-700 bg-zinc-900/50 px-6 py-3 text-sm font-medium text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600 transition-colors"
> >
+5 -50
View File
@@ -4,10 +4,9 @@ import { useMemo, useState, useEffect } from "react";
import Link from "next/link"; import Link from "next/link";
import { useSearchParams, useRouter } from "next/navigation"; import { useSearchParams, useRouter } from "next/navigation";
import { CATEGORIES } from "@/data/gunbuilderParts"; import { CATEGORIES } from "@/data/gunbuilderParts";
import { BUILDER_SLOTS, isSlotSatisfied } from "@/data/builderSlots";
import type { CategoryId, Part } from "@/types/gunbuilder"; import type { CategoryId, Part } from "@/types/gunbuilder";
import { PART_ROLE_TO_CATEGORY } from "@/data/partRoleMappings"; import { PART_ROLE_TO_CATEGORY } from "@/data/partRoleMappings";
import { Pencil, Link2, Square, CheckSquare } from "lucide-react"; import { Pencil, Link2 } from "lucide-react";
type GunbuilderProductFromApi = { type GunbuilderProductFromApi = {
id: number; // backend numeric id id: number; // backend numeric id
@@ -206,17 +205,6 @@ export default function GunbuilderPage() {
.filter(Boolean) as Part[]; .filter(Boolean) as Part[];
}, [build, parts]); }, [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( const totalPrice = useMemo(
() => selectedParts.reduce((sum, p) => sum + p.price, 0), () => selectedParts.reduce((sum, p) => sum + p.price, 0),
[selectedParts], [selectedParts],
@@ -350,10 +338,10 @@ export default function GunbuilderPage() {
<header className="mb-6"> <header className="mb-6">
<div> <div>
<p className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500"> <p className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
BATTL BUILDERS Shadow Standard Co.
</p> </p>
<h1 className="mt-1 text-2xl md:text-3xl font-semibold tracking-tight"> <h1 className="mt-1 text-2xl md:text-3xl font-semibold tracking-tight">
BattlBuilder <span className="text-amber-300">Early Access</span> The Build Bench: <span className="text-amber-300">Early Access</span>
</h1> </h1>
<p className="mt-2 text-sm text-zinc-400 max-w-xl"> <p className="mt-2 text-sm text-zinc-400 max-w-xl">
Explore components from trusted brands, choose one part per Explore components from trusted brands, choose one part per
@@ -364,9 +352,8 @@ export default function GunbuilderPage() {
</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 */}
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<h2 className="text-xs font-semibold uppercase tracking-[0.16em] text-amber-300"> <h2 className="text-xs font-semibold uppercase tracking-[0.16em] text-amber-300">
@@ -475,38 +462,6 @@ export default function GunbuilderPage() {
</div> </div>
</section> </section>
{/* Build status strip */}
<section className="mb-6 space-y-3">
<h2 className="text-[0.7rem] font-semibold uppercase tracking-[0.16em] text-zinc-500">
Build Status (Needs Refined)
</h2>
<div className="flex flex-wrap gap-2 md:gap-3">
{BUILDER_SLOTS.map((slot) => {
const satisfied = isSlotSatisfied(slot, selectedByCategory);
return (
<div
key={slot.id}
className={`inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-[0.7rem] md:text-xs ${
satisfied
? "border-emerald-500/70 bg-emerald-950/50 text-emerald-200"
: "border-zinc-800 bg-zinc-950/80 text-zinc-200"
}`}
>
{satisfied ? (
<CheckSquare className="h-4 w-4 text-emerald-400" />
) : (
<Square className="h-4 w-4 text-zinc-600/80" />
)}
<span className="font-medium truncate max-w-[9rem] md:max-w-[11rem]">
{slot.label}
</span>
</div>
);
})}
</div>
</section>
{/* Layout */} {/* Layout */}
<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">
+1 -1
View File
@@ -15,7 +15,7 @@ export default function LoginPage() {
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const next = searchParams.get("next") || "/gunbuilder"; const next = searchParams.get("next") || "/builder";
async function handleSubmit(e: FormEvent) { async function handleSubmit(e: FormEvent) {
e.preventDefault(); e.preventDefault();
+2 -2
View File
@@ -12,10 +12,10 @@ export default function HomePage() {
bg-gradient-to-r from-amber-400 via-amber-200 to-amber-400 bg-gradient-to-r from-amber-400 via-amber-200 to-amber-400
text-transparent uppercase bg-clip-text drop-shadow-[0_2px_6px_rgba(255,193,7,0.25)]" text-transparent uppercase bg-clip-text drop-shadow-[0_2px_6px_rgba(255,193,7,0.25)]"
> >
Battl Builder Battl Builder&trade;
</h1> </h1>
<p className="mt-3 text-sm md:text-base text-zinc-400"> <p className="mt-3 text-sm md:text-base text-zinc-400">
Battl Builder is your command center for rifle builds. We surface live Battl Builder&trade; is your command center for rifle builds. We surface live
parts and hard-use brands, giving you a mission-ready workbench to parts and hard-use brands, giving you a mission-ready workbench to
design smarter, track cost, swap components, and save setups. No design smarter, track cost, swap components, and save setups. No
fluff. Just clean, trustworthy data. fluff. Just clean, trustworthy data.
+1 -1
View File
@@ -16,7 +16,7 @@ export default function RegisterPage() {
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const next = searchParams.get("next") || "/gunbuilder"; const next = searchParams.get("next") || "/builder";
async function handleSubmit(e: FormEvent) { async function handleSubmit(e: FormEvent) {
e.preventDefault(); e.preventDefault();
-263
View File
@@ -1,263 +0,0 @@
// data/builderSlots.ts
/**
* Builder "slots" represent the major assemblies in a Battl Builder rifle:
* - Lower Receiver Assembly
* - Upper Receiver Assembly
* - Fire Control / Optics / Accessories, etc.
*
* Each slot can be satisfied by one or more "patterns" of categories.
* Example:
* Lower Assembly is satisfied by EITHER:
* - a single Complete Lower
* - OR a combination of stripped lower + parts + buffer + stock + grip + safety
*
* The frontend can use this file to:
* - show build progress for each slot
* - grey out / collapse sub-categories when a complete assembly is selected
* - validate that a build is "complete" before sharing/saving
*/
export type SlotGroup = "LOWER" | "UPPER" | "SYSTEM" | "ACCESSORIES";
/**
* CategoryId should correspond to CATEGORIES[id] from data/gunbuilderParts.ts
* We keep it loose here to avoid circular type imports.
*/
export type CategoryId = string;
export type BuilderSlotId =
| "lowerAssembly"
| "upperAssembly"
| "opticSystem"
| "sightSystem"
| "suppressorSystem"
| "magazineSystem"
| "accessoriesSupport";
export interface BuilderSlot {
id: BuilderSlotId;
label: string;
group: SlotGroup;
/**
* Human-facing description for tooltips / help text.
*/
description: string;
/**
* All category IDs that are visually associated with this slot.
* (Useful for layout / grouping in the UI.)
*/
categories: CategoryId[];
/**
* Each inner array is a valid way to "satisfy" the slot.
*
* Example (lowerAssembly):
* [
* ["completeLower"], // Pattern 1: single complete lower
* ["lower", "lowerParts", "trigger", "grip", "safety", "buffer", "stock"] // Pattern 2: custom lower
* ]
*/
satisfactionPatterns: CategoryId[][];
}
/**
* Core Battl Builder slots.
*
* These line up with your existing CATEGORIES in data/gunbuilderParts.ts:
*
* LOWER:
* - lower
* - completeLower
* - lowerParts
* - trigger
* - grip
* - safety
* - buffer
* - stock
*
* UPPER:
* - upper
* - completeUpper
* - bcg
* - barrel
* - gasBlock
* - gasTube
* - muzzleDevice
* - suppressor
* - handguard
* - chargingHandle
* - sights
* - optic
*
* ACCESSORIES:
* - magazine
* - weaponLight
* - foregrip
* - bipod
* - sling
* - railAccessory
* - tools
*/
export const BUILDER_SLOTS: BuilderSlot[] = [
{
id: "lowerAssembly",
label: "Lower Receiver Assembly",
group: "LOWER",
description:
"The serialized foundation of the rifle: receiver, controls, fire control, stock, and buffer system.",
categories: [
"lower",
"completeLower",
"lowerParts",
"trigger",
"grip",
"safety",
"buffer",
"stock",
],
satisfactionPatterns: [
// Pattern 1: single complete lower does it all
["completeLower"],
// Pattern 2: fully custom lower built from individual parts
["lower", "lowerParts", "trigger", "grip", "safety", "buffer", "stock"],
],
},
{
id: "upperAssembly",
label: "Upper Receiver Assembly",
group: "UPPER",
description:
"The working end of the rifle: upper, barrel, gas system, handguard, BCG, charging handle, and muzzle device.",
categories: [
"upper",
"completeUpper",
"bcg",
"barrel",
"gasBlock",
"gasTube",
"handguard",
"muzzleDevice",
"chargingHandle",
],
satisfactionPatterns: [
// Pattern 1: complete upper
["completeUpper"],
// Pattern 2: custom upper from parts
[
"upper",
"barrel",
"gasBlock",
"gasTube",
"handguard",
"bcg",
"chargingHandle",
"muzzleDevice",
],
],
},
{
id: "opticSystem",
label: "Optic System",
group: "SYSTEM",
description:
"Primary glass for the rifle: LPVOs, red dots, prisms, and magnifiers.",
categories: ["optic"],
satisfactionPatterns: [
// For now: any optic fills the slot.
["optic"],
],
},
{
id: "sightSystem",
label: "Backup / Iron Sights",
group: "SYSTEM",
description:
"Backup irons or primary sights for no-glass builds and hard-use setups.",
categories: ["sights"],
satisfactionPatterns: [["sights"]],
},
{
id: "suppressorSystem",
label: "Muzzle / Suppressor",
group: "SYSTEM",
description:
"Dedicated suppressor slot for QD or direct-thread cans. (Muzzle device is handled in the upper assembly patterns.)",
categories: ["suppressor"],
satisfactionPatterns: [["suppressor"]],
},
{
id: "magazineSystem",
label: "Magazines",
group: "ACCESSORIES",
description:
"Feeding the beast: mags appropriate to the platform and caliber.",
categories: ["magazine"],
satisfactionPatterns: [["magazine"]],
},
{
id: "accessoriesSupport",
label: "Support Gear",
group: "ACCESSORIES",
description:
"Lights, lasers, foregrips, bipods, slings, rail widgets, and tools that keep the rifle honest.",
categories: [
"weaponLight",
"foregrip",
"bipod",
"sling",
"railAccessory",
"tools",
],
// Accessories are all optional; this slot is never strictly "required"
satisfactionPatterns: [
// An empty pattern would technically always be satisfied,
// but we can treat this slot as "nice to have" in the UI instead.
["weaponLight"],
["foregrip"],
["bipod"],
["sling"],
["railAccessory"],
["tools"],
],
},
];
/**
* Helper: determine whether a slot is satisfied given currently selected parts.
*
* `selectedByCategory` is expected to be a map of categoryId → truthy if selected.
* The value can be the actual Part, or just `true` if you track it elsewhere.
*/
export function isSlotSatisfied(
slot: BuilderSlot,
selectedByCategory: Record<CategoryId, unknown>
): boolean {
return slot.satisfactionPatterns.some((pattern) =>
pattern.every((categoryId) => Boolean(selectedByCategory[categoryId]))
);
}
/**
* Optional helper: get the first pattern that is currently satisfied.
* Can be used to:
* - show which "path" the user took (complete vs custom)
* - conditionally collapse/expand sub-categories in the UI
*/
export function getSatisfiedPattern(
slot: BuilderSlot,
selectedByCategory: Record<CategoryId, unknown>
): CategoryId[] | null {
for (const pattern of slot.satisfactionPatterns) {
const ok = pattern.every((categoryId) =>
Boolean(selectedByCategory[categoryId])
);
if (ok) return pattern;
}
return null;
}
+1 -1
View File
@@ -10,7 +10,7 @@ export const CATEGORY_TO_PART_ROLES: Record<CategoryId, string[]> = {
buffer: ["buffer-kit"], buffer: ["buffer-kit"],
lowerParts: ["lower-parts-kit"], lowerParts: ["lower-parts-kit"],
sights: ["sight"], sights: ["sight"],
lower: ["LOWER_RECEIVER_STRIPPED"], lower: ["lower-receiver"],
optic: ["optic"], optic: ["optic"],
stock: ["stock"], stock: ["stock"],
-8
View File
@@ -994,7 +994,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@@ -1435,7 +1434,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.8.25", "baseline-browser-mapping": "^2.8.25",
"caniuse-lite": "^1.0.30001754", "caniuse-lite": "^1.0.30001754",
@@ -2098,7 +2096,6 @@
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1", "@eslint-community/regexpp": "^4.6.1",
@@ -4429,7 +4426,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"nanoid": "^3.3.7", "nanoid": "^3.3.7",
"picocolors": "^1.0.0", "picocolors": "^1.0.0",
@@ -4588,7 +4584,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"loose-envify": "^1.1.0" "loose-envify": "^1.1.0"
}, },
@@ -4601,7 +4596,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"loose-envify": "^1.1.0", "loose-envify": "^1.1.0",
"scheduler": "^0.23.2" "scheduler": "^0.23.2"
@@ -5548,7 +5542,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -5718,7 +5711,6 @@
"integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==", "integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"