"use client"; import { useEffect, useMemo, useState } from "react"; import { useSearchParams, useRouter } from "next/navigation"; const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL ?? ""; /** * 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 = { merchantId: number; merchantName: string; rawCategoryKey: string; mappedPartRole: string | null; productCount: number; }; /** * New “raw categories” row (for the combined UI). * If your backend returns fewer fields at first, it’s 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 = [ // Assemblies / receivers "upper-receiver", "complete-upper", "lower-receiver", "complete-lower", // Upper sub-parts "bcg", "barrel", "gas-block", "gas-tube", "muzzle-device", "suppressor", "handguard", "charging-handle", // Lower sub-parts "lower-parts", "trigger", "grip", "safety", "buffer", "stock", // Sights / optics "sights", "optic", // Accessories / gear "magazine", "weapon-light", "foregrip", "bipod", "sling", "rail-accessory", "tools", ] as const; function toLabel(role: string) { return role .split("-") .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) .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() { const searchParams = useSearchParams(); const router = useRouter(); // 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(initialTab); const [merchantId, setMerchantId] = useState(initialMerchantId); const [platform, setPlatform] = useState(initialPlatform); const [q, setQ] = useState(initialQ); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); // Options (for catalog tab + merchant dropdown) const [optionsLoading, setOptionsLoading] = useState(false); const [merchants, setMerchants] = useState([]); const [canonicalCategories, setCanonicalCategories] = useState< CanonicalCategoryOption[] >([]); // Rows const [roleBuckets, setRoleBuckets] = useState([]); const [rawRows, setRawRows] = useState([]); // Saving state const [savingKey, setSavingKey] = useState(null); // Part role dropdown options const roleOptions = useMemo( () => PART_ROLE_OPTIONS.map((value) => ({ value, label: toLabel(value), })), [] ); // 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(() => { async function loadOptions() { setOptionsLoading(true); try { setError(null); // EXPECTED backend endpoint (new): // GET { merchants: [{id,name}], canonicalCategories: [{id,name,slug}] } const res = await fetch(`${API_BASE_URL}/api/admin/mapping/options`, { headers: { Accept: "application/json" }, cache: "no-store", }); if (!res.ok) { // Don’t hard-fail the whole page if options aren’t 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) { const text = await res.text().catch(() => ""); throw new Error( `Failed to load pending buckets (${res.status})${text ? `: ${text}` : ""}` ); } const data: PendingBucket[] = await res.json(); setRoleBuckets(data); setRawRows([]); return; } // 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(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [tab]); // ===== Roles tab actions ===== const handleChangeRole = (idx: number, value: string) => { const normalized = value ? normalizeRole(value) : ""; setRoleBuckets((prev) => { const next = [...prev]; next[idx] = { ...next[idx], mappedPartRole: normalized || null }; return next; }); }; const handleSaveRole = async (row: PendingBucket) => { if (!row.mappedPartRole) return; const mapped = normalizeRole(row.mappedPartRole); const allowed = new Set(PART_ROLE_OPTIONS as readonly string[]); if (!allowed.has(mapped)) { setError( `Refusing to save unknown part role: "${row.mappedPartRole}". Pick a role from the dropdown list.` ); return; } const key = `roles-${row.merchantId}-${row.rawCategoryKey}`; try { setSavingKey(key); setError(null); // Existing endpoint you already have: // POST /api/admin/mapping/apply 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: mapped, }), }); if (!res.ok) { const text = await res.text().catch(() => ""); throw new Error( `Failed to save mapping (${res.status})${text ? `: ${text}` : ""}` ); } setRoleBuckets((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); } }; // ===== Catalog tab actions ===== const updateRawRow = (idx: number, patch: Partial) => { 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 server’s 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 (

Admin Mapping

Manage builder part roles and{" "} catalog categories without blending worlds.

{/* Tabs */}
{totals.buckets} rows • {totals.products} products
{/* Filters (catalog tab only) */} {tab === "catalog" && (
{optionsLoading && (

Loading merchants…

)}
{ setPlatform(e.target.value); syncUrl({ platform: e.target.value }); }} placeholder="AR-15" />
{ setQ(e.target.value); syncUrl({ q: e.target.value }); }} placeholder="e.g. Charging Handles" />
)} {loading && (

Loading…

)} {error && (

Error: {error}

)} {/* ROLES TAB TABLE */} {!loading && tab === "roles" && !error && roleBuckets.length === 0 && (

No pending role buckets 🎉

)} {!loading && tab === "roles" && roleBuckets.length > 0 && (

Pending Part Role Buckets

{totals.buckets} buckets • {totals.products} products
{roleBuckets.map((row, idx) => { const rowKey = `roles-${row.merchantId}-${row.rawCategoryKey}`; const isSaving = savingKey === rowKey; return ( ); })}
Merchant Raw Category Products Part Role Action
{row.merchantName} {row.rawCategoryKey} {row.productCount}
)} {/* CATALOG TAB TABLE */} {!loading && tab === "catalog" && !error && rawRows.length === 0 && (

No rows loaded. Pick a merchant + click Search.

)} {!loading && tab === "catalog" && rawRows.length > 0 && (

Catalog Category Mapping

{totals.buckets} rows • {totals.products} products
{rawRows.map((row, idx) => { const rowKey = `catalog-${row.merchantId}-${row.rawCategoryKey}`; const isSaving = savingKey === rowKey; const currentCategoryId = row.canonicalCategoryId != null ? String(row.canonicalCategoryId) : ""; return ( ); })}
Raw Category Products Canonical Category Enabled Action
{row.rawCategoryKey} {row.productCount} {canonicalCategories.length === 0 && (

No canonical categories loaded (options endpoint may not be wired yet).

)}
)}
); }