Refactor admin layout and merchants page for improved UI and functionality; add URL copy feature and enhance date formatting
CI / test (push) Successful in 5s

This commit is contained in:
2026-01-22 05:04:19 -05:00
parent 411c3bc05d
commit df46788570
5 changed files with 260 additions and 89 deletions
+6 -3
View File
@@ -149,7 +149,7 @@ export default function AdminLayout({
return (
// ✅ prevent browser-level horizontal scroll from any wide children
<div className="flex min-h-screen bg-black text-zinc-50 overflow-x-hidden">
<div className="min-h-screen bg-black text-zinc-50 overflow-x-hidden pt-[52px]">
<AdminLeftNavigation
collapsed={collapsed}
onToggleCollapsed={() => setCollapsed((v) => !v)}
@@ -157,8 +157,11 @@ export default function AdminLayout({
/>
{/* ✅ min-w-0 is the critical fix: lets the main column shrink */}
<div className="flex min-h-screen flex-1 min-w-0 flex-col">
<header className="flex items-center justify-between border-b border-zinc-900 bg-zinc-950/70 px-4 py-3">
<div
className="flex min-h-screen flex-1 min-w-0 flex-col transition-all duration-300"
style={{ marginLeft: collapsed ? '68px' : '280px' }}
>
<header className="flex items-center justify-between border-b border-zinc-900 bg-zinc-950/70 px-4 py-3 sticky top-0 z-30 backdrop-blur-sm">
<div>
<p className="text-xs uppercase tracking-[0.18em] text-zinc-500">
Admin
+249 -82
View File
@@ -16,13 +16,50 @@ type MerchantAdminDto = {
lastOfferSyncAt: string | null;
};
function formatDate(value: string | null) {
if (!value) return "—";
// 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);
@@ -31,6 +68,8 @@ export default function MerchantsAdminPage() {
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);
@@ -92,6 +131,15 @@ export default function MerchantsAdminPage() {
);
};
// --- 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);
@@ -135,6 +183,7 @@ export default function MerchantsAdminPage() {
// --- run full import ---
const handleRunFullImport = async (id: number) => {
setOpenDropdown(null);
try {
setImportingId(id);
setError(null);
@@ -162,6 +211,7 @@ export default function MerchantsAdminPage() {
// --- run offer sync ---
const handleRunOfferSync = async (id: number) => {
setOpenDropdown(null);
try {
setOfferSyncId(id);
setError(null);
@@ -242,7 +292,7 @@ export default function MerchantsAdminPage() {
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">
<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>
@@ -418,135 +468,252 @@ export default function MerchantsAdminPage() {
)}
</section>
{/* Table */}
<section className="rounded-lg border border-zinc-800 bg-zinc-950/70 p-3 md:p-4">
{/* Improved Table */}
<section className="rounded-lg border border-zinc-800 bg-zinc-950/70 overflow-hidden">
{loading ? (
<p className="text-sm text-zinc-500">Loading merchants</p>
<p className="text-sm text-zinc-500 p-4">Loading merchants</p>
) : merchants.length === 0 ? (
<p className="text-sm text-zinc-500">
No merchants found. Seed some merchants in the backend.
<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>
<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-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>
<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">
<tbody className="divide-y divide-zinc-800/50">
{merchants.map((m) => (
<tr key={m.id} className="align-top">
<td className="py-2 pr-3">
<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 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"
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>
<td className="py-2 px-3">
{/* 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 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"
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>
<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">
{/* Feed URL with copy button */}
<td className="py-3 px-4">
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={m.isActive}
type="text"
value={m.feedUrl ?? ""}
onChange={(e) =>
updateMerchantField(m.id, "isActive", e.target.checked)
updateMerchantField(m.id, "feedUrl", e.target.value)
}
className="h-4 w-4 rounded border-zinc-600 bg-zinc-900 text-amber-400 focus:ring-amber-400"
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"
/>
Active
</label>
<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>
<td className="py-2 px-3 text-xs text-zinc-400 whitespace-nowrap">
{formatDate(m.lastFullImportAt)}
{/* 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>
<td className="py-2 px-3 text-xs text-zinc-400 whitespace-nowrap">
{formatDate(m.lastOfferSyncAt)}
{/* 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>
<td className="py-2 pl-3">
<div className="flex flex-col items-end gap-1">
{/* 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-md px-3 py-1.5 text-xs font-medium transition-colors ${
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 ? "Saving…" : "Save"}
{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>
<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>
{/* 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>
<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>
{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>
@@ -559,4 +726,4 @@ export default function MerchantsAdminPage() {
</div>
</main>
);
}
}