mad cleanup.

This commit is contained in:
2025-12-29 13:59:17 -05:00
parent 0dd89a751b
commit cc386d14f4
14 changed files with 1054 additions and 315 deletions
+53 -40
View File
@@ -29,7 +29,7 @@ import { useAuth } from "@/context/AuthContext";
import { CATEGORIES } from "@/data/gunbuilderParts"; import { CATEGORIES } from "@/data/gunbuilderParts";
import { BUILDER_SLOTS, isSlotSatisfied } from "@/data/builderSlots"; import { BUILDER_SLOTS, isSlotSatisfied } from "@/data/builderSlots";
import type { CategoryId, Part } from "@/types/gunbuilder"; import type { BuilderSlotKey, Part } from "@/types/builderSlots";
import { PART_ROLE_TO_CATEGORY } from "@/lib/catalogMappings"; import { PART_ROLE_TO_CATEGORY } from "@/lib/catalogMappings";
// ✅ Centralized overlap rules + helpers // ✅ Centralized overlap rules + helpers
@@ -64,7 +64,7 @@ const API_BASE_URL =
const STORAGE_KEY = "gunbuilder-build-state"; const STORAGE_KEY = "gunbuilder-build-state";
// Categories that should NOT be filtered by platform (universal accessories / gear) // Categories that should NOT be filtered by platform (universal accessories / gear)
const UNIVERSAL_CATEGORIES = new Set<CategoryId>([ const UNIVERSAL_CATEGORIES = new Set<BuilderSlotKey>([
"magazine", "magazine",
"weapon-light", "weapon-light",
"foregrip", "foregrip",
@@ -82,7 +82,7 @@ const CATEGORY_GROUPS: {
id: string; id: string;
label: string; label: string;
description?: string; description?: string;
categoryIds: CategoryId[]; categoryIds: BuilderSlotKey[];
}[] = [ }[] = [
{ {
id: "lower-group", id: "lower-group",
@@ -92,10 +92,10 @@ const CATEGORY_GROUPS: {
categoryIds: [ categoryIds: [
"lower-receiver", "lower-receiver",
"complete-lower", "complete-lower",
"lower-parts", "lower-parts-kit", // ✅ was lower-parts
"trigger", "trigger",
"grip", "grip",
"safety", "safety-selector", // ✅ was safety
"buffer", "buffer",
"stock", "stock",
], ],
@@ -219,8 +219,8 @@ export default function GunbuilderPage() {
// ----------------------------- // -----------------------------
// Derived collections // Derived collections
// ----------------------------- // -----------------------------
const partsByCategory: Record<CategoryId, Part[]> = useMemo(() => { const partsByCategory: Record<BuilderSlotKey, Part[]> = useMemo(() => {
const grouped = {} as Record<CategoryId, Part[]>; const grouped = {} as Record<BuilderSlotKey, Part[]>;
for (const category of CATEGORIES) { for (const category of CATEGORIES) {
grouped[category.id] = parts.filter((p) => p.categoryId === category.id); grouped[category.id] = parts.filter((p) => p.categoryId === category.id);
} }
@@ -242,14 +242,14 @@ export default function GunbuilderPage() {
}); });
}, [build, parts]); }, [build, parts]);
const selectedByCategory: Record<CategoryId, unknown> = useMemo( const selectedByCategory: Record<BuilderSlotKey, unknown> = useMemo(
() => () =>
Object.fromEntries( Object.fromEntries(
Object.entries(build).map(([categoryId, partId]) => [ Object.entries(build).map(([categoryId, partId]) => [
categoryId as CategoryId, categoryId as BuilderSlotKey,
partId ? true : false, partId ? true : false,
]) ])
) as Record<CategoryId, unknown>, ) as Record<BuilderSlotKey, unknown>,
[build] [build]
); );
@@ -262,7 +262,7 @@ export default function GunbuilderPage() {
// Role -> Category resolver // Role -> Category resolver
// NOTE: defensive while backend roles/mappings evolve // NOTE: defensive while backend roles/mappings evolve
// ----------------------------- // -----------------------------
const resolveCategoryId = (normalizedRole: string): CategoryId | null => { const resolveCategoryId = (normalizedRole: string): BuilderSlotKey | null => {
if (normalizedRole === "upper-receiver") return "upper-receiver"; if (normalizedRole === "upper-receiver") return "upper-receiver";
if (normalizedRole.includes("complete-upper")) return "complete-upper"; if (normalizedRole.includes("complete-upper")) return "complete-upper";
@@ -311,15 +311,23 @@ export default function GunbuilderPage() {
if (normalizedRole.includes("suppress")) return "suppressor"; if (normalizedRole.includes("suppress")) return "suppressor";
// ✅ FIX: Lower Parts Kit canonical key
if ( if (
normalizedRole.includes("lower-parts") || normalizedRole.includes("lower-parts") ||
normalizedRole.includes("lpk") normalizedRole.includes("lpk")
) )
return "lower-parts"; return "lower-parts-kit";
if (normalizedRole.includes("trigger")) return "trigger"; if (normalizedRole.includes("trigger")) return "trigger";
if (normalizedRole.includes("grip")) return "grip"; if (normalizedRole.includes("grip")) return "grip";
if (normalizedRole.includes("safety")) return "safety";
// ✅ FIX: Safety canonical key
if (
normalizedRole.includes("safety") ||
normalizedRole.includes("selector")
)
return "safety-selector";
if (normalizedRole.includes("buffer")) return "buffer"; if (normalizedRole.includes("buffer")) return "buffer";
if (normalizedRole.includes("stock")) return "stock"; if (normalizedRole.includes("stock")) return "stock";
@@ -328,6 +336,7 @@ export default function GunbuilderPage() {
if (normalizedRole.includes("sight")) return "sights"; if (normalizedRole.includes("sight")) return "sights";
if (normalizedRole.includes("mag")) return "magazine"; if (normalizedRole.includes("mag")) return "magazine";
if ( if (
normalizedRole.includes("weapon-light") || normalizedRole.includes("weapon-light") ||
(normalizedRole.includes("light") && !normalizedRole.includes("flight")) (normalizedRole.includes("light") && !normalizedRole.includes("flight"))
@@ -354,27 +363,6 @@ export default function GunbuilderPage() {
return (PART_ROLE_TO_CATEGORY as any)[normalizedRole] ?? null; return (PART_ROLE_TO_CATEGORY as any)[normalizedRole] ?? null;
}; };
// -----------------------------
// Selection handler (with hint warnings)
// -----------------------------
const handleSelectPart = useCallback(
(categoryId: CategoryId, partId: string, _opts?: { confirm?: boolean }) => {
const warn = (msg: string) => {
setShareStatus(msg);
window.setTimeout(() => setShareStatus(null), 4000);
};
const hints = getSelectionHints(categoryId, build);
if (hints.length > 0) warn(hints[0]);
setBuild((prev) => ({
...prev,
[categoryId]: partId,
}));
},
[build]
);
// ----------------------------- // -----------------------------
// Fetch products (scoped + universal merge) // Fetch products (scoped + universal merge)
// ----------------------------- // -----------------------------
@@ -501,7 +489,7 @@ export default function GunbuilderPage() {
setBuild((prevBuild) => { setBuild((prevBuild) => {
const next: BuildState = {}; const next: BuildState = {};
for (const [categoryId, partId] of Object.entries(prevBuild)) { for (const [categoryId, partId] of Object.entries(prevBuild)) {
const cid = categoryId as CategoryId; const cid = categoryId as BuilderSlotKey;
if (UNIVERSAL_CATEGORIES.has(cid)) { if (UNIVERSAL_CATEGORIES.has(cid)) {
next[cid] = partId; next[cid] = partId;
} }
@@ -661,6 +649,31 @@ export default function GunbuilderPage() {
} }
}; };
// -----------------------------
// Selection handler (with hint warnings)
// -----------------------------
const handleSelectPart = useCallback(
(
categoryId: BuilderSlotKey,
partId: string,
_opts?: { confirm?: boolean }
) => {
const warn = (msg: string) => {
setShareStatus(msg);
window.setTimeout(() => setShareStatus(null), 4000);
};
const hints = getSelectionHints(categoryId, build);
if (hints.length > 0) warn(hints[0]);
setBuild((prev) => ({
...prev,
[categoryId]: partId,
}));
},
[build]
);
// ----------------------------- // -----------------------------
// Handle URL query parameters: // Handle URL query parameters:
// - ?select=categoryId:partId // - ?select=categoryId:partId
@@ -685,7 +698,7 @@ export default function GunbuilderPage() {
if (selectParam) { if (selectParam) {
const [categoryIdRaw, partId] = selectParam.split(":"); const [categoryIdRaw, partId] = selectParam.split(":");
const requestedCategoryId = categoryIdRaw as CategoryId; const requestedCategoryId = categoryIdRaw as BuilderSlotKey;
if (requestedCategoryId && partId) { if (requestedCategoryId && partId) {
handleSelectPart(requestedCategoryId, partId, { confirm: false }); handleSelectPart(requestedCategoryId, partId, { confirm: false });
@@ -696,7 +709,7 @@ export default function GunbuilderPage() {
if (CATEGORIES.some((c) => c.id === removeParam)) { if (CATEGORIES.some((c) => c.id === removeParam)) {
setBuild((prev) => { setBuild((prev) => {
const next: BuildState = { ...prev }; const next: BuildState = { ...prev };
delete next[removeParam as CategoryId]; delete next[removeParam as BuilderSlotKey];
return next; return next;
}); });
} }
@@ -744,7 +757,7 @@ export default function GunbuilderPage() {
} }
}, [build]); }, [build]);
const summaryCategoryOrder: CategoryId[] = useMemo( const summaryCategoryOrder: BuilderSlotKey[] = useMemo(
() => () =>
CATEGORY_GROUPS.flatMap((group) => group.categoryIds).filter((id) => CATEGORY_GROUPS.flatMap((group) => group.categoryIds).filter((id) =>
CATEGORIES.some((c) => c.id === id) CATEGORIES.some((c) => c.id === id)
@@ -1164,7 +1177,7 @@ export default function GunbuilderPage() {
<div className="space-y-6"> <div className="space-y-6">
{CATEGORY_GROUPS.map((group) => { {CATEGORY_GROUPS.map((group) => {
const groupCategories = CATEGORIES.filter((c) => const groupCategories = CATEGORIES.filter((c) =>
group.categoryIds.includes(c.id as CategoryId) group.categoryIds.includes(c.id as BuilderSlotKey)
); );
if (groupCategories.length === 0) return null; if (groupCategories.length === 0) return null;
@@ -1260,7 +1273,7 @@ export default function GunbuilderPage() {
{/* Overlap chips help explain conflicts / dependencies */} {/* Overlap chips help explain conflicts / dependencies */}
<div className="mt-1 flex flex-wrap gap-1.5"> <div className="mt-1 flex flex-wrap gap-1.5">
{getOverlapChipsForCategory( {getOverlapChipsForCategory(
category.id as CategoryId, category.id as BuilderSlotKey,
build build
).map((c) => ( ).map((c) => (
<OverlapChip <OverlapChip
@@ -3,7 +3,7 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useParams, useRouter, useSearchParams } from "next/navigation"; import { useParams, useRouter, useSearchParams } from "next/navigation";
import type { CategoryId } from "@/types/gunbuilder"; import type { CategoryId } from "@/types/builderSlots";
import { import {
PART_ROLE_TO_CATEGORY, PART_ROLE_TO_CATEGORY,
normalizePartRole, normalizePartRole,
@@ -4,7 +4,7 @@ import { useEffect, useMemo, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useParams, useRouter, useSearchParams } from "next/navigation"; import { useParams, useRouter, useSearchParams } from "next/navigation";
import { CATEGORIES } from "@/data/gunbuilderParts"; import { CATEGORIES } from "@/data/gunbuilderParts";
import type { CategoryId, Part } from "@/types/gunbuilder"; import type { CategoryId, Part } from "@/types/builderSlots";
import { CATEGORY_TO_PART_ROLES } from "@/data/partRoleMappings"; import { CATEGORY_TO_PART_ROLES } from "@/data/partRoleMappings";
type ViewMode = "card" | "list"; type ViewMode = "card" | "list";
@@ -4,7 +4,7 @@ import { useEffect, useMemo, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useParams, useRouter, useSearchParams } from "next/navigation"; import { useParams, useRouter, useSearchParams } from "next/navigation";
import type { CategoryId } from "@/types/gunbuilder"; import type { CategoryId } from "@/types/builderSlots";
import { PART_ROLE_TO_CATEGORY, normalizePartRole } from "@/lib/catalogMappings"; import { PART_ROLE_TO_CATEGORY, normalizePartRole } from "@/lib/catalogMappings";
/** /**
+339
View File
@@ -0,0 +1,339 @@
"use client";
import React, { useMemo, useState } from "react";
type Counts = Record<string, number>;
type DiffRow = {
productId: number;
name: string;
platform: string | null;
rawCategoryKey: string | null;
resolvedMerchantId: number | null;
existingPartRole: string | null;
existingSource: string | null;
partRoleLocked: boolean | null;
platformLocked: boolean | null;
resolvedPartRole: string | null;
resolvedSource: string | null;
resolvedConfidence: number | null;
resolvedReason: string | null;
status: string;
meta?: Record<string, any>;
};
type ReconcileResponse = {
dryRun: boolean;
scanned: number;
counts: Counts;
samples: DiffRow[];
};
const DEFAULT_LIMIT = 500;
const DEFAULT_PLATFORM = "AR-15";
// Update these if your admin mapping UI lives elsewhere.
const MAPPING_UI_PATH = "/admin/mapping"; // <- change if needed
const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
const url = `${API_BASE}/admin/classification/reconcile?dryRun=true&limit=500&platform=AR-15&merchantId=4`;
export default function AdminClassificationReconcilePage() {
const [merchantId, setMerchantId] = useState<string>("4"); // your current test merchant
const [platform, setPlatform] = useState<string>(DEFAULT_PLATFORM);
const [limit, setLimit] = useState<number>(DEFAULT_LIMIT);
const [includeLocked, setIncludeLocked] = useState<boolean>(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [data, setData] = useState<ReconcileResponse | null>(null);
const [statusFilter, setStatusFilter] = useState<string>("ALL");
const filteredSamples = useMemo(() => {
if (!data?.samples) return [];
if (statusFilter === "ALL") return data.samples;
return data.samples.filter((r) => r.status === statusFilter);
}, [data, statusFilter]);
const uniqueStatuses = useMemo(() => {
const set = new Set<string>();
(data?.samples ?? []).forEach((r) => set.add(r.status));
return ["ALL", ...Array.from(set.values()).sort()];
}, [data]);
async function runReconcile() {
setLoading(true);
setError(null);
try {
const params = new URLSearchParams();
params.set("dryRun", "true");
params.set("limit", String(limit));
if (platform?.trim()) params.set("platform", platform.trim());
if (merchantId?.trim()) params.set("merchantId", merchantId.trim());
if (includeLocked) params.set("includeLocked", "true");
const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
const url = `${API_BASE}/admin/classification/reconcile?${params.toString()}`;
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
cache: "no-store",
});
// Helpful error body (also avoids the "<!DOCTYPE" confusion)
const text = await res.text();
if (!res.ok) throw new Error(text || `Request failed (${res.status})`);
const json = JSON.parse(text) as ReconcileResponse;
setData(json);
} catch (e: any) {
setError(e?.message ?? "Unknown error");
setData(null);
} finally {
setLoading(false);
}
}
function mappingLink(row: DiffRow) {
// Deep-link into existing mapping UI with prefilled values.
// Adjust param names to match your mapping UI, if needed.
const qs = new URLSearchParams();
if (merchantId) qs.set("merchantId", merchantId);
if (platform) qs.set("platform", platform);
if (row.rawCategoryKey) qs.set("rawCategoryKey", row.rawCategoryKey);
return `${MAPPING_UI_PATH}?${qs.toString()}`;
}
return (
<div className="mx-auto max-w-6xl px-6 py-10">
<div className="mb-8">
<h1 className="text-2xl font-semibold tracking-tight">Classification Reconcile</h1>
<p className="mt-2 text-sm text-zinc-400">
Dry-run inspector for part-role classification. Finds <span className="text-zinc-200">UNMAPPED</span>,{" "}
<span className="text-zinc-200">IGNORED</span>, rule hits, and drift. (No writes. No regrets.)
</p>
</div>
{/* Controls */}
<div className="rounded-lg border border-zinc-800 bg-zinc-950/60 p-5">
<div className="grid grid-cols-1 gap-4 md:grid-cols-5">
<label className="block">
<div className="text-xs text-zinc-400">Merchant ID</div>
<input
value={merchantId}
onChange={(e) => setMerchantId(e.target.value)}
className="mt-1 w-full rounded-md border border-zinc-800 bg-zinc-950 px-3 py-2 text-sm text-zinc-100"
placeholder="4"
/>
</label>
<label className="block">
<div className="text-xs text-zinc-400">Platform</div>
<select
value={platform}
onChange={(e) => setPlatform(e.target.value)}
className="mt-1 w-full rounded-md border border-zinc-800 bg-zinc-950 px-3 py-2 text-sm text-zinc-100"
>
<option value="AR-15">AR-15</option>
<option value="AR-10">AR-10</option>
<option value="AR-9">AR-9</option>
<option value="AK-47">AK-47</option>
<option value="">(any)</option>
</select>
</label>
<label className="block">
<div className="text-xs text-zinc-400">Limit</div>
<input
type="number"
value={limit}
onChange={(e) => setLimit(Number(e.target.value))}
className="mt-1 w-full rounded-md border border-zinc-800 bg-zinc-950 px-3 py-2 text-sm text-zinc-100"
min={1}
max={20000}
/>
</label>
<label className="flex items-end gap-2">
<input
type="checkbox"
checked={includeLocked}
onChange={(e) => setIncludeLocked(e.target.checked)}
className="h-4 w-4 accent-zinc-200"
/>
<span className="text-sm text-zinc-200">Include locked</span>
</label>
<div className="flex items-end justify-end">
<button
onClick={runReconcile}
disabled={loading}
className="inline-flex w-full items-center justify-center rounded-md bg-zinc-100 px-4 py-2 text-sm font-medium text-zinc-900 hover:bg-white disabled:opacity-60 md:w-auto"
>
{loading ? "Running…" : "Run reconcile"}
</button>
</div>
</div>
{error && (
<div className="mt-4 rounded-md border border-red-900/40 bg-red-950/30 px-4 py-3 text-sm text-red-200">
{error}
</div>
)}
</div>
{/* Results */}
{data && (
<div className="mt-8 space-y-6">
<div className="rounded-lg border border-zinc-800 bg-zinc-950/60 p-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="text-sm text-zinc-300">
Scanned: <span className="text-zinc-100 font-medium">{data.scanned}</span> DryRun:{" "}
<span className="text-zinc-100 font-medium">{String(data.dryRun)}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-zinc-400">Filter</span>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="rounded-md border border-zinc-800 bg-zinc-950 px-3 py-2 text-sm text-zinc-100"
>
{uniqueStatuses.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
</div>
</div>
{/* Counts */}
<div className="mt-4 grid grid-cols-2 gap-3 md:grid-cols-7">
{Object.entries(data.counts ?? {}).map(([k, v]) => (
<div key={k} className="rounded-md border border-zinc-800 bg-zinc-950 px-3 py-2">
<div className="text-[11px] uppercase tracking-wider text-zinc-500">{k}</div>
<div className="text-lg font-semibold text-zinc-100">{v}</div>
</div>
))}
</div>
</div>
{/* Samples table */}
<div className="rounded-lg border border-zinc-800 bg-zinc-950/60">
<div className="flex items-center justify-between border-b border-zinc-800 px-5 py-4">
<div>
<div className="text-sm font-medium text-zinc-100">Samples</div>
<div className="text-xs text-zinc-400">
Showing up to 50 interesting rows (UNMAPPED / IGNORED / CONFLICT / WOULD_UPDATE + rule hits).
</div>
</div>
<a
href={MAPPING_UI_PATH}
className="text-sm text-zinc-200 underline decoration-zinc-700 underline-offset-4 hover:text-white"
>
Go to mappings
</a>
</div>
<div className="overflow-auto">
<table className="w-full text-left text-sm">
<thead className="sticky top-0 bg-zinc-950">
<tr className="border-b border-zinc-800 text-xs text-zinc-400">
<th className="px-5 py-3">Status</th>
<th className="px-5 py-3">Product</th>
<th className="px-5 py-3">Raw Category</th>
<th className="px-5 py-3">Existing Role</th>
<th className="px-5 py-3">Resolved</th>
<th className="px-5 py-3">Why</th>
<th className="px-5 py-3"></th>
</tr>
</thead>
<tbody>
{filteredSamples.length === 0 ? (
<tr>
<td colSpan={7} className="px-5 py-6 text-zinc-400">
No rows match this filter.
</td>
</tr>
) : (
filteredSamples.map((r) => (
<tr key={r.productId} className="border-b border-zinc-900 align-top">
<td className="px-5 py-3">
<span className="rounded-md border border-zinc-800 bg-zinc-950 px-2 py-1 text-xs text-zinc-200">
{r.status}
</span>
{r.resolvedSource?.startsWith("rules_") && (
<div className="mt-2 text-[11px] text-zinc-500">
rule: <span className="text-zinc-300">{r.resolvedSource}</span>
</div>
)}
</td>
<td className="px-5 py-3">
<div className="text-zinc-100">{r.name}</div>
<div className="mt-1 text-xs text-zinc-500">
#{r.productId} {r.platform ?? "—"} merchantUsed: {r.resolvedMerchantId ?? "—"}
</div>
</td>
<td className="px-5 py-3">
<div className="text-zinc-200">{r.rawCategoryKey ?? "—"}</div>
</td>
<td className="px-5 py-3">
<div className="text-zinc-200">{r.existingPartRole ?? "—"}</div>
<div className="mt-1 text-xs text-zinc-500">
src: {r.existingSource ?? "—"}
{r.partRoleLocked ? " • locked" : ""}
{r.platformLocked ? " • platformLocked" : ""}
</div>
</td>
<td className="px-5 py-3">
<div className="text-zinc-100">{r.resolvedPartRole ?? "—"}</div>
<div className="mt-1 text-xs text-zinc-500">
src: {r.resolvedSource ?? "—"} conf:{" "}
{typeof r.resolvedConfidence === "number" ? r.resolvedConfidence.toFixed(2) : "—"}
</div>
</td>
<td className="px-5 py-3">
<div className="text-zinc-300">{r.resolvedReason ?? "—"}</div>
</td>
<td className="px-5 py-3 text-right">
{r.status === "UNMAPPED" && r.rawCategoryKey ? (
<a
href={mappingLink(r)}
className="rounded-md border border-zinc-800 bg-zinc-950 px-3 py-2 text-xs text-zinc-200 hover:text-white"
>
Map category
</a>
) : (
<span className="text-xs text-zinc-600"></span>
)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
)}
</div>
);
}
+540 -84
View File
@@ -1,10 +1,21 @@
"use client"; "use client";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useSearchParams, useRouter } from "next/navigation";
const API_BASE_URL = const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080"; process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
/**
* Tabs:
* - roles: map raw category -> canonical_part_role (builder slot)
* - catalog: map raw category -> canonical_category_id (FK to canonical_categories)
*/
type TabKey = "roles" | "catalog";
/**
* Existing (you already have this working)
*/
type PendingBucket = { type PendingBucket = {
merchantId: number; merchantId: number;
merchantName: string; merchantName: string;
@@ -14,8 +25,47 @@ type PendingBucket = {
}; };
/** /**
* Canonical part roles (kebab-case) — should match what the importer normalizes to. * New “raw categories” row (for the combined UI).
* Keep this list tight + intentional so mappings don't create junk roles in the DB. * If your backend returns fewer fields at first, its fine—just keep the ones you have.
*/
type RawCategoryRow = {
merchantId: number;
merchantName?: string | null;
platform?: string | null;
rawCategoryKey: string;
productCount: number;
// current mapping state (merchant_category_map row)
mcmId?: number | null;
enabled?: boolean | null;
canonicalPartRole?: string | null;
canonicalCategoryId?: number | null;
canonicalCategoryName?: string | null;
};
type CanonicalCategoryOption = {
id: number;
name: string;
slug?: string | null;
};
type MerchantOption = {
id: number;
name: string;
};
type MappingOptionsResponse = {
merchants: MerchantOption[];
canonicalCategories: CanonicalCategoryOption[];
};
const DEFAULT_PLATFORM = "AR-15";
/**
* Canonical part roles (kebab-case)
*/ */
const PART_ROLE_OPTIONS = [ const PART_ROLE_OPTIONS = [
// Assemblies / receivers // Assemblies / receivers
@@ -56,23 +106,62 @@ const PART_ROLE_OPTIONS = [
"tools", "tools",
] as const; ] as const;
type PartRole = (typeof PART_ROLE_OPTIONS)[number];
function toLabel(role: string) { function toLabel(role: string) {
// "complete-upper" -> "Complete Upper"
return role return role
.split("-") .split("-")
.map((w) => w.charAt(0).toUpperCase() + w.slice(1)) .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(" "); .join(" ");
} }
function normalizeRole(role: string) {
return role.trim().toLowerCase().replace(/_/g, "-");
}
function safeNum(v: any): number | null {
if (v == null) return null;
if (typeof v === "number") return Number.isFinite(v) ? v : null;
if (typeof v === "string") {
const n = Number(v);
return Number.isFinite(n) ? n : null;
}
return null;
}
export default function MappingAdminPage() { export default function MappingAdminPage() {
const [rows, setRows] = useState<PendingBucket[]>([]); const searchParams = useSearchParams();
const [loading, setLoading] = useState(true); const router = useRouter();
const [savingKey, setSavingKey] = useState<string | null>(null);
// URL-driven state
const initialTab = (searchParams.get("tab") as TabKey) || "roles";
const initialMerchantId = searchParams.get("merchantId") || "";
const initialPlatform = searchParams.get("platform") || DEFAULT_PLATFORM;
const initialQ = searchParams.get("q") || "";
const [tab, setTab] = useState<TabKey>(initialTab);
const [merchantId, setMerchantId] = useState<string>(initialMerchantId);
const [platform, setPlatform] = useState<string>(initialPlatform);
const [q, setQ] = useState<string>(initialQ);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const options = useMemo( // Options (for catalog tab + merchant dropdown)
const [optionsLoading, setOptionsLoading] = useState<boolean>(false);
const [merchants, setMerchants] = useState<MerchantOption[]>([]);
const [canonicalCategories, setCanonicalCategories] = useState<
CanonicalCategoryOption[]
>([]);
// Rows
const [roleBuckets, setRoleBuckets] = useState<PendingBucket[]>([]);
const [rawRows, setRawRows] = useState<RawCategoryRow[]>([]);
// Saving state
const [savingKey, setSavingKey] = useState<string | null>(null);
// Part role dropdown options
const roleOptions = useMemo(
() => () =>
PART_ROLE_OPTIONS.map((value) => ({ PART_ROLE_OPTIONS.map((value) => ({
value, value,
@@ -81,16 +170,83 @@ export default function MappingAdminPage() {
[] []
); );
// Keep URL in sync (so reconcile can deep-link)
function syncUrl(next: Partial<{ tab: TabKey; merchantId: string; platform: string; q: string }>) {
const params = new URLSearchParams(searchParams.toString());
if (next.tab) params.set("tab", next.tab);
if (next.merchantId !== undefined) {
if (next.merchantId) params.set("merchantId", next.merchantId);
else params.delete("merchantId");
}
if (next.platform !== undefined) {
if (next.platform) params.set("platform", next.platform);
else params.delete("platform");
}
if (next.q !== undefined) {
if (next.q) params.set("q", next.q);
else params.delete("q");
}
router.replace(`/admin/mapping?${params.toString()}`);
}
// Load merchants + canonical categories (needed for catalog tab, but also nice for filtering everywhere)
useEffect(() => { useEffect(() => {
async function load() { async function loadOptions() {
setOptionsLoading(true);
try { try {
setLoading(true);
setError(null); setError(null);
const res = await fetch( // EXPECTED backend endpoint (new):
`${API_BASE_URL}/api/admin/mapping/pending-buckets`, // GET { merchants: [{id,name}], canonicalCategories: [{id,name,slug}] }
{ headers: { Accept: "application/json" } } const res = await fetch(`${API_BASE_URL}/api/admin/mapping/options`, {
); headers: { Accept: "application/json" },
cache: "no-store",
});
if (!res.ok) {
// Dont hard-fail the whole page if options arent wired yet.
// Roles tab can still function using pending-buckets.
const txt = await res.text().catch(() => "");
console.warn("options endpoint not ready:", res.status, txt);
return;
}
const json = (await res.json()) as MappingOptionsResponse;
setMerchants(json.merchants ?? []);
setCanonicalCategories(json.canonicalCategories ?? []);
} catch (e) {
console.warn("Failed to load mapping options:", e);
} finally {
setOptionsLoading(false);
}
}
loadOptions();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Default merchant if not provided and merchants list exists
useEffect(() => {
if (!merchantId && merchants.length > 0) {
setMerchantId(String(merchants[0].id));
syncUrl({ merchantId: String(merchants[0].id) });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [merchants]);
// Core loader: depends on tab
async function load() {
setLoading(true);
setError(null);
try {
if (tab === "roles") {
// Existing endpoint you already have:
// GET /api/admin/mapping/pending-buckets
const res = await fetch(`${API_BASE_URL}/api/admin/mapping/pending-buckets`, {
headers: { Accept: "application/json" },
cache: "no-store",
});
if (!res.ok) { if (!res.ok) {
const text = await res.text().catch(() => ""); const text = await res.text().catch(() => "");
@@ -100,40 +256,71 @@ export default function MappingAdminPage() {
} }
const data: PendingBucket[] = await res.json(); const data: PendingBucket[] = await res.json();
setRows(data); setRoleBuckets(data);
} catch (e: any) { setRawRows([]);
console.error(e); return;
setError(e.message ?? "Failed to load pending buckets");
} finally {
setLoading(false);
} }
}
// tab === "catalog"
// EXPECTED new endpoint:
// GET /api/admin/mapping/raw-categories?merchantId=4&platform=AR-15&q=...
// returns rows with productCount + mapping state including canonicalCategoryId
if (!merchantId) {
setRawRows([]);
setError("Pick a merchant to view catalog mappings.");
return;
}
const params = new URLSearchParams();
params.set("merchantId", merchantId);
if (platform?.trim()) params.set("platform", platform.trim());
if (q?.trim()) params.set("q", q.trim());
const res = await fetch(
`${API_BASE_URL}/api/admin/mapping/raw-categories?${params.toString()}`,
{ headers: { Accept: "application/json" }, cache: "no-store" }
);
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(
`Catalog mapping endpoint not ready (${res.status})${text ? `: ${text}` : ""}`
);
}
const data: RawCategoryRow[] = await res.json();
setRawRows(data);
setRoleBuckets([]);
} catch (e: any) {
console.error(e);
setError(e?.message ?? "Failed to load mapping data");
} finally {
setLoading(false);
}
}
// Load on mount + when tab changes
useEffect(() => {
load(); load();
}, []); // eslint-disable-next-line react-hooks/exhaustive-deps
}, [tab]);
// ===== Roles tab actions =====
const handleChangeRole = (idx: number, value: string) => { const handleChangeRole = (idx: number, value: string) => {
// Always store normalized kebab-case (defensive) const normalized = value ? normalizeRole(value) : "";
const normalized = value setRoleBuckets((prev) => {
? value.trim().toLowerCase().replace(/_/g, "-")
: "";
setRows((prev) => {
const next = [...prev]; const next = [...prev];
next[idx] = { ...next[idx], mappedPartRole: normalized || null }; next[idx] = { ...next[idx], mappedPartRole: normalized || null };
return next; return next;
}); });
}; };
const handleSave = async (row: PendingBucket) => { const handleSaveRole = async (row: PendingBucket) => {
if (!row.mappedPartRole) return; if (!row.mappedPartRole) return;
const mapped = row.mappedPartRole const mapped = normalizeRole(row.mappedPartRole);
.trim()
.toLowerCase()
.replace(/_/g, "-");
// Basic guard: prevent saving random roles from stale data
const allowed = new Set<string>(PART_ROLE_OPTIONS as readonly string[]); const allowed = new Set<string>(PART_ROLE_OPTIONS as readonly string[]);
if (!allowed.has(mapped)) { if (!allowed.has(mapped)) {
setError( setError(
@@ -142,11 +329,13 @@ export default function MappingAdminPage() {
return; return;
} }
const key = `${row.merchantId}-${row.rawCategoryKey}`; const key = `roles-${row.merchantId}-${row.rawCategoryKey}`;
try { try {
setSavingKey(key); setSavingKey(key);
setError(null); setError(null);
// Existing endpoint you already have:
// POST /api/admin/mapping/apply
const res = await fetch(`${API_BASE_URL}/api/admin/mapping/apply`, { const res = await fetch(`${API_BASE_URL}/api/admin/mapping/apply`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
@@ -164,8 +353,7 @@ export default function MappingAdminPage() {
); );
} }
// After save, remove this bucket from the list setRoleBuckets((prev) =>
setRows((prev) =>
prev.filter( prev.filter(
(r) => (r) =>
!( !(
@@ -176,46 +364,228 @@ export default function MappingAdminPage() {
); );
} catch (e: any) { } catch (e: any) {
console.error(e); console.error(e);
setError(e.message ?? "Failed to save mapping"); setError(e?.message ?? "Failed to save mapping");
} finally { } finally {
setSavingKey(null); setSavingKey(null);
} }
}; };
// ===== Catalog tab actions =====
const updateRawRow = (idx: number, patch: Partial<RawCategoryRow>) => {
setRawRows((prev) => {
const next = [...prev];
next[idx] = { ...next[idx], ...patch };
return next;
});
};
const handleSaveCatalog = async (row: RawCategoryRow) => {
if (!merchantId) return;
const key = `catalog-${row.merchantId}-${row.rawCategoryKey}`;
try {
setSavingKey(key);
setError(null);
// EXPECTED new endpoint:
// POST /api/admin/mapping/upsert
const res = await fetch(`${API_BASE_URL}/api/admin/mapping/upsert`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
merchantId: row.merchantId,
platform: row.platform ?? platform ?? null,
rawCategory: row.rawCategoryKey,
enabled: row.enabled ?? true,
canonicalCategoryId: row.canonicalCategoryId ?? null,
// do NOT overwrite part role here; backend should merge/partial update
canonicalPartRole: null,
}),
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(
`Failed to save catalog mapping (${res.status})${text ? `: ${text}` : ""}`
);
}
// Reload after save so we reflect the servers truth.
await load();
} catch (e: any) {
console.error(e);
setError(e?.message ?? "Failed to save catalog mapping");
} finally {
setSavingKey(null);
}
};
const totals = useMemo(() => {
if (tab === "roles") {
return {
buckets: roleBuckets.length,
products: roleBuckets.reduce((s, r) => s + (r.productCount ?? 0), 0),
};
}
return {
buckets: rawRows.length,
products: rawRows.reduce((s, r) => s + (r.productCount ?? 0), 0),
};
}, [tab, roleBuckets, rawRows]);
return ( return (
<main className="min-h-screen bg-black text-zinc-50"> <main className="min-h-screen bg-black text-zinc-50">
<div className="mx-auto max-w-6xl px-4 py-6"> <div className="mx-auto max-w-6xl px-4 py-6">
<h1 className="text-xl font-semibold tracking-tight"> <div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
Bulk Mapping Pending Categories <div>
</h1> <h1 className="text-xl font-semibold tracking-tight">Admin Mapping</h1>
<p className="mt-1 text-sm text-zinc-400"> <p className="mt-1 text-sm text-zinc-400">
Bucketed by merchant + raw category. Map each bucket to a part role to Manage <span className="text-zinc-200">builder part roles</span> and{" "}
clean up the builder catalog. <span className="text-zinc-200">catalog categories</span> without
</p> blending worlds.
</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => load()}
className="rounded-md border border-zinc-700 bg-zinc-950/60 px-3 py-1.5 text-xs font-semibold text-zinc-100 hover:bg-zinc-900/60"
>
Refresh
</button>
</div>
</div>
{/* Tabs */}
<div className="mt-4 flex items-center gap-2">
<button
onClick={() => {
setTab("roles");
syncUrl({ tab: "roles" });
}}
className={[
"rounded-md px-3 py-1.5 text-xs font-semibold",
tab === "roles"
? "bg-amber-400 text-black"
: "border border-zinc-700 bg-zinc-950/60 text-zinc-100 hover:bg-zinc-900/60",
].join(" ")}
>
Builder Part Roles
</button>
<button
onClick={() => {
setTab("catalog");
syncUrl({ tab: "catalog" });
}}
className={[
"rounded-md px-3 py-1.5 text-xs font-semibold",
tab === "catalog"
? "bg-amber-400 text-black"
: "border border-zinc-700 bg-zinc-950/60 text-zinc-100 hover:bg-zinc-900/60",
].join(" ")}
>
Catalog Categories
</button>
<span className="ml-auto text-xs text-zinc-400">
{totals.buckets} rows {totals.products} products
</span>
</div>
{/* Filters (catalog tab only) */}
{tab === "catalog" && (
<section className="mt-4 rounded-lg border border-zinc-800 bg-zinc-950/80 p-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<div>
<label className="block text-[0.7rem] font-semibold uppercase tracking-[0.14em] text-zinc-500">
Merchant
</label>
<select
className="mt-1 w-full rounded-md border border-zinc-700 bg-zinc-950/80 px-2 py-2 text-xs text-zinc-100 outline-none focus:border-amber-400"
value={merchantId}
onChange={(e) => {
setMerchantId(e.target.value);
syncUrl({ merchantId: e.target.value });
}}
>
<option value="">Select merchant</option>
{merchants.map((m) => (
<option key={m.id} value={String(m.id)}>
{m.name} (#{m.id})
</option>
))}
</select>
{optionsLoading && (
<p className="mt-1 text-[0.7rem] text-zinc-500">Loading merchants</p>
)}
</div>
<div>
<label className="block text-[0.7rem] font-semibold uppercase tracking-[0.14em] text-zinc-500">
Platform
</label>
<input
className="mt-1 w-full rounded-md border border-zinc-700 bg-zinc-950/80 px-2 py-2 text-xs text-zinc-100 outline-none focus:border-amber-400"
value={platform}
onChange={(e) => {
setPlatform(e.target.value);
syncUrl({ platform: e.target.value });
}}
placeholder="AR-15"
/>
</div>
<div>
<label className="block text-[0.7rem] font-semibold uppercase tracking-[0.14em] text-zinc-500">
Search raw categories
</label>
<div className="mt-1 flex gap-2">
<input
className="w-full rounded-md border border-zinc-700 bg-zinc-950/80 px-2 py-2 text-xs text-zinc-100 outline-none focus:border-amber-400"
value={q}
onChange={(e) => {
setQ(e.target.value);
syncUrl({ q: e.target.value });
}}
placeholder="e.g. Charging Handles"
/>
<button
onClick={() => load()}
className="rounded-md bg-emerald-500 px-3 py-2 text-xs font-semibold text-black"
>
Search
</button>
</div>
</div>
</div>
</section>
)}
{loading && ( {loading && (
<p className="mt-4 text-sm text-zinc-400">Loading pending buckets</p> <p className="mt-4 text-sm text-zinc-400">Loading</p>
)} )}
{error && ( {error && (
<p className="mt-4 text-sm text-red-400">Error: {error}</p> <p className="mt-4 text-sm text-red-400">Error: {error}</p>
)} )}
{!loading && rows.length === 0 && !error && ( {/* ROLES TAB TABLE */}
{!loading && tab === "roles" && !error && roleBuckets.length === 0 && (
<p className="mt-4 text-sm text-emerald-400"> <p className="mt-4 text-sm text-emerald-400">
No pending mapping buckets 🎉 No pending role buckets 🎉
</p> </p>
)} )}
{!loading && rows.length > 0 && ( {!loading && tab === "roles" && roleBuckets.length > 0 && (
<section className="mt-4 rounded-lg border border-zinc-800 bg-zinc-950/80 p-3"> <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"> <div className="mb-2 flex items-center justify-between">
<h2 className="text-xs font-semibold uppercase tracking-[0.14em] text-zinc-500"> <h2 className="text-xs font-semibold uppercase tracking-[0.14em] text-zinc-500">
Pending Buckets Pending Part Role Buckets
</h2> </h2>
<span className="text-xs text-zinc-400"> <span className="text-xs text-zinc-400">
{rows.length} buckets {" "} {totals.buckets} buckets {totals.products} products
{rows.reduce((s, r) => s + r.productCount, 0)} products
</span> </span>
</div> </div>
@@ -223,26 +593,17 @@ export default function MappingAdminPage() {
<table className="min-w-full text-xs"> <table className="min-w-full text-xs">
<thead> <thead>
<tr className="border-b border-zinc-800 bg-zinc-900/60"> <tr className="border-b border-zinc-800 bg-zinc-900/60">
<th className="px-2 py-1 text-left text-zinc-400"> <th className="px-2 py-1 text-left text-zinc-400">Merchant</th>
Merchant <th className="px-2 py-1 text-left text-zinc-400">Raw Category</th>
</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"> <th className="px-2 py-1 text-left text-zinc-400">Part Role</th>
Raw Category <th className="px-2 py-1 text-right text-zinc-400">Action</th>
</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> </tr>
</thead> </thead>
<tbody> <tbody>
{rows.map((row, idx) => { {roleBuckets.map((row, idx) => {
const rowKey = `${row.merchantId}-${row.rawCategoryKey}`; const rowKey = `roles-${row.merchantId}-${row.rawCategoryKey}`;
const isSaving = savingKey === rowKey; const isSaving = savingKey === rowKey;
return ( return (
@@ -250,25 +611,17 @@ export default function MappingAdminPage() {
key={rowKey} key={rowKey}
className="border-b border-zinc-900 hover:bg-zinc-900/40" className="border-b border-zinc-900 hover:bg-zinc-900/40"
> >
<td className="px-2 py-1 text-zinc-100"> <td className="px-2 py-1 text-zinc-100">{row.merchantName}</td>
{row.merchantName} <td className="px-2 py-1 text-zinc-300">{row.rawCategoryKey}</td>
</td> <td className="px-2 py-1 text-zinc-200">{row.productCount}</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"> <td className="px-2 py-1 text-zinc-100">
<select <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" 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 ?? ""} value={row.mappedPartRole ?? ""}
onChange={(e) => onChange={(e) => handleChangeRole(idx, e.target.value)}
handleChangeRole(idx, e.target.value)
}
> >
<option value="">Select part role</option> <option value="">Select part role</option>
{options.map((opt) => ( {roleOptions.map((opt) => (
<option key={opt.value} value={opt.value}> <option key={opt.value} value={opt.value}>
{opt.label} ({opt.value}) {opt.label} ({opt.value})
</option> </option>
@@ -277,7 +630,7 @@ export default function MappingAdminPage() {
</td> </td>
<td className="px-2 py-1 text-right"> <td className="px-2 py-1 text-right">
<button <button
onClick={() => handleSave(row)} onClick={() => handleSaveRole(row)}
disabled={!row.mappedPartRole || isSaving} 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" 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"
> >
@@ -292,6 +645,109 @@ export default function MappingAdminPage() {
</div> </div>
</section> </section>
)} )}
{/* CATALOG TAB TABLE */}
{!loading && tab === "catalog" && !error && rawRows.length === 0 && (
<p className="mt-4 text-sm text-zinc-400">
No rows loaded. Pick a merchant + click Search.
</p>
)}
{!loading && tab === "catalog" && rawRows.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">
Catalog Category Mapping
</h2>
<span className="text-xs text-zinc-400">
{totals.buckets} rows {totals.products} 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">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">Canonical Category</th>
<th className="px-2 py-1 text-left text-zinc-400">Enabled</th>
<th className="px-2 py-1 text-right text-zinc-400">Action</th>
</tr>
</thead>
<tbody>
{rawRows.map((row, idx) => {
const rowKey = `catalog-${row.merchantId}-${row.rawCategoryKey}`;
const isSaving = savingKey === rowKey;
const currentCategoryId =
row.canonicalCategoryId != null ? String(row.canonicalCategoryId) : "";
return (
<tr
key={rowKey}
className="border-b border-zinc-900 hover:bg-zinc-900/40"
>
<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={currentCategoryId}
onChange={(e) => {
const id = safeNum(e.target.value);
const opt = canonicalCategories.find((c) => c.id === id);
updateRawRow(idx, {
canonicalCategoryId: id,
canonicalCategoryName: opt?.name ?? null,
});
}}
>
<option value="">Select canonical category</option>
{canonicalCategories.map((c) => (
<option key={c.id} value={String(c.id)}>
{c.name}
</option>
))}
</select>
{canonicalCategories.length === 0 && (
<p className="mt-1 text-[0.7rem] text-zinc-500">
No canonical categories loaded (options endpoint may not be wired yet).
</p>
)}
</td>
<td className="px-2 py-1 text-zinc-100">
<label className="inline-flex items-center gap-2">
<input
type="checkbox"
checked={row.enabled ?? true}
onChange={(e) => updateRawRow(idx, { enabled: e.target.checked })}
/>
<span className="text-zinc-300">Enabled</span>
</label>
</td>
<td className="px-2 py-1 text-right">
<button
onClick={() => handleSaveCatalog(row)}
disabled={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> </div>
</main> </main>
); );
+1 -1
View File
@@ -18,7 +18,7 @@
import Link from "next/link"; import Link from "next/link";
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import { CATEGORIES } from "@/data/gunbuilderParts"; import { CATEGORIES } from "@/data/gunbuilderParts";
import type { Category } from "@/types/gunbuilder"; import type { Category } from "@/types/builderSlots";
type BuilderNavProps = { type BuilderNavProps = {
// Optional override if a parent wants to force the active category // Optional override if a parent wants to force the active category
+1 -1
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import type { Part } from "@/types/gunbuilder"; import type { Part } from "@/types/builderSlots";
interface PartCardProps { interface PartCardProps {
part: Part; part: Part;
+1 -1
View File
@@ -20,7 +20,7 @@ import PartsGrid from "@/components/parts/PartsGrid";
import Pagination from "@/components/parts/Pagination"; import Pagination from "@/components/parts/Pagination";
import PlatformSwitcher from "@/components/parts/PlatformSwitcher"; import PlatformSwitcher from "@/components/parts/PlatformSwitcher";
import type { CategoryId } from "@/types/gunbuilder"; import type { CategoryId } from "@/types/builderSlots";
import { import {
PART_ROLE_TO_CATEGORY, PART_ROLE_TO_CATEGORY,
normalizePartRole, normalizePartRole,
+29 -54
View File
@@ -1,62 +1,37 @@
import type { Category } from "@/types/gunbuilder"; // data/gunbuilderParts.ts
import { CATEGORY_SLUGS } from "@/types/gunbuilder"; import type { Category } from "@/types/builderSlots";
export const CATEGORIES: Category[] = [ export const CATEGORIES: Category[] = [
// Lower group // Lower group
{ { id: "lower-receiver", name: "Stripped Lower", group: "lower" },
id: "lower-receiver", { id: "complete-lower", name: "Complete Lower", group: "lower" },
name: "Stripped Lower", { id: "lower-parts-kit", name: "Lower Parts Kit", group: "lower" },
group: "lower", { id: "trigger", name: "Trigger / Fire Control", group: "lower" },
slug: CATEGORY_SLUGS["lower-receiver"], { id: "grip", name: "Pistol Grip", group: "lower" },
}, { id: "safety-selector", name: "Safety / Selector", group: "lower" },
{ { id: "buffer", name: "Buffer System", group: "lower" },
id: "complete-lower", { id: "stock", name: "Stock / Brace", group: "lower" },
name: "Complete Lower",
group: "lower",
slug: CATEGORY_SLUGS["complete-lower"],
},
{
id: "lower-parts",
name: "Lower Parts Kit",
group: "lower",
slug: CATEGORY_SLUGS["lower-parts"],
},
{ id: "trigger", name: "Trigger / Fire Control", group: "lower", slug: CATEGORY_SLUGS.trigger },
{ id: "grip", name: "Pistol Grip", group: "lower", slug: CATEGORY_SLUGS.grip },
{ id: "safety", name: "Safety / Selector", group: "lower", slug: CATEGORY_SLUGS.safety },
{ id: "buffer", name: "Buffer System", group: "lower", slug: CATEGORY_SLUGS.buffer },
{ id: "stock", name: "Stock / Brace", group: "lower", slug: CATEGORY_SLUGS.stock },
// Upper group // Upper group
{ { id: "upper-receiver", name: "Stripped Upper", group: "upper" },
id: "upper-receiver", { id: "complete-upper", name: "Complete Upper", group: "upper" },
name: "Stripped Upper", { id: "barrel", name: "Barrels", group: "upper" },
group: "upper", { id: "handguard", name: "Handguards / Rails", group: "upper" },
slug: CATEGORY_SLUGS["upper-receiver"], { id: "gas-block", name: "Gas Block", group: "upper" },
}, { id: "gas-tube", name: "Gas Tube", group: "upper" },
{ { id: "muzzle-device", name: "Muzzle Device", group: "upper" },
id: "complete-upper", { id: "sights", name: "Iron Sights", group: "upper" },
name: "Complete Upper", { id: "bcg", name: "Bolt Carrier Group", group: "upper" },
group: "upper", { id: "charging-handle", name: "Charging Handle", group: "upper" },
slug: CATEGORY_SLUGS["complete-upper"], { id: "suppressor", name: "Suppressors", group: "upper" },
}, { id: "optic", name: "Optics", group: "upper" },
{ id: "barrel", name: "Barrels", group: "upper", slug: CATEGORY_SLUGS.barrel },
{ id: "handguard", name: "Handguards / Rails", group: "upper", slug: CATEGORY_SLUGS.handguard },
{ id: "gas-block", name: "Gas Block", group: "upper", slug: CATEGORY_SLUGS["gas-block"] },
{ id: "gas-tube", name: "Gas Tube", group: "upper", slug: CATEGORY_SLUGS["gas-tube"] },
{ id: "muzzle-device", name: "Muzzle Device", group: "upper", slug: CATEGORY_SLUGS["muzzle-device"] },
{ id: "sights", name: "Iron Sights", group: "upper", slug: CATEGORY_SLUGS.sights },
{ id: "bcg", name: "Bolt Carrier Group", group: "upper", slug: CATEGORY_SLUGS.bcg },
{ id: "charging-handle", name: "Charging Handle", group: "upper", slug: CATEGORY_SLUGS["charging-handle"] },
{ id: "suppressor", name: "Suppressors", group: "upper", slug: CATEGORY_SLUGS.suppressor },
{ id: "optic", name: "Optics", group: "upper", slug: CATEGORY_SLUGS.optic },
// Accessories (platform-agnostic) // Accessories (platform-agnostic)
{ id: "magazine", name: "Magazines", group: "accessories", slug: CATEGORY_SLUGS.magazine }, { id: "magazine", name: "Magazines", group: "accessories" },
{ id: "weapon-light", name: "Weapon Lights & Lasers", group: "accessories", slug: CATEGORY_SLUGS["weapon-light"] }, { id: "weapon-light", name: "Weapon Lights & Lasers", group: "accessories" },
{ id: "foregrip", name: "Vertical / Angled Grips", group: "accessories", slug: CATEGORY_SLUGS.foregrip }, { id: "foregrip", name: "Vertical / Angled Grips", group: "accessories" },
{ id: "bipod", name: "Bipods", group: "accessories", slug: CATEGORY_SLUGS.bipod }, { id: "bipod", name: "Bipods", group: "accessories" },
{ id: "sling", name: "Slings & Mounts", group: "accessories", slug: CATEGORY_SLUGS.sling }, { id: "sling", name: "Slings & Mounts", group: "accessories" },
{ id: "rail-accessory", name: "Rail Accessories", group: "accessories", slug: CATEGORY_SLUGS["rail-accessory"] }, { id: "rail-accessory", name: "Rail Accessories", group: "accessories" },
{ id: "tools", name: "Tools & Maintenance", group: "accessories", slug: CATEGORY_SLUGS.tools }, { id: "tools", name: "Tools & Maintenance", group: "accessories" },
]; ];
+1 -1
View File
@@ -1,5 +1,5 @@
// /lib/buildOverlaps.ts // /lib/buildOverlaps.ts
import type { CategoryId } from "@/types/gunbuilder"; import type { CategoryId } from "@/types/builderSlots";
export type OverlapChipModel = { export type OverlapChipModel = {
key: string; key: string;
+1 -1
View File
@@ -1,4 +1,4 @@
import type { CategoryId } from "@/types/gunbuilder"; import type { CategoryId } from "@/types/builderSlots";
/** /**
* Normalize backend part roles into a canonical kebab-case form. * Normalize backend part roles into a canonical kebab-case form.
+85
View File
@@ -0,0 +1,85 @@
// types/builderSlots.ts
export type CategoryGroup = "lower" | "upper" | "accessories";
/**
* Canonical key used across UI, localStorage, and (ideally) backend part_roles.key.
* Keep these in sync with part_roles.key to avoid mapping hell.
*/
export type BuilderSlotKey =
// ===== LOWER =====
| "lower-receiver"
| "complete-lower"
| "lower-parts-kit"
| "trigger"
| "grip"
| "safety-selector"
| "buffer"
| "stock"
// ===== UPPER =====
| "upper-receiver"
| "complete-upper"
| "barrel"
| "handguard"
| "gas-block"
| "gas-tube"
| "muzzle-device"
| "bcg"
| "charging-handle"
| "suppressor"
| "sights"
| "optic"
// ===== ACCESSORIES =====
| "magazine"
| "weapon-light"
| "foregrip"
| "bipod"
| "sling"
| "rail-accessory"
| "tools";
export interface Category {
id: BuilderSlotKey;
name: string;
description?: string;
group?: CategoryGroup;
}
export interface Part {
id: string;
name: string;
brand: string;
categoryId: BuilderSlotKey;
price: number;
affiliateUrl?: string;
url?: string;
imageUrl?: string;
notes?: string;
}
/**
* Legacy localStorage / URL keys support (read-only).
* Add/remove as needed during migration.
*/
const LEGACY_SLOT_ALIASES: Record<string, BuilderSlotKey> = {
completeUpper: "complete-upper",
gasBlock: "gas-block",
gasTube: "gas-tube",
muzzleDevice: "muzzle-device",
chargingHandle: "charging-handle",
completeLower: "complete-lower",
lowerParts: "lower-parts-kit",
weaponLight: "weapon-light",
railAccessory: "rail-accessory",
// also handle older kebab mismatches if they exist:
"lower-parts": "lower-parts-kit",
safety: "safety-selector",
};
export function normalizeSlotKey(key: string): BuilderSlotKey | null {
const k = (key ?? "").trim();
if (!k) return null;
return (LEGACY_SLOT_ALIASES[k] ?? (k as BuilderSlotKey)) || null;
}
-129
View File
@@ -1,129 +0,0 @@
// Grouping for nav + layout
export type CategoryGroup = "lower" | "upper" | "accessories";
/**
* CategoryId is the canonical identifier used throughout the UI + localStorage.
*
* ✅ Canonical IDs are kebab-case to match route segments and `gunbuilderParts.ts`.
*
* 🧯 Legacy camelCase IDs are temporarily supported to avoid breaking older
* localStorage/build-state and older code paths. Prefer kebab-case going forward.
*/
export type CategoryId =
// ===== LOWER =====
| "lower-receiver"
| "complete-lower"
| "lower-parts"
| "trigger"
| "grip"
| "safety"
| "buffer"
| "stock"
// ===== UPPER =====
| "upper-receiver"
| "complete-upper"
| "barrel"
| "handguard"
| "gas-block"
| "gas-tube"
| "muzzle-device"
| "bcg"
| "charging-handle"
| "suppressor"
| "sights"
| "optic"
// ===== ACCESSORIES =====
| "magazine"
| "weapon-light"
| "foregrip"
| "bipod"
| "sling"
| "rail-accessory"
| "tools"
// ===== LEGACY (temporary) =====
| "completeUpper"
| "gasBlock"
| "gasTube"
| "muzzleDevice"
| "chargingHandle"
| "completeLower"
| "lowerParts"
| "weaponLight"
| "railAccessory";
export interface Category {
id: CategoryId;
name: string;
description?: string;
/** used for nav & grouping */
group?: CategoryGroup;
/** optional slug override (mostly for legacy IDs) */
slug?: string;
}
export interface Part {
id: string;
name: string;
brand: string;
categoryId: CategoryId;
price: number;
affiliateUrl?: string;
url?: string;
imageUrl?: string;
notes?: string;
}
/**
* Route / UI slug for each CategoryId.
*
* - Canonical kebab-case ids map 1:1.
* - Legacy camelCase ids map to the same slug.
*/
export const CATEGORY_SLUGS = {
// LOWER
"lower-receiver": "lower-receiver",
"complete-lower": "complete-lower",
"lower-parts": "lower-parts",
trigger: "trigger",
grip: "grip",
safety: "safety",
buffer: "buffer",
stock: "stock",
// UPPER
"upper-receiver": "upper-receiver",
"complete-upper": "complete-upper",
barrel: "barrel",
handguard: "handguard",
"gas-block": "gas-block",
"gas-tube": "gas-tube",
"muzzle-device": "muzzle-device",
sights: "sights",
bcg: "bcg",
"charging-handle": "charging-handle",
suppressor: "suppressor",
optic: "optic",
// ACCESSORIES
magazine: "magazine",
"weapon-light": "weapon-light",
foregrip: "foregrip",
bipod: "bipod",
sling: "sling",
"rail-accessory": "rail-accessory",
tools: "tools",
// LEGACY (temporary)
completeUpper: "complete-upper",
gasBlock: "gas-block",
gasTube: "gas-tube",
muzzleDevice: "muzzle-device",
chargingHandle: "charging-handle",
completeLower: "complete-lower",
lowerParts: "lower-parts",
weaponLight: "weapon-light",
railAccessory: "rail-accessory",
} as const satisfies Record<CategoryId, string>;