Files
shadow-gunbuilder-ai-proto/app/admin/merchants/page.tsx
T

379 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}