Merge branch 'develop' of ssh://gitea.gofwd.group:2225/sean/shadow-gunbuilder-ai-proto into develop
CI / test (push) Successful in 5s
CI / test (push) Successful in 5s
This commit is contained in:
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
NEXT_PUBLIC_API_BASE_URL=http://battlbuilder-api:8080
|
NEXT_PUBLIC_API_BASE_URL=http://battlbuilder-api:8080
|
||||||
|
|
||||||
# Middleware to limited site to only root page
|
# 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
|
NEXT_PUBLIC_SHORTLINK_BASE_URL=https://battl.build
|
||||||
|
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ export default function AdminLayout({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
// ✅ prevent browser-level horizontal scroll from any wide children
|
// ✅ 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
|
<AdminLeftNavigation
|
||||||
collapsed={collapsed}
|
collapsed={collapsed}
|
||||||
onToggleCollapsed={() => setCollapsed((v) => !v)}
|
onToggleCollapsed={() => setCollapsed((v) => !v)}
|
||||||
@@ -157,8 +157,11 @@ export default function AdminLayout({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* ✅ min-w-0 is the critical fix: lets the main column shrink */}
|
{/* ✅ 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">
|
<div
|
||||||
<header className="flex items-center justify-between border-b border-zinc-900 bg-zinc-950/70 px-4 py-3">
|
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>
|
<div>
|
||||||
<p className="text-xs uppercase tracking-[0.18em] text-zinc-500">
|
<p className="text-xs uppercase tracking-[0.18em] text-zinc-500">
|
||||||
Admin
|
Admin
|
||||||
|
|||||||
+455
-81
@@ -16,13 +16,50 @@ type MerchantAdminDto = {
|
|||||||
lastOfferSyncAt: string | null;
|
lastOfferSyncAt: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
function formatDate(value: string | null) {
|
// Helper: relative time formatting
|
||||||
if (!value) return "—";
|
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);
|
const d = new Date(value);
|
||||||
if (Number.isNaN(d.getTime())) return value;
|
if (Number.isNaN(d.getTime())) return value;
|
||||||
return d.toLocaleString();
|
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() {
|
export default function MerchantsAdminPage() {
|
||||||
const [merchants, setMerchants] = useState<MerchantAdminDto[]>([]);
|
const [merchants, setMerchants] = useState<MerchantAdminDto[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -31,6 +68,19 @@ export default function MerchantsAdminPage() {
|
|||||||
const [importingId, setImportingId] = useState<number | null>(null);
|
const [importingId, setImportingId] = useState<number | null>(null);
|
||||||
const [offerSyncId, setOfferSyncId] = useState<number | null>(null);
|
const [offerSyncId, setOfferSyncId] = useState<number | null>(null);
|
||||||
const [banner, setBanner] = useState<string | 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 ---
|
// --- load merchants ---
|
||||||
useEffect(() => {
|
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) ---
|
// --- save merchant (name/feed URLs/isActive) ---
|
||||||
const handleSave = async (id: number) => {
|
const handleSave = async (id: number) => {
|
||||||
const merchant = merchants.find((m) => m.id === id);
|
const merchant = merchants.find((m) => m.id === id);
|
||||||
@@ -124,6 +183,7 @@ export default function MerchantsAdminPage() {
|
|||||||
|
|
||||||
// --- run full import ---
|
// --- run full import ---
|
||||||
const handleRunFullImport = async (id: number) => {
|
const handleRunFullImport = async (id: number) => {
|
||||||
|
setOpenDropdown(null);
|
||||||
try {
|
try {
|
||||||
setImportingId(id);
|
setImportingId(id);
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -151,6 +211,7 @@ export default function MerchantsAdminPage() {
|
|||||||
|
|
||||||
// --- run offer sync ---
|
// --- run offer sync ---
|
||||||
const handleRunOfferSync = async (id: number) => {
|
const handleRunOfferSync = async (id: number) => {
|
||||||
|
setOpenDropdown(null);
|
||||||
try {
|
try {
|
||||||
setOfferSyncId(id);
|
setOfferSyncId(id);
|
||||||
setError(null);
|
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 (
|
return (
|
||||||
<main className="min-h-screen bg-black text-zinc-50">
|
<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 */}
|
||||||
<header className="mb-6 flex items-center justify-between gap-4">
|
<header className="mb-6 flex items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
@@ -211,135 +322,398 @@ export default function MerchantsAdminPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Table */}
|
{/* Add New Merchant Section */}
|
||||||
<section className="rounded-lg border border-zinc-800 bg-zinc-950/70 p-3 md:p-4">
|
<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 ? (
|
{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 ? (
|
) : merchants.length === 0 ? (
|
||||||
<p className="text-sm text-zinc-500">
|
<p className="text-sm text-zinc-500 p-4">
|
||||||
No merchants found. Seed some merchants in the backend.
|
No merchants found. Use the "Add New Merchant" button above.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="min-w-full text-left text-sm">
|
<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">
|
<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-3 px-4 font-medium">Merchant</th>
|
||||||
<th className="py-2 px-3">AvantLink MID</th>
|
<th className="py-3 px-4 font-medium">MID</th>
|
||||||
<th className="py-2 px-3">Feed URL</th>
|
<th className="py-3 px-4 font-medium">Feed URL</th>
|
||||||
<th className="py-2 px-3">Offer Feed URL</th>
|
<th className="py-3 px-4 font-medium">Offer URL</th>
|
||||||
<th className="py-2 px-3">Active</th>
|
<th className="py-3 px-4 font-medium">Status</th>
|
||||||
<th className="py-2 px-3">Last Full Import</th>
|
<th className="py-3 px-4 font-medium">Last Import</th>
|
||||||
<th className="py-2 px-3">Last Offer Sync</th>
|
<th className="py-3 px-4 font-medium text-right">Actions</th>
|
||||||
<th className="py-2 pl-3 text-right">Actions</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|
||||||
<tbody className="divide-y divide-zinc-800">
|
<tbody className="divide-y divide-zinc-800/50">
|
||||||
{merchants.map((m) => (
|
{merchants.map((m) => (
|
||||||
<tr key={m.id} className="align-top">
|
<tr key={m.id} className="hover:bg-zinc-900/30 transition-colors">
|
||||||
<td className="py-2 pr-3">
|
{/* Merchant Name */}
|
||||||
|
<td className="py-3 px-4">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={m.name}
|
value={m.name}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
updateMerchantField(m.id, "name", e.target.value)
|
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>
|
||||||
|
|
||||||
<td className="py-2 px-3">
|
{/* AvantLink MID */}
|
||||||
|
<td className="py-3 px-4">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={m.avantlinkMid}
|
value={m.avantlinkMid}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
updateMerchantField(m.id, "avantlinkMid", e.target.value)
|
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>
|
||||||
|
|
||||||
<td className="py-2 px-3">
|
{/* Feed URL with copy button */}
|
||||||
<input
|
<td className="py-3 px-4">
|
||||||
type="text"
|
<div className="flex items-center gap-2">
|
||||||
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
|
<input
|
||||||
type="checkbox"
|
type="text"
|
||||||
checked={m.isActive}
|
value={m.feedUrl ?? ""}
|
||||||
onChange={(e) =>
|
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
|
<button
|
||||||
</label>
|
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>
|
||||||
|
|
||||||
<td className="py-2 px-3 text-xs text-zinc-400 whitespace-nowrap">
|
{/* Offer Feed URL with copy button */}
|
||||||
{formatDate(m.lastFullImportAt)}
|
<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>
|
||||||
|
|
||||||
<td className="py-2 px-3 text-xs text-zinc-400 whitespace-nowrap">
|
{/* Status Badge (replacing checkbox) */}
|
||||||
{formatDate(m.lastOfferSyncAt)}
|
<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>
|
||||||
|
|
||||||
<td className="py-2 pl-3">
|
{/* Last Import with tooltip */}
|
||||||
<div className="flex flex-col items-end gap-1">
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleSave(m.id)}
|
onClick={() => handleSave(m.id)}
|
||||||
disabled={savingId === 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
|
savingId === m.id
|
||||||
? "bg-amber-400/20 text-amber-200 cursor-wait"
|
? "bg-amber-400/20 text-amber-200 cursor-wait"
|
||||||
: "bg-amber-400 text-black hover:bg-amber-300"
|
: "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>
|
||||||
|
|
||||||
<button
|
{/* Dropdown Menu */}
|
||||||
type="button"
|
<div className="relative">
|
||||||
onClick={() => handleRunFullImport(m.id)}
|
<button
|
||||||
disabled={importingId === m.id}
|
type="button"
|
||||||
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 ${
|
onClick={() =>
|
||||||
importingId === m.id ? "opacity-60 cursor-wait" : ""
|
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"
|
||||||
{importingId === m.id ? "Importing…" : "Run Full Import"}
|
title="More actions"
|
||||||
</button>
|
>
|
||||||
|
<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
|
{openDropdown === m.id && (
|
||||||
type="button"
|
<>
|
||||||
onClick={() => handleRunOfferSync(m.id)}
|
<div
|
||||||
disabled={offerSyncId === m.id}
|
className="fixed inset-0 z-10"
|
||||||
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 ${
|
onClick={() => setOpenDropdown(null)}
|
||||||
offerSyncId === m.id ? "opacity-60 cursor-wait" : ""
|
/>
|
||||||
}`}
|
<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">
|
||||||
{offerSyncId === m.id ? "Syncing Offers…" : "Run Offer Sync"}
|
<button
|
||||||
</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>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -46,9 +46,10 @@ export default function AdminLeftNavigation({
|
|||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
className={cx(
|
className={cx(
|
||||||
"sticky top-0 flex h-screen shrink-0 flex-col border-r border-zinc-900 bg-zinc-950/60 backdrop-blur",
|
"fixed left-0 flex shrink-0 flex-col border-r border-zinc-900 bg-zinc-950/60 backdrop-blur z-40",
|
||||||
collapsed ? "w-[68px]" : "w-[280px]"
|
collapsed ? "w-[68px]" : "w-[280px]"
|
||||||
)}
|
)}
|
||||||
|
style={{ top: '52px', height: 'calc(100vh - 52px)' }}
|
||||||
>
|
>
|
||||||
{/* Header / Brand */}
|
{/* Header / Brand */}
|
||||||
<div className={cx("flex items-center justify-between px-3 py-3", collapsed && "justify-center")}>
|
<div className={cx("flex items-center justify-between px-3 py-3", collapsed && "justify-center")}>
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ import React from "react";
|
|||||||
|
|
||||||
export function Banner() {
|
export function Banner() {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="fixed top-0 left-0 right-0 z-50">
|
||||||
{/* Early access bar */}
|
{/* Early access bar */}
|
||||||
<div className="border-b border-amber-500/20 bg-amber-500/5">
|
<div className="border-b border-amber-500/20 bg-amber-500/5 backdrop-blur-sm">
|
||||||
<div className="mx-auto flex max-w-6xl flex-col gap-1 px-4 py-2 text-[0.75rem] text-amber-100 md:flex-row md:items-center md:justify-between">
|
<div className="mx-auto flex max-w-6xl flex-col gap-1 px-4 py-2 text-[0.75rem] text-amber-100 md:flex-row md:items-center md:justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="rounded-sm bg-amber-500/20 px-2 py-0.5 text-[0.65rem] font-semibold uppercase tracking-[0.18em] text-amber-300">
|
<span className="rounded-sm bg-amber-500/20 px-2 py-0.5 text-[0.65rem] font-semibold uppercase tracking-[0.18em] text-amber-300">
|
||||||
|
|||||||
@@ -303,7 +303,7 @@ export default function PartsBrowseClient(props: {
|
|||||||
end: Math.min(currentPage * PAGE_SIZE, serverTotalElements),
|
end: Math.min(currentPage * PAGE_SIZE, serverTotalElements),
|
||||||
};
|
};
|
||||||
|
|
||||||
const headingTitle = props.title ?? `${partRole.replaceAll("-", " ")} parts`;
|
const headingTitle = props.title ?? `${partRole.replaceAll("-", " ")}s`;
|
||||||
const headingSubtitle = props.subtitle ?? "Browse available parts.";
|
const headingSubtitle = props.subtitle ?? "Browse available parts.";
|
||||||
|
|
||||||
// ----------------------------
|
// ----------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user