Files
shadow-gunbuilder-ai-proto/app/admin/merchants/page.tsx
T
2026-01-25 13:40:10 -05:00

729 lines
30 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";
// API routes now handled by Next.js /api routes (server-side)
type MerchantAdminDto = {
id: number;
name: string;
avantlinkMid: string;
feedUrl: string;
offerFeedUrl: string | null;
isActive: boolean;
lastFullImportAt: string | null;
lastOfferSyncAt: string | null;
};
// Helper: relative time formatting
function formatRelativeTime(value: string | null): string {
if (!value) return "Never";
const d = new Date(value);
if (Number.isNaN(d.getTime())) return "Invalid date";
const now = Date.now();
const diff = now - d.getTime();
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000);
if (minutes < 1) return "Just now";
if (minutes < 60) return `${minutes}m ago`;
if (hours < 24) return `${hours}h ago`;
if (days < 30) return `${days}d ago`;
return d.toLocaleDateString();
}
// Helper: full date formatting for tooltips
function formatFullDate(value: string | null): string {
if (!value) return "Never";
const d = new Date(value);
if (Number.isNaN(d.getTime())) return value;
return d.toLocaleString();
}
// Helper: truncate URL for display
function truncateUrl(url: string | null, maxLength = 40): string {
if (!url) return "—";
if (url.length <= maxLength) return url;
return url.substring(0, maxLength) + "...";
}
// Helper: copy to clipboard
async function copyToClipboard(text: string) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
return false;
}
}
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);
const [copiedUrl, setCopiedUrl] = useState<string | null>(null);
const [openDropdown, setOpenDropdown] = useState<number | null>(null);
// --- new merchant form state ---
const [showNewMerchantForm, setShowNewMerchantForm] = useState(false);
const [creating, setCreating] = useState(false);
const [newMerchant, setNewMerchant] = useState({
name: "",
avantlinkMid: "",
feedUrl: "",
offerFeedUrl: "",
isActive: true,
});
// --- load merchants ---
useEffect(() => {
const controller = new AbortController();
async function loadMerchants() {
try {
setLoading(true);
setError(null);
const 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))
);
};
// --- copy URL handler ---
const handleCopyUrl = async (url: string, id: number) => {
const success = await copyToClipboard(url);
if (success) {
setCopiedUrl(`${id}-${url}`);
setTimeout(() => setCopiedUrl(null), 2000);
}
};
// --- 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/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) => {
setOpenDropdown(null);
try {
setImportingId(id);
setError(null);
setBanner(null);
const res = await fetch(`/api/admin/merchants/${id}/import`, {
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) => {
setOpenDropdown(null);
try {
setOfferSyncId(id);
setError(null);
setBanner(null);
const res = await fetch(
`/api/admin/merchants/${id}/offer-sync`,
{ 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);
}
};
// --- create new merchant ---
const handleCreateMerchant = async () => {
try {
setCreating(true);
setError(null);
setBanner(null);
const res = await fetch(`/api/admin/merchants`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: newMerchant.name,
avantlinkMid: newMerchant.avantlinkMid,
feedUrl: newMerchant.feedUrl,
offerFeedUrl: newMerchant.offerFeedUrl || null,
isActive: newMerchant.isActive,
}),
});
if (!res.ok) {
const text = await res.text().catch(() => "");
console.error("Merchant creation failed", res.status, text);
throw new Error(
`Creation failed (${res.status})${text ? `: ${text}` : ""}`
);
}
const created: MerchantAdminDto = await res.json();
setMerchants((prev) => [...prev, created]);
// Reset form
setNewMerchant({
name: "",
avantlinkMid: "",
feedUrl: "",
offerFeedUrl: "",
isActive: true,
});
setShowNewMerchantForm(false);
setBanner("Merchant created successfully.");
} catch (e: any) {
console.error("Error creating merchant", e);
setError(e?.message ?? "Failed to create merchant");
} finally {
setCreating(false);
setTimeout(() => setBanner(null), 3000);
}
};
return (
<main className="min-h-screen bg-black text-zinc-50">
<div className="mx-auto max-w-7xl 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>
)}
{/* Add New Merchant Section */}
<section className="mb-6 rounded-lg border border-zinc-800 bg-zinc-950/70 p-4">
{!showNewMerchantForm ? (
<button
type="button"
onClick={() => setShowNewMerchantForm(true)}
className="inline-flex items-center gap-2 rounded-md bg-amber-400 px-4 py-2 text-sm font-semibold text-black hover:bg-amber-300 transition-colors"
>
<span className="text-lg">+</span>
Add New Merchant
</button>
) : (
<div>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-zinc-100">
Add New Merchant
</h2>
<button
type="button"
onClick={() => {
setShowNewMerchantForm(false);
setNewMerchant({
name: "",
avantlinkMid: "",
feedUrl: "",
offerFeedUrl: "",
isActive: true,
});
}}
className="text-zinc-400 hover:text-zinc-200 text-sm"
>
Cancel
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-zinc-400 mb-1">
Merchant Name *
</label>
<input
type="text"
value={newMerchant.name}
onChange={(e) =>
setNewMerchant((prev) => ({ ...prev, name: e.target.value }))
}
placeholder="e.g., Brownells"
className="w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-50 placeholder:text-zinc-600 focus:border-amber-400/70 focus:outline-none focus:ring-0"
/>
</div>
<div>
<label className="block text-xs font-medium text-zinc-400 mb-1">
AvantLink MID *
</label>
<input
type="text"
value={newMerchant.avantlinkMid}
onChange={(e) =>
setNewMerchant((prev) => ({
...prev,
avantlinkMid: e.target.value,
}))
}
placeholder="e.g., 12345"
className="w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-50 placeholder:text-zinc-600 focus:border-amber-400/70 focus:outline-none focus:ring-0"
/>
</div>
<div className="md:col-span-2">
<label className="block text-xs font-medium text-zinc-400 mb-1">
Feed URL *
</label>
<input
type="text"
value={newMerchant.feedUrl}
onChange={(e) =>
setNewMerchant((prev) => ({ ...prev, feedUrl: e.target.value }))
}
placeholder="https://..."
className="w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-50 placeholder:text-zinc-600 focus:border-amber-400/70 focus:outline-none focus:ring-0"
/>
</div>
<div className="md:col-span-2">
<label className="block text-xs font-medium text-zinc-400 mb-1">
Offer Feed URL (optional)
</label>
<input
type="text"
value={newMerchant.offerFeedUrl}
onChange={(e) =>
setNewMerchant((prev) => ({
...prev,
offerFeedUrl: e.target.value,
}))
}
placeholder="https://..."
className="w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-50 placeholder:text-zinc-600 focus:border-amber-400/70 focus:outline-none focus:ring-0"
/>
</div>
<div className="md:col-span-2">
<label className="inline-flex items-center gap-2 text-sm text-zinc-300">
<input
type="checkbox"
checked={newMerchant.isActive}
onChange={(e) =>
setNewMerchant((prev) => ({
...prev,
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>
</div>
</div>
<div className="mt-4 flex gap-3">
<button
type="button"
onClick={handleCreateMerchant}
disabled={
creating ||
!newMerchant.name ||
!newMerchant.avantlinkMid ||
!newMerchant.feedUrl
}
className={`rounded-md px-4 py-2 text-sm font-semibold transition-colors ${
creating ||
!newMerchant.name ||
!newMerchant.avantlinkMid ||
!newMerchant.feedUrl
? "bg-amber-400/20 text-amber-200 cursor-not-allowed"
: "bg-amber-400 text-black hover:bg-amber-300"
}`}
>
{creating ? "Creating…" : "Create Merchant"}
</button>
</div>
</div>
)}
</section>
{/* Improved Table */}
<section className="rounded-lg border border-zinc-800 bg-zinc-950/70 overflow-hidden">
{loading ? (
<p className="text-sm text-zinc-500 p-4">Loading merchants</p>
) : merchants.length === 0 ? (
<p className="text-sm text-zinc-500 p-4">
No merchants found. Use the "Add New Merchant" button above.
</p>
) : (
<div className="overflow-x-auto">
<table className="min-w-full text-left text-sm">
<thead className="sticky top-0 bg-zinc-950/95 backdrop-blur-sm">
<tr className="border-b border-zinc-800 text-xs uppercase tracking-[0.16em] text-zinc-500">
<th className="py-3 px-4 font-medium">Merchant</th>
<th className="py-3 px-4 font-medium">MID</th>
<th className="py-3 px-4 font-medium">Feed URL</th>
<th className="py-3 px-4 font-medium">Offer URL</th>
<th className="py-3 px-4 font-medium">Status</th>
<th className="py-3 px-4 font-medium">Last Import</th>
<th className="py-3 px-4 font-medium text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-800/50">
{merchants.map((m) => (
<tr key={m.id} className="hover:bg-zinc-900/30 transition-colors">
{/* Merchant Name */}
<td className="py-3 px-4">
<input
type="text"
value={m.name}
onChange={(e) =>
updateMerchantField(m.id, "name", e.target.value)
}
className="w-full min-w-[140px] rounded border border-zinc-700 bg-zinc-900 px-2 py-1.5 text-sm font-medium text-zinc-50 focus:border-amber-400/70 focus:outline-none focus:ring-0"
/>
</td>
{/* AvantLink MID */}
<td className="py-3 px-4">
<input
type="text"
value={m.avantlinkMid}
onChange={(e) =>
updateMerchantField(m.id, "avantlinkMid", e.target.value)
}
className="w-full min-w-[80px] rounded border border-zinc-700 bg-zinc-900 px-2 py-1.5 text-xs font-mono text-zinc-50 focus:border-amber-400/70 focus:outline-none focus:ring-0"
/>
</td>
{/* Feed URL with copy button */}
<td className="py-3 px-4">
<div className="flex items-center gap-2">
<input
type="text"
value={m.feedUrl ?? ""}
onChange={(e) =>
updateMerchantField(m.id, "feedUrl", e.target.value)
}
title={m.feedUrl ?? ""}
className="flex-1 min-w-[180px] rounded border border-zinc-700 bg-zinc-900 px-2 py-1.5 text-xs font-mono text-zinc-50 focus:border-amber-400/70 focus:outline-none focus:ring-0"
/>
<button
type="button"
onClick={() => handleCopyUrl(m.feedUrl, m.id)}
className="flex-shrink-0 p-1.5 rounded hover:bg-zinc-800 transition-colors text-zinc-400 hover:text-zinc-200"
title="Copy URL"
>
{copiedUrl === `${m.id}-${m.feedUrl}` ? (
<svg className="w-4 h-4 text-green-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
) : (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
)}
</button>
</div>
</td>
{/* Offer Feed URL with copy button */}
<td className="py-3 px-4">
<div className="flex items-center gap-2">
<input
type="text"
value={m.offerFeedUrl ?? ""}
onChange={(e) =>
updateMerchantField(m.id, "offerFeedUrl", e.target.value || null)
}
placeholder="—"
title={m.offerFeedUrl ?? "None"}
className="flex-1 min-w-[180px] rounded border border-zinc-700 bg-zinc-900 px-2 py-1.5 text-xs font-mono text-zinc-50 placeholder:text-zinc-600 focus:border-amber-400/70 focus:outline-none focus:ring-0"
/>
{m.offerFeedUrl && (
<button
type="button"
onClick={() => handleCopyUrl(m.offerFeedUrl!, m.id)}
className="flex-shrink-0 p-1.5 rounded hover:bg-zinc-800 transition-colors text-zinc-400 hover:text-zinc-200"
title="Copy URL"
>
{copiedUrl === `${m.id}-${m.offerFeedUrl}` ? (
<svg className="w-4 h-4 text-green-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
) : (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
)}
</button>
)}
</div>
</td>
{/* Status Badge (replacing checkbox) */}
<td className="py-3 px-4">
<button
type="button"
onClick={() =>
updateMerchantField(m.id, "isActive", !m.isActive)
}
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium transition-colors ${
m.isActive
? "bg-emerald-500/10 text-emerald-400 hover:bg-emerald-500/20"
: "bg-zinc-700/50 text-zinc-400 hover:bg-zinc-700"
}`}
>
<span
className={`w-1.5 h-1.5 rounded-full ${
m.isActive ? "bg-emerald-400" : "bg-zinc-500"
}`}
/>
{m.isActive ? "Active" : "Inactive"}
</button>
</td>
{/* Last Import with tooltip */}
<td className="py-3 px-4">
<div className="flex flex-col gap-0.5">
<span
className="text-xs text-zinc-400"
title={`Full: ${formatFullDate(m.lastFullImportAt)}`}
>
{formatRelativeTime(m.lastFullImportAt)}
</span>
<span
className="text-[10px] text-zinc-500"
title={`Offers: ${formatFullDate(m.lastOfferSyncAt)}`}
>
Offers: {formatRelativeTime(m.lastOfferSyncAt)}
</span>
</div>
</td>
{/* Actions Dropdown */}
<td className="py-3 px-4">
<div className="flex items-center justify-end gap-2">
{/* Primary Save Button */}
<button
type="button"
onClick={() => handleSave(m.id)}
disabled={savingId === m.id}
className={`rounded 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 ? (
<span className="flex items-center gap-1">
<svg className="animate-spin h-3 w-3" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
Saving
</span>
) : (
"Save"
)}
</button>
{/* Dropdown Menu */}
<div className="relative">
<button
type="button"
onClick={() =>
setOpenDropdown(openDropdown === m.id ? null : m.id)
}
className="p-1.5 rounded hover:bg-zinc-800 transition-colors text-zinc-400 hover:text-zinc-200"
title="More actions"
>
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z" />
</svg>
</button>
{openDropdown === m.id && (
<>
<div
className="fixed inset-0 z-10"
onClick={() => setOpenDropdown(null)}
/>
<div className="absolute right-0 mt-1 w-48 rounded-md border border-zinc-700 bg-zinc-900 shadow-lg z-20">
<div className="py-1">
<button
type="button"
onClick={() => handleRunFullImport(m.id)}
disabled={importingId === m.id}
className="w-full text-left px-3 py-2 text-xs text-zinc-300 hover:bg-zinc-800 hover:text-white transition-colors disabled:opacity-50 disabled:cursor-wait"
>
{importingId === m.id ? (
<span className="flex items-center gap-2">
<svg className="animate-spin h-3 w-3" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
Importing...
</span>
) : (
"Run Full Import"
)}
</button>
<button
type="button"
onClick={() => handleRunOfferSync(m.id)}
disabled={offerSyncId === m.id}
className="w-full text-left px-3 py-2 text-xs text-zinc-300 hover:bg-zinc-800 hover:text-white transition-colors disabled:opacity-50 disabled:cursor-wait"
>
{offerSyncId === m.id ? (
<span className="flex items-center gap-2">
<svg className="animate-spin h-3 w-3" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
Syncing...
</span>
) : (
"Run Offer Sync"
)}
</button>
</div>
</div>
</>
)}
</div>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</div>
</main>
);
}