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>
);
}
+379
View File
@@ -0,0 +1,379 @@
"use client";
import { useEffect, useState } from "react";
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
type MerchantAdminDto = {
id: number;
name: string;
avantlinkMid: string;
feedUrl: string;
offerFeedUrl: string | null;
isActive: boolean;
lastFullImportAt: string | null;
lastOfferSyncAt: string | null;
};
function formatDate(value: string | null) {
if (!value) return "—";
const d = new Date(value);
if (Number.isNaN(d.getTime())) return value;
return d.toLocaleString();
}
export default function MerchantsAdminPage() {
const [merchants, setMerchants] = useState<MerchantAdminDto[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [savingId, setSavingId] = useState<number | null>(null);
const [importingId, setImportingId] = useState<number | null>(null);
const [offerSyncId, setOfferSyncId] = useState<number | null>(null);
const [banner, setBanner] = useState<string | null>(null);
// --- load merchants ---
useEffect(() => {
let cancelled = false;
async function loadMerchants() {
try {
console.log("Loading merchants from:", `${API_BASE_URL}/admin/merchants`);
const res = await fetch(`${API_BASE_URL}/admin/merchants`, {
method: "GET",
headers: {
Accept: "application/json",
},
});
console.log("Merchants response status:", res.status);
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`HTTP ${res.status} ${res.statusText} ${text}`);
}
const data: MerchantAdminDto[] = await res.json();
setMerchants(data);
setError(null);
} catch (err: any) {
console.error("Error loading merchants", err);
setError(err.message ?? "Failed to load merchants");
} finally {
setLoading(false);
}
}
loadMerchants();
return () => {
cancelled = true;
};
}, []);
// --- local field editing ---
const updateMerchantField = <K extends keyof MerchantAdminDto>(
id: number,
field: K,
value: MerchantAdminDto[K],
) => {
setMerchants((prev) =>
prev.map((m) => (m.id === id ? { ...m, [field]: value } : m)),
);
};
// --- save merchant (name/feed URLs/isActive) ---
const handleSave = async (id: number) => {
const merchant = merchants.find((m) => m.id === id);
if (!merchant) return;
try {
setSavingId(id);
setError(null);
setBanner(null);
const res = await fetch(`${API_BASE_URL}/admin/merchants/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: merchant.name,
avantlinkMid: merchant.avantlinkMid,
feedUrl: merchant.feedUrl,
offerFeedUrl: merchant.offerFeedUrl,
isActive: merchant.isActive,
}),
});
if (!res.ok) {
const text = await res.text().catch(() => "");
console.error("Merchant save failed", res.status, text);
throw new Error(
`Save failed (${res.status})${text ? `: ${text}` : ""}`,
);
}
const updated: MerchantAdminDto = await res.json();
setMerchants((prev) =>
prev.map((m) => (m.id === id ? updated : m)),
);
setBanner("Merchant saved successfully.");
} catch (e: any) {
console.error("Error saving merchant", e);
setError(e.message ?? "Failed to save merchant");
} finally {
setSavingId(null);
setTimeout(() => setBanner(null), 3000);
}
};
// --- run full import ---
const handleRunFullImport = async (id: number) => {
try {
setImportingId(id);
setError(null);
setBanner(null);
const res = await fetch(`${API_BASE_URL}/admin/imports/${id}`, {
method: "POST",
});
if (!res.ok) {
const text = await res.text().catch(() => "");
console.error("Full import failed", res.status, text);
throw new Error(
`Import failed (${res.status})${text ? `: ${text}` : ""}`,
);
}
setBanner("Full import started successfully.");
} catch (e: any) {
console.error("Error starting full import", e);
setError(e.message ?? "Failed to start full import");
} finally {
setImportingId(null);
setTimeout(() => setBanner(null), 3000);
}
};
// --- run offer sync (matches /{merchantId}/offers-only) ---
const handleRunOfferSync = async (id: number) => {
try {
setOfferSyncId(id);
setError(null);
setBanner(null);
const res = await fetch(
`${API_BASE_URL}/admin/imports/${id}/offers-only`,
{
method: "POST",
},
);
if (!res.ok) {
const text = await res.text().catch(() => "");
console.error("Offer sync failed", res.status, text);
throw new Error(
`Offer sync failed (${res.status})${text ? `: ${text}` : ""}`,
);
}
setBanner("Offer sync started successfully.");
} catch (e: any) {
console.error("Error starting offer sync", e);
setError(e.message ?? "Failed to start offer sync");
} finally {
setOfferSyncId(null);
setTimeout(() => setBanner(null), 3000);
}
};
return (
<main className="min-h-screen bg-black text-zinc-50">
<div className="mx-auto max-w-6xl px-4 py-6 lg:py-10">
{/* Header */}
<header className="mb-6 flex items-center justify-between gap-4">
<div>
<p className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
Shadow Standard
</p>
<h1 className="mt-1 text-2xl md:text-3xl font-semibold tracking-tight">
Merchant Feeds <span className="text-amber-300">Admin</span>
</h1>
<p className="mt-2 text-sm text-zinc-400 max-w-2xl">
Manage your AvantLink merchants, product feed URLs, and offer
sync settings. Use this to onboard new merchants and keep feeds
fresh without touching SQL.
</p>
</div>
</header>
{/* Status banners */}
{banner && (
<div className="mb-4 rounded-md border border-emerald-500/40 bg-emerald-500/10 px-3 py-2 text-sm text-emerald-200">
{banner}
</div>
)}
{error && (
<div className="mb-4 rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2 text-sm text-red-200">
{error}
</div>
)}
{/* Table */}
<section className="rounded-lg border border-zinc-800 bg-zinc-950/70 p-3 md:p-4">
{loading ? (
<p className="text-sm text-zinc-500">Loading merchants</p>
) : merchants.length === 0 ? (
<p className="text-sm text-zinc-500">
No merchants found. Seed some merchants in the backend.
</p>
) : (
<div className="overflow-x-auto">
<table className="min-w-full text-left text-sm">
<thead>
<tr className="border-b border-zinc-800 text-xs uppercase tracking-[0.16em] text-zinc-500">
<th className="py-2 pr-3">Merchant</th>
<th className="py-2 px-3">AvantLink MID</th>
<th className="py-2 px-3">Feed URL</th>
<th className="py-2 px-3">Offer Feed URL</th>
<th className="py-2 px-3">Active</th>
<th className="py-2 px-3">Last Full Import</th>
<th className="py-2 px-3">Last Offer Sync</th>
<th className="py-2 pl-3 text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-800">
{merchants.map((m) => (
<tr key={m.id} className="align-top">
<td className="py-2 pr-3">
<input
type="text"
value={m.name}
onChange={(e) =>
updateMerchantField(m.id, "name", e.target.value)
}
className="w-full rounded-md border border-zinc-700 bg-zinc-900 px-2 py-1 text-sm text-zinc-50 focus:border-amber-400/70 focus:outline-none focus:ring-0"
/>
</td>
<td className="py-2 px-3">
<input
type="text"
value={m.avantlinkMid}
onChange={(e) =>
updateMerchantField(
m.id,
"avantlinkMid",
e.target.value,
)
}
className="w-full rounded-md border border-zinc-700 bg-zinc-900 px-2 py-1 text-sm text-zinc-50 focus:border-amber-400/70 focus:outline-none focus:ring-0"
/>
</td>
<td className="py-2 px-3">
<input
type="text"
value={m.feedUrl ?? ""}
onChange={(e) =>
updateMerchantField(
m.id,
"feedUrl",
e.target.value,
)
}
className="w-full rounded-md border border-zinc-700 bg-zinc-900 px-2 py-1 text-xs md:text-sm text-zinc-50 focus:border-amber-400/70 focus:outline-none focus:ring-0"
/>
</td>
<td className="py-2 px-3">
<input
type="text"
value={m.offerFeedUrl ?? ""}
onChange={(e) =>
updateMerchantField(
m.id,
"offerFeedUrl",
e.target.value || null,
)
}
className="w-full rounded-md border border-zinc-700 bg-zinc-900 px-2 py-1 text-xs md:text-sm text-zinc-50 focus:border-amber-400/70 focus:outline-none focus:ring-0"
/>
</td>
<td className="py-2 px-3">
<label className="inline-flex items-center gap-2 text-xs text-zinc-300">
<input
type="checkbox"
checked={m.isActive}
onChange={(e) =>
updateMerchantField(
m.id,
"isActive",
e.target.checked,
)
}
className="h-4 w-4 rounded border-zinc-600 bg-zinc-900 text-amber-400 focus:ring-amber-400"
/>
Active
</label>
</td>
<td className="py-2 px-3 text-xs text-zinc-400 whitespace-nowrap">
{formatDate(m.lastFullImportAt)}
</td>
<td className="py-2 px-3 text-xs text-zinc-400 whitespace-nowrap">
{formatDate(m.lastOfferSyncAt)}
</td>
<td className="py-2 pl-3">
<div className="flex flex-col items-end gap-1">
<button
type="button"
onClick={() => handleSave(m.id)}
disabled={savingId === m.id}
className={`rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
savingId === m.id
? "bg-amber-400/20 text-amber-200 cursor-wait"
: "bg-amber-400 text-black hover:bg-amber-300"
}`}
>
{savingId === m.id ? "Saving…" : "Save"}
</button>
<button
type="button"
onClick={() => handleRunFullImport(m.id)}
disabled={importingId === m.id}
className={`rounded-md px-3 py-1.5 text-xs font-medium border border-zinc-700 bg-zinc-900 text-zinc-200 hover:bg-zinc-800 transition-colors ${
importingId === m.id
? "opacity-60 cursor-wait"
: ""
}`}
>
{importingId === m.id
? "Importing…"
: "Run Full Import"}
</button>
<button
type="button"
onClick={() => handleRunOfferSync(m.id)}
disabled={offerSyncId === m.id}
className={`rounded-md px-3 py-1.5 text-xs font-medium border border-zinc-700 bg-zinc-900 text-zinc-200 hover:bg-zinc-800 transition-colors ${
offerSyncId === m.id
? "opacity-60 cursor-wait"
: ""
}`}
>
{offerSyncId === m.id
? "Syncing Offers…"
: "Run Offer Sync"}
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</div>
</main>
);
}
@@ -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>
);
}