"use client"; import { useEffect, useState, useMemo } from "react"; const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080"; type Merchant = { id: number; name: string; }; type Mapping = { id: number | null; // might be null if just created client-side merchantId: number; merchantName: string; rawCategory: string; mappedPartRole: string | null; }; type UpsertRequest = { merchantId: number; rawCategory: string; mappedPartRole: string | null; }; // Keep this in sync with your backend `partRole` values const PART_ROLE_OPTIONS = [ { value: "", label: "— Unmapped —" }, // Lower / fire control { value: "lower-receiver", label: "Lower Receiver" }, { value: "lower-parts-kit", label: "Lower Parts Kit" }, { value: "trigger", label: "Trigger / Trigger Kit" }, { value: "pistol-grip", label: "Pistol Grip" }, { value: "safety-selector", label: "Safety Selector" }, // Upper { value: "upper-receiver", label: "Upper Receiver" }, { value: "barrel", label: "Barrel" }, { value: "handguard", label: "Handguard" }, { value: "gas-system", label: "Gas System / Gas Block" }, { value: "muzzle-device", label: "Muzzle Device" }, { value: "suppressor", label: "Suppressor" }, // Furniture / sights / optics { value: "stock", label: "Stock" }, { value: "buffer-kit", label: "Buffer Kit" }, { value: "charging-handle", label: "Charging Handle" }, { value: "sight", label: "Iron / Backup Sight" }, { value: "optic", label: "Optic" }, { value: "other", label: "Other / Ignore" }, ]; export default function CategoryMappingsPage() { const [merchants, setMerchants] = useState([]); const [selectedMerchantId, setSelectedMerchantId] = useState( null, ); const [mappings, setMappings] = useState([]); const [loadingMappings, setLoadingMappings] = useState(false); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); const [successMessage, setSuccessMessage] = useState(null); const [showOnlyUnmapped, setShowOnlyUnmapped] = useState(false); const sortedMappings = useMemo(() => { // Mapped rows first, then unmapped; within each group, alpha by rawCategory return [...mappings].sort((a, b) => { const aMapped = !!a.mappedPartRole; const bMapped = !!b.mappedPartRole; if (aMapped !== bMapped) { return aMapped ? -1 : 1; // mapped first } return a.rawCategory.localeCompare(b.rawCategory); }); }, [mappings]); const visibleMappings = useMemo( () => showOnlyUnmapped ? sortedMappings.filter((m) => !m.mappedPartRole) : sortedMappings, [sortedMappings, showOnlyUnmapped], ); // Load merchants useEffect(() => { async function fetchMerchants() { try { const res = await fetch(`${API_BASE_URL}/admin/merchants`); if (!res.ok) { throw new Error(`Failed to load merchants (${res.status})`); } const data: Merchant[] = await res.json(); setMerchants(data); // Auto-select the first merchant if you want if (data.length > 0 && selectedMerchantId === null) { setSelectedMerchantId(data[0].id); } } catch (err: any) { console.error(err); setError(err.message ?? "Failed to load merchants"); } } fetchMerchants(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Load mappings for selected merchant useEffect(() => { if (!selectedMerchantId) return; const controller = new AbortController(); async function fetchMappings() { try { setLoadingMappings(true); setError(null); setSuccessMessage(null); const url = `${API_BASE_URL}/admin/merchant-category-mappings?merchantId=${selectedMerchantId}`; const res = await fetch(url, { signal: controller.signal }); if (!res.ok) { throw new Error(`Failed to load mappings (${res.status})`); } const data = (await res.json()) as Mapping[]; setMappings(data); } catch (err: any) { if (err.name === "AbortError") return; console.error(err); setError(err.message ?? "Failed to load mappings"); } finally { setLoadingMappings(false); } } fetchMappings(); return () => controller.abort(); }, [selectedMerchantId]); const handleChangePartRole = (rawCategory: string, newRole: string) => { setMappings((prev) => prev.map((m) => m.rawCategory === rawCategory ? { ...m, mappedPartRole: newRole || null } : m, ), ); }; const handleSave = async () => { if (!selectedMerchantId) return; setSaving(true); setError(null); setSuccessMessage(null); try { // Save each mapping in parallel const requests: Promise[] = mappings.map((m) => { const payload: UpsertRequest = { merchantId: selectedMerchantId, rawCategory: m.rawCategory, mappedPartRole: m.mappedPartRole, }; return fetch(`${API_BASE_URL}/admin/merchant-category-mappings`, { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify(payload), }); }); const responses = await Promise.all(requests); const failed = responses.filter((r) => !r.ok); if (failed.length > 0) { throw new Error(`Some mappings failed to save (${failed.length})`); } setSuccessMessage("Mappings saved. Re-run the import to apply changes."); } catch (err: any) { console.error(err); setError(err.message ?? "Failed to save mappings"); } finally { setSaving(false); } }; const selectedMerchantName = merchants.find((m) => m.id === selectedMerchantId)?.name ?? "—"; return (

Shadow Standard / Admin

Merchant Category Mapping

Normalize each merchant's category names into your internal part roles (upper, barrel, handguard, etc.). Unmapped categories will be skipped during import until they are mapped here.

{error && (
{error}
)} {successMessage && (
{successMessage}
)} {!selectedMerchantId ? (

Choose a merchant above to view category mappings.

) : loadingMappings ? (

Loading mappings for {selectedMerchantName}

) : mappings.length === 0 ? (

No category rows yet for this merchant. Once you run an import, discovered categories will appear here automatically.

) : ( <>
Mappings for{" "} {selectedMerchantName}.{" "} Set a part role for each raw category. Unmapped categories will be skipped by the importer.
{visibleMappings.map((m) => ( ))}
Raw Category Mapped Part Role Status
{m.rawCategory} {m.mappedPartRole ? ( Mapped ) : ( Unmapped / Ignored )}
)}
); }