diff --git a/.env.production b/.env.production index 3987ead..bddee24 100644 --- a/.env.production +++ b/.env.production @@ -2,7 +2,7 @@ NEXT_PUBLIC_API_BASE_URL=http://battlbuilder-api:8080 # Middleware to limited site to only root page -NEXT_PUBLIC_LAUNCH_ONLY_ROOT=false +NEXT_PUBLIC_LAUNCH_ONLY_ROOT=true NEXT_PUBLIC_SHORTLINK_BASE_URL=https://battl.build diff --git a/app/admin/layout.tsx b/app/admin/layout.tsx index b01090c..d787cd0 100644 --- a/app/admin/layout.tsx +++ b/app/admin/layout.tsx @@ -149,7 +149,7 @@ export default function AdminLayout({ return ( // ✅ prevent browser-level horizontal scroll from any wide children -
+
setCollapsed((v) => !v)} @@ -157,8 +157,11 @@ export default function AdminLayout({ /> {/* ✅ min-w-0 is the critical fix: lets the main column shrink */} -
-
+
+

Admin diff --git a/app/admin/merchants/page.tsx b/app/admin/merchants/page.tsx index be58ef2..63f63ff 100644 --- a/app/admin/merchants/page.tsx +++ b/app/admin/merchants/page.tsx @@ -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([]); const [loading, setLoading] = useState(true); @@ -31,6 +68,19 @@ export default function MerchantsAdminPage() { const [importingId, setImportingId] = useState(null); const [offerSyncId, setOfferSyncId] = useState(null); const [banner, setBanner] = useState(null); + const [copiedUrl, setCopiedUrl] = useState(null); + const [openDropdown, setOpenDropdown] = useState(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(() => { @@ -81,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); @@ -124,6 +183,7 @@ export default function MerchantsAdminPage() { // --- run full import --- const handleRunFullImport = async (id: number) => { + setOpenDropdown(null); try { setImportingId(id); setError(null); @@ -151,6 +211,7 @@ export default function MerchantsAdminPage() { // --- run offer sync --- const handleRunOfferSync = async (id: number) => { + setOpenDropdown(null); try { setOfferSyncId(id); setError(null); @@ -179,9 +240,59 @@ export default function MerchantsAdminPage() { } }; + // --- create new merchant --- + const handleCreateMerchant = async () => { + try { + setCreating(true); + setError(null); + setBanner(null); + + const res = await fetch(`${API_BASE_URL}/api/v1/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 (

-
+
{/* Header */}
@@ -211,135 +322,398 @@ export default function MerchantsAdminPage() {
)} - {/* Table */} -
+ {/* Add New Merchant Section */} +
+ {!showNewMerchantForm ? ( + + ) : ( +
+
+

+ Add New Merchant +

+ +
+ +
+
+ + + 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" + /> +
+ +
+ + + 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" + /> +
+ +
+ + + 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" + /> +
+ +
+ + + 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" + /> +
+ +
+ +
+
+ +
+ +
+
+ )} +
+ + {/* Improved Table */} +
{loading ? ( -

Loading merchants…

+

Loading merchants…

) : merchants.length === 0 ? ( -

- No merchants found. Seed some merchants in the backend. +

+ No merchants found. Use the "Add New Merchant" button above.

) : (
- + - - - - - - - - + + + + + + + - + {merchants.map((m) => ( - - + {/* Merchant Name */} + - - - - - - - - - + + {/* Actions Dropdown */} + @@ -352,4 +726,4 @@ export default function MerchantsAdminPage() { ); -} \ No newline at end of file +} diff --git a/components/AdminLeftNavigation.tsx b/components/AdminLeftNavigation.tsx index bf2b10e..b808bf8 100644 --- a/components/AdminLeftNavigation.tsx +++ b/components/AdminLeftNavigation.tsx @@ -46,9 +46,10 @@ export default function AdminLeftNavigation({ return (
MerchantAvantLink MIDFeed URLOffer Feed URLActiveLast Full ImportLast Offer SyncActionsMerchantMIDFeed URLOffer URLStatusLast ImportActions
+
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" /> + {/* AvantLink MID */} + 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" /> - - 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" - /> - - - 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" - /> - - +
- 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 - + +
- {formatDate(m.lastFullImportAt)} + {/* Offer Feed URL with copy button */} + +
+ + 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 && ( + + )} +
- {formatDate(m.lastOfferSyncAt)} + {/* Status Badge (replacing checkbox) */} + + -
+ {/* Last Import with tooltip */} +
+
+ + {formatRelativeTime(m.lastFullImportAt)} + + + Offers: {formatRelativeTime(m.lastOfferSyncAt)} + +
+
+
+ {/* Primary Save Button */} - + {/* Dropdown Menu */} +
+ - + {openDropdown === m.id && ( + <> +
setOpenDropdown(null)} + /> +
+
+ + +
+
+ + )} +