fuck if i know

This commit is contained in:
2025-12-04 15:06:57 -05:00
parent fb3c23fa46
commit cb16698940
76 changed files with 1197 additions and 603 deletions
+125
View File
@@ -0,0 +1,125 @@
// app/admin/categories/page.tsx
"use client";
import { useEffect, useState } from "react";
import { useAuth } from "@/context/AuthContext";
import {
fetchAdminCategories,
type AdminCategory,
} from "@/lib/api/adminCategoryMappings";
export default function AdminCategoriesPage() {
const { token } = useAuth();
const [categories, setCategories] = useState<AdminCategory[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!token) return;
const load = async () => {
try {
setLoading(true);
const cats = await fetchAdminCategories(token);
setCategories(cats);
} catch (e: any) {
console.error(e);
setError(e?.message ?? "Failed to load categories");
} finally {
setLoading(false);
}
};
load();
}, [token]);
if (!token) {
return (
<div className="p-6 text-sm text-red-500">
You must be logged in to view this page.
</div>
);
}
return (
<div className="p-6 space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-lg font-semibold tracking-tight">
Canonical categories
</h1>
<p className="mt-1 text-xs text-zinc-400">
These are your standardized builder categories and their groupings.
</p>
</div>
{/* Placeholder for future actions (add/edit) */}
{/* <button className="rounded-md bg-emerald-600 px-3 py-1 text-xs font-medium text-white hover:bg-emerald-500">
+ Add category
</button> */}
</div>
{/* Error */}
{error && (
<div className="rounded border border-red-500/60 bg-red-950/30 px-3 py-2 text-xs text-red-200">
{error}
</div>
)}
{/* Table */}
{loading ? (
<div className="text-sm text-zinc-400">Loading</div>
) : (
<div className="overflow-x-auto rounded-lg border border-zinc-800 bg-zinc-950/60">
<table className="min-w-full text-left text-xs">
<thead className="bg-zinc-900/80 text-zinc-400">
<tr>
<th className="px-3 py-2 font-medium">Group</th>
<th className="px-3 py-2 font-medium">Name</th>
<th className="px-3 py-2 font-medium">Slug</th>
<th className="px-3 py-2 font-medium">Sort order</th>
<th className="px-3 py-2 font-medium">Description</th>
</tr>
</thead>
<tbody>
{categories.map((cat) => (
<tr
key={cat.id}
className="border-t border-zinc-800/70 hover:bg-zinc-900/60"
>
<td className="px-3 py-2 text-[11px] text-zinc-400">
{cat.groupName ?? "—"}
</td>
<td className="px-3 py-2 text-zinc-100">{cat.name}</td>
<td className="px-3 py-2 text-[11px] text-zinc-500">
<code>{cat.slug}</code>
</td>
<td className="px-3 py-2 text-[11px] text-zinc-400">
{cat.sortOrder ?? "—"}
</td>
<td className="px-3 py-2 text-zinc-300">
{cat.description || (
<span className="text-zinc-500">No description</span>
)}
</td>
</tr>
))}
{categories.length === 0 && (
<tr>
<td
colSpan={5}
className="px-3 py-4 text-center text-xs text-zinc-500"
>
No categories found. Seed the <code>part_categories</code>{" "}
table to get started.
</td>
</tr>
)}
</tbody>
</table>
</div>
)}
</div>
);
}
@@ -0,0 +1,241 @@
"use client";
import { useEffect, useState } from "react";
import { useApi } from "@/lib/useApi";
type Merchant = {
id: number;
name: string;
};
type CategoryMapping = {
id: number;
merchantId: number;
merchantName: string;
rawCategoryPath: string;
partCategoryId: number | null;
partCategoryName?: string | null;
};
// Matches the Spring MerchantCategoryMappingDto record
type MerchantCategoryMappingDto = {
id: number;
merchantId: number;
merchantName: string;
rawCategoryPath: string;
partCategoryId: number | null;
partCategoryName?: string | null;
};
export default function MerchantCategoryMappingPage() {
const { get, post } = useApi();
const [merchants, setMerchants] = useState<Merchant[]>([]);
const [selectedMerchantId, setSelectedMerchantId] = useState<number | null>(
null
);
const [mappings, setMappings] = useState<CategoryMapping[]>([]);
const [loadingMerchants, setLoadingMerchants] = useState(false);
const [loadingMappings, setLoadingMappings] = useState(false);
const [savingId, setSavingId] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
// 1) Load merchants for the dropdown
useEffect(() => {
setLoadingMerchants(true);
setError(null);
get<Merchant[]>("/api/admin/category-mappings/merchants")
.then((data) => {
setMerchants(data);
if (data.length > 0 && selectedMerchantId == null) {
setSelectedMerchantId(data[0].id);
}
})
.catch((err: any) => {
console.error("Failed to load merchants", err);
setError(
err instanceof Error ? err.message : "Failed to load merchants"
);
})
.finally(() => setLoadingMerchants(false));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// 2) Load mappings when merchant changes (THIS sends merchantId)
useEffect(() => {
if (selectedMerchantId == null) return;
setLoadingMappings(true);
setError(null);
get<CategoryMapping[]>(
`/api/admin/category-mappings?merchantId=${selectedMerchantId}`
)
.then((data) => {
setMappings(data);
})
.catch((err: any) => {
console.error("Failed to load category mappings", err);
setError(
err instanceof Error ? err.message : "Failed to load category mappings"
);
})
.finally(() => setLoadingMappings(false));
}, [selectedMerchantId]);
// 3) Save a single mapping row (adjust endpoint if yours differs)
const handleSaveMapping = async (
mappingId: number,
updated: { partCategoryId: number | null }
) => {
setSavingId(mappingId);
setError(null);
try {
const updatedMapping = await post<MerchantCategoryMappingDto>(
`/api/admin/category-mappings/${mappingId}`,
{
partCategoryId: updated.partCategoryId,
}
);
setMappings((prev) =>
prev.map((m) => (m.id === mappingId ? {
...m,
// map Java DTO fields back into your UI model
merchantId: updatedMapping.merchantId,
merchantName: updatedMapping.merchantName,
rawCategoryPath: updatedMapping.rawCategoryPath,
partCategoryId: updatedMapping.partCategoryId,
partCategoryName: updatedMapping.partCategoryName,
} : m))
);
} catch (err: any) {
console.error("Failed to save mapping", err);
setError(
err instanceof Error ? err.message : "Failed to save mapping"
);
} finally {
setSavingId(null);
}
};
return (
<div className="p-6 space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-semibold text-zinc-50">
Merchant Category Mapping
</h1>
<div className="flex items-center gap-2">
<span className="text-sm text-zinc-400">Merchant:</span>
<select
className="bg-zinc-900 border border-zinc-700 rounded px-2 py-1 text-sm"
value={selectedMerchantId ?? ""}
onChange={(e) =>
setSelectedMerchantId(
e.target.value ? Number(e.target.value) : null
)
}
>
{loadingMerchants && (
<option value="">Loading merchants</option>
)}
{!loadingMerchants && merchants.length === 0 && (
<option value="">No merchants</option>
)}
{merchants.map((m) => (
<option key={m.id} value={m.id}>
{m.name}
</option>
))}
</select>
</div>
</div>
{error && (
<div className="rounded bg-red-900/40 border border-red-700 px-3 py-2 text-sm text-red-200">
{error}
</div>
)}
<div className="border border-zinc-800 rounded-lg overflow-hidden">
<div className="bg-zinc-900 border-b border-zinc-800 px-4 py-2 text-xs uppercase tracking-wide text-zinc-400">
Category Mappings
</div>
{loadingMappings ? (
<div className="p-4 text-sm text-zinc-400">Loading mappings</div>
) : mappings.length === 0 ? (
<div className="p-4 text-sm text-zinc-400">
No mappings found for this merchant.
</div>
) : (
<table className="w-full text-sm">
<thead className="bg-zinc-950/60 border-b border-zinc-800">
<tr>
<th className="text-left px-4 py-2 text-xs uppercase tracking-wide text-zinc-500">
Merchant Category
</th>
<th className="text-left px-4 py-2 text-xs uppercase tracking-wide text-zinc-500">
Global Category
</th>
<th className="px-4 py-2 text-xs uppercase tracking-wide text-zinc-500">
Actions
</th>
</tr>
</thead>
<tbody>
{mappings.map((m) => (
<tr key={m.id} className="border-t border-zinc-800">
<td className="px-4 py-2 text-zinc-100">
{m.rawCategoryPath}
</td>
<td className="px-4 py-2">
{/* For now, simple text input for partCategoryId.
You can swap this for a select populated from /api/admin/categories */}
<input
type="number"
className="bg-zinc-950 border border-zinc-700 rounded px-2 py-1 text-sm w-28"
value={m.partCategoryId ?? ""}
onChange={(e) => {
const value = e.target.value
? Number(e.target.value)
: null;
setMappings((prev) =>
prev.map((row) =>
row.id === m.id
? { ...row, partCategoryId: value }
: row
)
);
}}
/>
{m.partCategoryName && (
<div className="text-xs text-zinc-500 mt-1">
{m.partCategoryName}
</div>
)}
</td>
<td className="px-4 py-2 text-right">
<button
onClick={() =>
handleSaveMapping(m.id, {
partCategoryId: m.partCategoryId ?? null,
})
}
disabled={savingId === m.id}
className="text-xs px-3 py-1 rounded border border-zinc-600 hover:bg-zinc-800 disabled:opacity-50"
>
{savingId === m.id ? "Saving…" : "Save"}
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
);
}
@@ -0,0 +1,228 @@
// app/admin/category-mappings/page.tsx
"use client";
import { useEffect, useState } from "react";
import { useAuth } from "@/context/AuthContext";
import {
fetchAdminCategories,
fetchPartRoleMappings,
createPartRoleMapping,
updatePartRoleMapping,
deletePartRoleMapping,
type AdminCategory,
type AdminPartRoleMapping,
} from "@/lib/api/adminCategoryMappings";
export default function AdminCategoryMappingsPage() {
const { token } = useAuth();
const [categories, setCategories] = useState<AdminCategory[]>([]);
const [mappings, setMappings] = useState<AdminPartRoleMapping[]>([]);
const [platform, setPlatform] = useState("AR-15");
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!token) return;
const load = async () => {
try {
setLoading(true);
const [cats, maps] = await Promise.all([
fetchAdminCategories(token),
fetchPartRoleMappings(token, platform),
]);
setCategories(cats);
setMappings(maps);
} catch (e: any) {
console.error(e);
setError(e?.message ?? "Failed to load mappings");
} finally {
setLoading(false);
}
};
load();
}, [token, platform]);
const handleCreate = async () => {
if (!token) return;
const defaultCategory = categories[0];
if (!defaultCategory) return;
try {
setSaving(true);
const created = await createPartRoleMapping(token, {
platform,
partRole: "NEW_PART_ROLE",
categorySlug: defaultCategory.slug,
});
setMappings((prev) => [...prev, created]);
} catch (e: any) {
console.error(e);
setError(e?.message ?? "Failed to create mapping");
} finally {
setSaving(false);
}
};
const handleUpdate = async (row: AdminPartRoleMapping, updates: Partial<AdminPartRoleMapping>) => {
if (!token) return;
try {
setSaving(true);
const updated = await updatePartRoleMapping(token, row.id, {
platform: updates.platform ?? row.platform,
partRole: updates.partRole ?? row.partRole,
categorySlug: updates.categorySlug ?? row.categorySlug,
notes: updates.notes ?? row.notes ?? undefined,
});
setMappings((prev) => prev.map((m) => (m.id === row.id ? updated : m)));
} catch (e: any) {
console.error(e);
setError(e?.message ?? "Failed to update mapping");
} finally {
setSaving(false);
}
};
const handleDelete = async (row: AdminPartRoleMapping) => {
if (!token) return;
if (!confirm(`Delete mapping for ${row.partRole}?`)) return;
try {
setSaving(true);
await deletePartRoleMapping(token, row.id);
setMappings((prev) => prev.filter((m) => m.id !== row.id));
} catch (e: any) {
console.error(e);
setError(e?.message ?? "Failed to delete mapping");
} finally {
setSaving(false);
}
};
if (!token) {
return <div className="p-6 text-sm text-red-500">You must be logged in to view this page.</div>;
}
return (
<div className="p-6 space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-lg font-semibold tracking-tight">
Part role category mappings
</h1>
<p className="mt-1 text-xs text-zinc-400">
FOR LATER! Advanced mapping: defines which canonical category each builder part role
uses for a given platform.
</p>
<div className="flex items-center gap-3">
<select
value={platform}
onChange={(e) => setPlatform(e.target.value)}
className="rounded-md border border-zinc-700 bg-zinc-900 px-3 py-1 text-sm"
>
<option value="AR-15">AR-15</option>
{/* add other platforms later */}
</select>
<button
onClick={handleCreate}
disabled={saving || loading || categories.length === 0}
className="rounded-md bg-emerald-600 px-3 py-1 text-xs font-medium text-white hover:bg-emerald-500 disabled:opacity-50"
>
+ Add mapping
</button>
</div>
</div>
{error && (
<div className="rounded border border-red-500/60 bg-red-950/30 px-3 py-2 text-xs text-red-200">
{error}
</div>
)}
{loading ? (
<div className="text-sm text-zinc-400">Loading</div>
) : (
<div className="overflow-x-auto rounded-lg border border-zinc-800 bg-zinc-950/60">
<table className="min-w-full text-left text-xs">
<thead className="bg-zinc-900/80 text-zinc-400">
<tr>
<th className="px-3 py-2 font-medium">Platform</th>
<th className="px-3 py-2 font-medium">Part role</th>
<th className="px-3 py-2 font-medium">Category</th>
<th className="px-3 py-2 font-medium">Group</th>
<th className="px-3 py-2 font-medium">Notes</th>
<th className="px-3 py-2 text-right font-medium">Actions</th>
</tr>
</thead>
<tbody>
{mappings.map((row) => (
<tr key={row.id} className="border-t border-zinc-800/70 hover:bg-zinc-900/60">
<td className="px-3 py-2 text-zinc-300">{row.platform}</td>
<td className="px-3 py-2">
<input
className="w-full rounded border border-zinc-700 bg-zinc-900 px-2 py-1 text-xs text-zinc-100"
defaultValue={row.partRole}
onBlur={(e) => {
if (e.target.value !== row.partRole) {
handleUpdate(row, { partRole: e.target.value });
}
}}
/>
</td>
<td className="px-3 py-2">
<select
className="w-full rounded border border-zinc-700 bg-zinc-900 px-2 py-1 text-xs text-zinc-100"
value={row.categorySlug}
onChange={(e) => {
handleUpdate(row, { categorySlug: e.target.value });
}}
>
{categories.map((cat) => (
<option key={cat.id} value={cat.slug}>
{cat.groupName ? `[${cat.groupName}] ` : ""}
{cat.name}
</option>
))}
</select>
</td>
<td className="px-3 py-2 text-zinc-400 text-[11px]">{row.groupName}</td>
<td className="px-3 py-2">
<input
className="w-full rounded border border-zinc-700 bg-zinc-900 px-2 py-1 text-xs text-zinc-100"
defaultValue={row.notes ?? ""}
onBlur={(e) => {
if (e.target.value !== (row.notes ?? "")) {
handleUpdate(row, { notes: e.target.value });
}
}}
/>
</td>
<td className="px-3 py-2 text-right">
<button
onClick={() => handleDelete(row)}
className="rounded border border-red-700/70 px-2 py-1 text-[11px] text-red-300 hover:bg-red-900/40"
>
Delete
</button>
</td>
</tr>
))}
{mappings.length === 0 && (
<tr>
<td
colSpan={6}
className="px-3 py-4 text-center text-xs text-zinc-500"
>
No mappings yet for {platform}. Click Add mapping to get started.
</td>
</tr>
)}
</tbody>
</table>
</div>
)}
</div>
);
}
+67
View File
@@ -0,0 +1,67 @@
"use client";
import Link from "next/link";
import { useAuth } from "@/context/AuthContext";
export default function AdminHomePage() {
const { token } = useAuth();
if (!token) {
return (
<div className="p-6 text-sm text-red-500">
You must be logged in to view this page.
</div>
);
}
const cards = [
{
title: "Canonical categories",
href: "admin/gunbuilder/categories",
description:
"Manage the core builder categories and their group/grouping + sort order.",
},
{
title: "Part role mappings",
href: "/admin/gunbuilder/part-role-mappings",
description:
"Advanced: map logical builder part roles to canonical categories per platform.",
},
{
title: "Merchant category mappings",
href: "/admin/gunbuilder/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>
);
}
@@ -1,356 +0,0 @@
"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<Merchant[]>([]);
const [selectedMerchantId, setSelectedMerchantId] = useState<number | null>(
null,
);
const [mappings, setMappings] = useState<Mapping[]>([]);
const [loadingMappings, setLoadingMappings] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(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<Response>[] = 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 (
<main className="min-h-screen bg-black text-zinc-50">
<div className="mx-auto max-w-5xl px-4 py-6 lg:py-10">
<header className="mb-6 flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
<div>
<p className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
Shadow Standard / Admin
</p>
<h1 className="mt-1 text-2xl md:text-3xl font-semibold tracking-tight">
Merchant Category Mapping
</h1>
<p className="mt-2 text-sm text-zinc-400 max-w-xl">
Normalize each merchant&apos;s category names into your internal
part roles (upper, barrel, handguard, etc.). Unmapped categories
will be skipped during import until they are mapped here.
</p>
</div>
<div className="space-y-2">
<label className="block text-xs font-semibold text-zinc-400 mb-1">
Select Merchant
</label>
<select
className="rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-100"
value={selectedMerchantId ?? ""}
onChange={(e) =>
setSelectedMerchantId(
e.target.value ? Number(e.target.value) : null,
)
}
>
<option value=""> Choose merchant </option>
{merchants.map((m) => (
<option key={m.id} value={m.id}>
{m.name}
</option>
))}
</select>
</div>
</header>
<section className="rounded-lg border border-zinc-800 bg-zinc-950/60 p-3 md:p-4">
{error && (
<div className="mb-3 rounded border border-red-500/60 bg-red-500/10 px-3 py-2 text-xs text-red-200">
{error}
</div>
)}
{successMessage && (
<div className="mb-3 rounded border border-emerald-500/60 bg-emerald-500/10 px-3 py-2 text-xs text-emerald-200">
{successMessage}
</div>
)}
{!selectedMerchantId ? (
<p className="text-sm text-zinc-500">
Choose a merchant above to view category mappings.
</p>
) : loadingMappings ? (
<p className="text-sm text-zinc-500">
Loading mappings for <span className="text-zinc-300">{selectedMerchantName}</span>
</p>
) : mappings.length === 0 ? (
<p className="text-sm text-zinc-500">
No category rows yet for this merchant. Once you run an import,
discovered categories will appear here automatically.
</p>
) : (
<>
<div className="mb-3 flex items-center justify-between gap-3 text-xs text-zinc-500">
<div>
Mappings for{" "}
<span className="text-zinc-300">{selectedMerchantName}</span>.{" "}
Set a part role for each raw category. Unmapped categories will
be skipped by the importer.
</div>
<label className="inline-flex items-center gap-1 text-[0.7rem] text-zinc-400">
<input
type="checkbox"
className="h-3 w-3 rounded border-zinc-600 bg-zinc-900"
checked={showOnlyUnmapped}
onChange={(e) => setShowOnlyUnmapped(e.target.checked)}
/>
<span>Show only unmapped</span>
</label>
</div>
<div className="overflow-x-auto">
<table className="min-w-full text-sm">
<thead>
<tr className="border-b border-zinc-800 text-xs uppercase tracking-[0.16em] text-zinc-500">
<th className="py-2 text-left pr-4">Raw Category</th>
<th className="py-2 text-left pr-4">Mapped Part Role</th>
<th className="py-2 text-left pr-4">Status</th>
</tr>
</thead>
<tbody>
{visibleMappings.map((m) => (
<tr
key={m.rawCategory}
className="border-b border-zinc-900 last:border-b-0"
>
<td className="py-2 pr-4 align-top text-zinc-200">
{m.rawCategory}
</td>
<td className="py-2 pr-4 align-top">
<select
className="rounded-md border border-zinc-700 bg-zinc-900 px-2 py-1 text-xs text-zinc-100"
value={m.mappedPartRole ?? ""}
onChange={(e) =>
handleChangePartRole(
m.rawCategory,
e.target.value,
)
}
>
{PART_ROLE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</td>
<td className="py-2 pr-4 align-top text-sm">
{m.mappedPartRole ? (
<span className="inline-flex items-center rounded-full bg-emerald-500/10 px-2 py-0.5 text-[0.7rem] font-medium text-emerald-300">
Mapped
</span>
) : (
<span className="inline-flex items-center rounded-full bg-zinc-700/40 px-2 py-0.5 text-[0.7rem] font-medium text-zinc-300">
Unmapped / Ignored
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="mt-4 flex justify-end">
<button
type="button"
onClick={handleSave}
disabled={saving}
className="rounded-md bg-amber-400 px-4 py-2 text-sm font-semibold text-black hover:bg-amber-300 disabled:opacity-50"
>
{saving ? "Saving…" : "Save Mappings"}
</button>
</div>
</>
)}
</section>
</div>
</main>
);
}
+1 -2
View File
@@ -3,14 +3,13 @@
import "./globals.css";
import type { ReactNode } from "react";
import { AuthProvider } from "@/context/AuthContext";
import { TopNav } from "@/components/TopNav"; // or default import if that's how you exported it
import { TopNav } from "@/components/TopNav";
import { BuilderNav } from "@/components/BuilderNav";
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body className="bg-black text-zinc-100">
{/* AuthProvider wraps everything that uses useAuth */}
<AuthProvider>
<TopNav />
<BuilderNav />
-26
View File
@@ -1,26 +0,0 @@
import { useAuth } from "@/context/AuthContext";
export function useApi() {
const { token } = useAuth();
async function get(path: string) {
return fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}${path}`, {
headers: {
Authorization: token ? `Bearer ${token}` : "",
},
});
}
async function post(path: string, body: any) {
return fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL}${path}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: token ? `Bearer ${token}` : "",
},
body: JSON.stringify(body),
});
}
return { get, post };
}
-73
View File
@@ -1,73 +0,0 @@
import { apiFetch } from "@/lib/useApi";
export type AdminCategory = {
id: number;
slug: string;
name: string;
description?: string | null;
groupName?: string | null;
sortOrder?: number | null;
};
export type AdminPartRoleMapping = {
id: number;
platform: string;
partRole: string;
categorySlug: string;
categoryName: string;
groupName?: string | null;
notes?: string | null;
};
export async function fetchAdminCategories(token: string) {
return apiFetch<AdminCategory[]>("/api/admin/categories", {
token,
});
}
export async function fetchPartRoleMappings(token: string, platform = "AR-15") {
const params = new URLSearchParams({ platform });
return apiFetch<AdminPartRoleMapping[]>(`/api/admin/category-mappings?${params.toString()}`, {
token,
});
}
export async function createPartRoleMapping(
token: string,
payload: {
platform: string;
partRole: string;
categorySlug: string;
notes?: string;
},
) {
return apiFetch<AdminPartRoleMapping>("/api/admin/category-mappings", {
method: "POST",
token,
body: payload,
});
}
export async function updatePartRoleMapping(
token: string,
id: number,
payload: {
platform: string;
partRole: string;
categorySlug: string;
notes?: string;
},
) {
return apiFetch<AdminPartRoleMapping>(`/api/admin/category-mappings/${id}`, {
method: "PUT",
token,
body: payload,
});
}
export async function deletePartRoleMapping(token: string, id: number) {
await apiFetch<void>(`/api/admin/category-mappings/${id}`, {
method: "DELETE",
token,
});
}
-116
View File
@@ -1,116 +0,0 @@
// lib/auth-client.ts
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
export type AuthUser = {
id: number;
email: string;
displayName?: string | null;
role: string;
};
export type AuthPayload = {
accessToken: string;
refreshToken?: string;
userId: number;
email: string;
displayName?: string | null;
role: string;
};
export type RegisterInput = {
email: string;
password: string;
displayName?: string;
};
export type LoginInput = {
email: string;
password: string;
};
const AUTH_STORAGE_KEY = "armory-auth";
export function storeAuth(payload: AuthPayload) {
if (typeof window === "undefined") return;
const user: AuthUser = {
id: payload.userId,
email: payload.email,
displayName: payload.displayName,
role: payload.role,
};
const toStore = {
user,
accessToken: payload.accessToken,
refreshToken: payload.refreshToken ?? null,
};
window.localStorage.setItem(AUTH_STORAGE_KEY, JSON.stringify(toStore));
}
export function loadStoredAuth():
| { user: AuthUser; accessToken: string; refreshToken: string | null }
| null {
if (typeof window === "undefined") return null;
try {
const raw = window.localStorage.getItem(AUTH_STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw);
if (!parsed.user || !parsed.accessToken) return null;
return {
user: parsed.user as AuthUser,
accessToken: parsed.accessToken as string,
refreshToken: parsed.refreshToken ?? null,
};
} catch {
return null;
}
}
export function clearStoredAuth() {
if (typeof window === "undefined") return;
window.localStorage.removeItem(AUTH_STORAGE_KEY);
}
async function handleAuthResponse(res: Response): Promise<AuthPayload> {
if (!res.ok) {
let message = `Request failed (${res.status})`;
try {
const body = await res.json();
if (body?.message) message = body.message;
} catch {
// ignore
}
throw new Error(message);
}
return (await res.json()) as AuthPayload;
}
export async function apiRegister(input: RegisterInput): Promise<AuthPayload> {
const res = await fetch(`${API_BASE_URL}/api/auth/register`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify(input),
});
return handleAuthResponse(res);
}
export async function apiLogin(input: LoginInput): Promise<AuthPayload> {
const res = await fetch(`${API_BASE_URL}/api/auth/login`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify(input),
});
return handleAuthResponse(res);
}