355 lines
13 KiB
TypeScript
355 lines
13 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useState } from "react";
|
||
|
||
const API_BASE_URL =
|
||
process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
|
||
|
||
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(() => {
|
||
const controller = new AbortController();
|
||
|
||
async function loadMerchants() {
|
||
try {
|
||
setLoading(true);
|
||
setError(null);
|
||
|
||
const url = `${API_BASE_URL}/api/admin/merchants`;
|
||
console.log("Loading merchants from:", url);
|
||
|
||
const res = await fetch(url, {
|
||
method: "GET",
|
||
headers: { Accept: "application/json" },
|
||
signal: controller.signal,
|
||
});
|
||
|
||
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);
|
||
} catch (err: any) {
|
||
if (err?.name === "AbortError") return;
|
||
console.error("Error loading merchants", err);
|
||
setError(err?.message ?? "Failed to load merchants");
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}
|
||
|
||
loadMerchants();
|
||
return () => controller.abort();
|
||
}, []);
|
||
|
||
// --- 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}/api/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}/api/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 ---
|
||
const handleRunOfferSync = async (id: number) => {
|
||
try {
|
||
setOfferSyncId(id);
|
||
setError(null);
|
||
setBanner(null);
|
||
|
||
const res = await fetch(
|
||
`${API_BASE_URL}/api/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>
|
||
);
|
||
} |