Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6863b3618c | |||
| b4177883f8 | |||
| 48e677bcfb |
Generated
+8
@@ -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
@@ -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>
|
||||||
Generated
+8
@@ -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>
|
||||||
Generated
+9
@@ -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>
|
||||||
Generated
+6
@@ -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>
|
||||||
Generated
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<project version="4">
|
<project version="4">
|
||||||
<component name="VcsDirectoryMappings">
|
<component name="VcsDirectoryMappings">
|
||||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
<mapping directory="" vcs="Git" />
|
||||||
</component>
|
</component>
|
||||||
</project>
|
</project>
|
||||||
Generated
-93
@@ -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">{
|
|
||||||
"associatedIndex": 1
|
|
||||||
}</component>
|
|
||||||
<component name="ProjectId" id="361l08WqyxLtDro87xvcOWn3FhH" />
|
|
||||||
<component name="ProjectViewState">
|
|
||||||
<option name="hideEmptyMiddlePackages" value="true" />
|
|
||||||
<option name="showLibraryContents" value="true" />
|
|
||||||
</component>
|
|
||||||
<component name="PropertiesComponent">{
|
|
||||||
"keyToString": {
|
|
||||||
"ModuleVcsDetector.initialDetectionPerformed": "true",
|
|
||||||
"RunOnceActivity.ShowReadmeOnStart": "true",
|
|
||||||
"RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true",
|
|
||||||
"RunOnceActivity.git.unshallow": "true",
|
|
||||||
"git-widget-placeholder": "main",
|
|
||||||
"ignore.virus.scanning.warn.message": "true",
|
|
||||||
"js.debugger.nextJs.config.created.client": "true",
|
|
||||||
"js.debugger.nextJs.config.created.server": "true",
|
|
||||||
"kotlin-language-version-configured": "true",
|
|
||||||
"last_opened_file_path": "//wsl.localhost/Arch/home/don/code/shadow-gunbuilder-ai-proto",
|
|
||||||
"node.js.detected.package.eslint": "true",
|
|
||||||
"node.js.detected.package.tslint": "true",
|
|
||||||
"node.js.selected.package.eslint": "(autodetect)",
|
|
||||||
"node.js.selected.package.tslint": "(autodetect)",
|
|
||||||
"nodejs_package_manager_path": "npm",
|
|
||||||
"vue.rearranger.settings.migration": "true"
|
|
||||||
}
|
|
||||||
}</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>
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
+58
-169
@@ -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 = {
|
|
||||||
totalProducts: number;
|
|
||||||
mappedProducts: number;
|
|
||||||
unmappedProducts: number;
|
|
||||||
merchantCount: number;
|
|
||||||
categoryMappingCount: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function AdminLandingPage() {
|
|
||||||
const [overview, setOverview] = useState<AdminOverview | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
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/dashboard/overview`);
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error(`Failed to load dashboard (${res.status})`);
|
|
||||||
}
|
|
||||||
const data: AdminOverview = await res.json();
|
|
||||||
setOverview(data);
|
|
||||||
} catch (e: any) {
|
|
||||||
console.error(e);
|
|
||||||
setError(e.message ?? "Failed to load admin dashboard");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
load();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen bg-black text-zinc-50">
|
<div className="p-6 text-sm text-red-500">
|
||||||
<div className="mx-auto max-w-6xl px-4 py-8">
|
You must be logged in to view this page.
|
||||||
<header className="flex items-center justify-between gap-4">
|
</div>
|
||||||
<div>
|
);
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">
|
}
|
||||||
Battl Builder – Admin
|
|
||||||
</h1>
|
const cards = [
|
||||||
<p className="mt-1 text-sm text-zinc-400">
|
{
|
||||||
Control room for feeds, mappings, and catalog health.
|
title: "Canonical categories",
|
||||||
</p>
|
href: "admin/categories",
|
||||||
|
description:
|
||||||
|
"Manage the core builder categories and their group/grouping + sort order.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Part role mappings",
|
||||||
|
href: "/admin/part-role-mappings",
|
||||||
|
description:
|
||||||
|
"Advanced: map logical builder part roles to canonical categories per platform.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Merchant category mappings",
|
||||||
|
href: "/admin/merchant-category-mappings",
|
||||||
|
description:
|
||||||
|
"Map raw merchant feed categories to your canonical categories.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-5xl p-6 space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-lg font-semibold tracking-tight text-zinc-100">
|
||||||
|
Admin
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-xs text-zinc-400">
|
||||||
|
Internal tools to keep your builder data clean and consistent.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
</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 →
|
|
||||||
</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>
|
|
||||||
</main>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -84,11 +86,16 @@ export default function CategoryPage() {
|
|||||||
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,
|
||||||
@@ -156,7 +163,7 @@ export default function CategoryPage() {
|
|||||||
...prev,
|
...prev,
|
||||||
[categoryId]: partId,
|
[categoryId]: partId,
|
||||||
}));
|
}));
|
||||||
router.push("/builder");
|
router.push("/gunbuilder");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -302,7 +309,7 @@ export default function CategoryPage() {
|
|||||||
Category Not Found
|
Category Not Found
|
||||||
</h1>
|
</h1>
|
||||||
<Link
|
<Link
|
||||||
href="/builder"
|
href="/gunbuilder"
|
||||||
className="text-amber-300 hover:text-amber-200 underline"
|
className="text-amber-300 hover:text-amber-200 underline"
|
||||||
>
|
>
|
||||||
Return to Gunbuilder
|
Return to Gunbuilder
|
||||||
@@ -319,7 +326,7 @@ export default function CategoryPage() {
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<header className="mb-6">
|
<header className="mb-6">
|
||||||
<Link
|
<Link
|
||||||
href="/builder"
|
href="/gunbuilder"
|
||||||
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
|
||||||
@@ -663,7 +670,7 @@ export default function CategoryPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="mt-2 flex gap-2">
|
<div className="mt-2 flex gap-2">
|
||||||
<Link
|
<Link
|
||||||
href={`/builder/${categoryId}/${part.id}`}
|
href={`/gunbuilder/${categoryId}/${part.id}`}
|
||||||
className="flex-1 rounded-md border border-zinc-700 bg-zinc-800/50 px-3 py-2 text-center text-xs font-medium text-zinc-300 transition-colors hover:border-zinc-600 hover:bg-zinc-700"
|
className="flex-1 rounded-md border border-zinc-700 bg-zinc-800/50 px-3 py-2 text-center text-xs font-medium text-zinc-300 transition-colors hover:border-zinc-600 hover:bg-zinc-700"
|
||||||
>
|
>
|
||||||
View Details
|
View Details
|
||||||
@@ -726,7 +733,7 @@ export default function CategoryPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
<Link
|
<Link
|
||||||
href={`/builder/${categoryId}/${part.id}`}
|
href={`/gunbuilder/${categoryId}/${part.id}`}
|
||||||
className="whitespace-nowrap rounded-md border border-zinc-700 bg-zinc-800/50 px-3 py-1.5 text-xs font-medium text-zinc-300 transition-colors hover:border-zinc-600 hover:bg-zinc-700"
|
className="whitespace-nowrap rounded-md border border-zinc-700 bg-zinc-800/50 px-3 py-1.5 text-xs font-medium text-zinc-300 transition-colors hover:border-zinc-600 hover:bg-zinc-700"
|
||||||
>
|
>
|
||||||
View Details
|
View Details
|
||||||
|
|||||||
+8
-53
@@ -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
|
||||||
@@ -176,7 +175,7 @@ export default function GunbuilderPage() {
|
|||||||
return updated;
|
return updated;
|
||||||
});
|
});
|
||||||
|
|
||||||
router.replace("/builder", { scroll: false });
|
router.replace("/gunbuilder", { scroll: false });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [searchParams, router]);
|
}, [searchParams, router]);
|
||||||
@@ -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],
|
||||||
@@ -235,7 +223,7 @@ export default function GunbuilderPage() {
|
|||||||
const payload = JSON.stringify(build);
|
const payload = JSON.stringify(build);
|
||||||
const encoded = window.btoa(payload);
|
const encoded = window.btoa(payload);
|
||||||
const origin = window.location?.origin ?? "";
|
const origin = window.location?.origin ?? "";
|
||||||
const url = `${origin}/builder/build?build=${encodeURIComponent(
|
const url = `${origin}/gunbuilder/build?build=${encodeURIComponent(
|
||||||
encoded,
|
encoded,
|
||||||
)}`;
|
)}`;
|
||||||
setShareUrl(url);
|
setShareUrl(url);
|
||||||
@@ -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,7 +352,6 @@ 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 */}
|
||||||
@@ -385,7 +372,7 @@ export default function GunbuilderPage() {
|
|||||||
{/* Primary actions now live under the total */}
|
{/* Primary actions now live under the total */}
|
||||||
<div className="mt-2 flex flex-col items-stretch gap-2 sm:flex-row sm:flex-wrap">
|
<div className="mt-2 flex flex-col items-stretch gap-2 sm:flex-row sm:flex-wrap">
|
||||||
<Link
|
<Link
|
||||||
href="/builder/build"
|
href="/gunbuilder/build"
|
||||||
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 ${
|
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
|
selectedParts.length === 0
|
||||||
? "opacity-40 cursor-not-allowed pointer-events-none"
|
? "opacity-40 cursor-not-allowed pointer-events-none"
|
||||||
@@ -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">
|
||||||
@@ -643,7 +598,7 @@ export default function GunbuilderPage() {
|
|||||||
{selectedPart && selectedPart.url ? (
|
{selectedPart && selectedPart.url ? (
|
||||||
<>
|
<>
|
||||||
<Link
|
<Link
|
||||||
href={`/builder/${category.id}`}
|
href={`/gunbuilder/${category.id}`}
|
||||||
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"
|
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"
|
aria-label="Change part"
|
||||||
title="Change part"
|
title="Change part"
|
||||||
@@ -663,7 +618,7 @@ export default function GunbuilderPage() {
|
|||||||
</>
|
</>
|
||||||
) : hasParts ? (
|
) : hasParts ? (
|
||||||
<Link
|
<Link
|
||||||
href={`/builder/${category.id}`}
|
href={`/gunbuilder/${category.id}`}
|
||||||
className="inline-flex rounded-md border border-zinc-700 bg-zinc-900/70 px-2.5 py-1 text-[0.7rem] font-medium text-zinc-200 hover:bg-zinc-800 hover:border-zinc-600 transition-colors"
|
className="inline-flex rounded-md border border-zinc-700 bg-zinc-900/70 px-2.5 py-1 text-[0.7rem] font-medium text-zinc-200 hover:bg-zinc-800 hover:border-zinc-600 transition-colors"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
// If you want to pre-select something later, you can handle here.
|
// If you want to pre-select something later, you can handle here.
|
||||||
|
|||||||
+1
-1
@@ -22,7 +22,7 @@ export default function HomePage() {
|
|||||||
</p>
|
</p>
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
<Link
|
<Link
|
||||||
href="/builder"
|
href="/gunbuilder"
|
||||||
className="inline-flex items-center justify-center rounded-md border border-amber-400/70 bg-amber-400/10 px-4 py-2 text-sm font-medium text-amber-200 hover:bg-amber-400/15"
|
className="inline-flex items-center justify-center rounded-md border border-amber-400/70 bg-amber-400/10 px-4 py-2 text-sm font-medium text-amber-200 hover:bg-amber-400/15"
|
||||||
>
|
>
|
||||||
Launch The Builder
|
Launch The Builder
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export function BuilderNav({ activeCategoryId }: BuilderNavProps) {
|
|||||||
const currentCategory =
|
const currentCategory =
|
||||||
activeCategoryId ?? searchParams.get("category") ?? undefined;
|
activeCategoryId ?? searchParams.get("category") ?? undefined;
|
||||||
|
|
||||||
const baseHref = "/builder";
|
const baseHref = "/gunbuilder";
|
||||||
|
|
||||||
const renderDropdown = (label: string, items: Category[]) => {
|
const renderDropdown = (label: string, items: Category[]) => {
|
||||||
if (!items.length) return null;
|
if (!items.length) return null;
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export function CategoryColumn({
|
|||||||
<div className="w-full border border-zinc-700 rounded-md p-3 mb-2 flex items-center justify-between gap-3">
|
<div className="w-full border border-zinc-700 rounded-md p-3 mb-2 flex items-center justify-between gap-3">
|
||||||
<p className="text-sm text-zinc-500">No part selected</p>
|
<p className="text-sm text-zinc-500">No part selected</p>
|
||||||
<Link
|
<Link
|
||||||
href={`/builder/${category.id}`}
|
href={`/gunbuilder/${category.id}`}
|
||||||
className="inline-flex items-center gap-2 rounded-md border border-zinc-700 bg-zinc-900/60 px-3 py-1.5 text-xs font-medium text-zinc-200 hover:bg-zinc-800 hover:border-zinc-500 transition-colors"
|
className="inline-flex items-center gap-2 rounded-md border border-zinc-700 bg-zinc-900/60 px-3 py-1.5 text-xs font-medium text-zinc-200 hover:bg-zinc-800 hover:border-zinc-500 transition-colors"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
@@ -68,7 +68,7 @@ export function CategoryColumn({
|
|||||||
</div>
|
</div>
|
||||||
{parts.length >= 1 && (
|
{parts.length >= 1 && (
|
||||||
<Link
|
<Link
|
||||||
href={`/builder/${category.id}`}
|
href={`/gunbuilder/${category.id}`}
|
||||||
className="mt-2 w-full rounded-md border border-zinc-700 bg-zinc-900/50 px-3 py-2 text-xs font-medium text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600 transition-colors text-center"
|
className="mt-2 w-full rounded-md border border-zinc-700 bg-zinc-900/50 px-3 py-2 text-xs font-medium text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600 transition-colors text-center"
|
||||||
>
|
>
|
||||||
View All ({parts.length})
|
View All ({parts.length})
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export function TopNav() {
|
|||||||
{/* Center: main nav (Builder / Admin) */}
|
{/* Center: main nav (Builder / Admin) */}
|
||||||
<nav className="hidden items-center gap-4 md:flex">
|
<nav className="hidden items-center gap-4 md:flex">
|
||||||
<Link
|
<Link
|
||||||
href="/builder"
|
href="/gunbuilder"
|
||||||
className="text-xs font-medium text-zinc-300 hover:text-zinc-50 transition-colors"
|
className="text-xs font-medium text-zinc-300 hover:text-zinc-50 transition-colors"
|
||||||
>
|
>
|
||||||
Builder
|
Builder
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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"],
|
||||||
|
|
||||||
|
|||||||
Generated
-8
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user