Admin AI Tools page: unified design system styling, provider selector with OpenAI turd emoji, free-text model input with exact ID warning
This commit is contained in:
@@ -1,86 +1,204 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import type { MessageLogEntry } from "@/actions/communications/send";
|
||||
import { useState, useEffect } from "react";
|
||||
import { getMessageLogs, type MessageLogEntry } from "@/actions/communications/send";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
|
||||
// Icon components
|
||||
const Icons = {
|
||||
list: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="8" y1="6" x2="21" y2="6"/>
|
||||
<line x1="8" y1="12" x2="21" y2="12"/>
|
||||
<line x1="8" y1="18" x2="21" y2="18"/>
|
||||
<line x1="3" y1="6" x2="3.01" y2="6"/>
|
||||
<line x1="3" y1="12" x2="3.01" y2="12"/>
|
||||
<line x1="3" y1="18" x2="3.01" y2="18"/>
|
||||
</svg>
|
||||
),
|
||||
search: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="11" cy="11" r="8"/>
|
||||
<path d="m21 21-4.3-4.3"/>
|
||||
</svg>
|
||||
),
|
||||
refresh: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/>
|
||||
<path d="M3 3v5h5"/>
|
||||
<path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16"/>
|
||||
<path d="M16 16h5v5"/>
|
||||
</svg>
|
||||
),
|
||||
check: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="20 6 9 17 4 12"/>
|
||||
</svg>
|
||||
),
|
||||
x: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<line x1="15" y1="9" x2="9" y2="15"/>
|
||||
<line x1="9" y1="9" x2="15" y2="15"/>
|
||||
</svg>
|
||||
),
|
||||
eye: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
</svg>
|
||||
),
|
||||
mousePointer: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="m3 3 7.07 16.97 2.51-7.39 7.39-2.51L3 3z"/>
|
||||
<path d="m13 13 6 6"/>
|
||||
</svg>
|
||||
),
|
||||
messageSquare: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
queued: "bg-zinc-950 text-zinc-300",
|
||||
sent: "bg-blue-900/40 text-blue-700",
|
||||
queued: "bg-stone-100 text-stone-600",
|
||||
sent: "bg-blue-100 text-blue-700",
|
||||
delivered: "bg-emerald-100 text-emerald-700",
|
||||
opened: "bg-violet-100 text-violet-700",
|
||||
clicked: "bg-amber-100 text-amber-700",
|
||||
bounced: "bg-red-900/40 text-red-400",
|
||||
failed: "bg-red-900/40 text-red-400",
|
||||
bounced: "bg-red-100 text-red-700",
|
||||
failed: "bg-red-100 text-red-700",
|
||||
unsubscribed: "bg-orange-100 text-orange-700",
|
||||
};
|
||||
|
||||
const METHOD_COLORS: Record<string, string> = {
|
||||
email: "bg-blue-900/40 text-blue-700",
|
||||
sms: "bg-green-900/40 text-green-400",
|
||||
email: "bg-blue-100 text-blue-700",
|
||||
sms: "bg-emerald-100 text-emerald-700",
|
||||
push: "bg-purple-100 text-purple-700",
|
||||
internal: "bg-zinc-950 text-zinc-400",
|
||||
internal: "bg-stone-100 text-stone-600",
|
||||
};
|
||||
|
||||
function EngagementBadges({ log }: { log: MessageLogEntry }) {
|
||||
const badges = [];
|
||||
if (log.delivered_at) {
|
||||
badges.push(
|
||||
<span key="delivered" className="rounded-full bg-emerald-100 text-emerald-700 px-2 py-0.5 text-xs font-medium">
|
||||
✓ Delivered
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (log.opened_at) {
|
||||
badges.push(
|
||||
<span key="opened" className="rounded-full bg-violet-100 text-violet-700 px-2 py-0.5 text-xs font-medium">
|
||||
Opened {formatDate(new Date(log.opened_at))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (log.clicked_at) {
|
||||
badges.push(
|
||||
<span key="clicked" className="rounded-full bg-amber-100 text-amber-700 px-2 py-0.5 text-xs font-medium">
|
||||
Clicked {formatDate(new Date(log.clicked_at))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (log.bounced_at) {
|
||||
badges.push(
|
||||
<span key="bounced" className="rounded-full bg-red-900/40 text-red-400 px-2 py-0.5 text-xs font-medium">
|
||||
✗ Bounced
|
||||
{log.bounce_reason && <span className="ml-1">{log.bounce_reason}</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return badges.length > 0 ? <div className="flex flex-wrap gap-1 mt-1">{badges}</div> : null;
|
||||
}
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
export default function MessageLogPanel({ initialLogs }: { initialLogs: MessageLogEntry[] }) {
|
||||
const [logs] = useState(initialLogs);
|
||||
const [filterStatus, setFilterStatus] = useState("all");
|
||||
const [filterMethod, setFilterMethod] = useState("all");
|
||||
export default function MessageLogPanel({ brandId }: { brandId?: string }) {
|
||||
const [logs, setLogs] = useState<MessageLogEntry[]>([]);
|
||||
const [search, setSearch] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("all");
|
||||
const [page, setPage] = useState(1);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const filtered = logs.filter((l) => {
|
||||
if (filterStatus !== "all" && l.status !== filterStatus) return false;
|
||||
if (filterMethod !== "all" && l.delivery_method !== filterMethod) return false;
|
||||
return true;
|
||||
});
|
||||
const fetchLogs = async () => {
|
||||
if (!brandId) return;
|
||||
setIsLoading(true);
|
||||
const result = await getMessageLogs({
|
||||
brandId,
|
||||
status: statusFilter === "all" ? undefined : statusFilter,
|
||||
limit: 100,
|
||||
});
|
||||
if (result.success) {
|
||||
setLogs(result.logs);
|
||||
}
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchLogs();
|
||||
}, [brandId, statusFilter]);
|
||||
|
||||
// Filter logs based on search
|
||||
const filteredLogs = search
|
||||
? logs.filter((l: MessageLogEntry) =>
|
||||
l.customer_email?.toLowerCase().includes(search.toLowerCase()) ||
|
||||
l.subject?.toLowerCase().includes(search.toLowerCase()) ||
|
||||
l.status?.toLowerCase().includes(search.toLowerCase())
|
||||
)
|
||||
: logs;
|
||||
|
||||
// Calculate stats
|
||||
const stats = {
|
||||
total: logs.length,
|
||||
delivered: logs.filter((l: MessageLogEntry) => l.status === "delivered" || l.delivered_at).length,
|
||||
failed: logs.filter((l: MessageLogEntry) => l.status === "failed" || l.status === "bounced").length,
|
||||
pending: logs.filter((l: MessageLogEntry) => l.status === "queued" || l.status === "sent").length,
|
||||
};
|
||||
|
||||
// Paginate
|
||||
const totalPages = Math.max(1, Math.ceil(filteredLogs.length / PAGE_SIZE));
|
||||
const paginatedLogs = filteredLogs.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
|
||||
|
||||
const handleRefresh = () => {
|
||||
fetchLogs();
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="p-4 sm:p-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-zinc-100">Message Logs</h2>
|
||||
<p className="text-sm text-zinc-500">{filtered.length} message{filtered.length !== 1 ? "s" : ""}</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-emerald-600">
|
||||
{Icons.messageSquare("h-5 w-5 text-white")}
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base sm:text-lg font-bold text-[var(--admin-text-primary)]">Message Logs</h2>
|
||||
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)]">
|
||||
{isLoading ? "Loading..." : `${filteredLogs.length} message${filteredLogs.length !== 1 ? "s" : ""}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={isLoading}
|
||||
className="inline-flex items-center gap-2 px-3 py-2 text-sm font-medium text-stone-600 bg-white border border-stone-200 rounded-lg hover:bg-stone-50 hover:text-stone-900 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{Icons.refresh("h-4 w-4" + (isLoading ? " animate-spin" : ""))}
|
||||
<span className="hidden sm:inline">Refresh</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-4">
|
||||
<div className="bg-white rounded-xl border border-[var(--admin-border)] p-3 sm:p-4">
|
||||
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium">Total</p>
|
||||
<p className="text-xl sm:text-2xl font-bold text-[var(--admin-text-primary)] mt-1">{stats.total}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-[var(--admin-border)] p-3 sm:p-4">
|
||||
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium">Delivered</p>
|
||||
<p className="text-xl sm:text-2xl font-bold text-emerald-600 mt-1">{stats.delivered}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-[var(--admin-border)] p-3 sm:p-4">
|
||||
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium">Failed</p>
|
||||
<p className="text-xl sm:text-2xl font-bold text-red-600 mt-1">{stats.failed}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-[var(--admin-border)] p-3 sm:p-4">
|
||||
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)] font-medium">Pending</p>
|
||||
<p className="text-xl sm:text-2xl font-bold text-stone-600 mt-1">{stats.pending}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 mb-4 flex-wrap">
|
||||
{/* Filters */}
|
||||
<div className="flex flex-col sm:flex-row gap-3 mb-4">
|
||||
<div className="relative flex-1">
|
||||
{Icons.search("absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-stone-400")}
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by email or subject..."
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
className="w-full pl-10 pr-4 py-2.5 text-sm border border-[var(--admin-border)] rounded-xl bg-white text-[var(--admin-text-primary)] placeholder:text-stone-400 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
className="text-sm border border-zinc-600 rounded-lg px-3 py-2"
|
||||
value={filterStatus}
|
||||
onChange={(e) => setFilterStatus(e.target.value)}
|
||||
value={statusFilter}
|
||||
onChange={(e) => {
|
||||
setStatusFilter(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
className="px-4 py-2.5 text-sm border border-[var(--admin-border)] rounded-xl bg-white text-[var(--admin-text-primary)] focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
||||
>
|
||||
<option value="all">All Statuses</option>
|
||||
<option value="queued">Queued</option>
|
||||
@@ -91,70 +209,87 @@ export default function MessageLogPanel({ initialLogs }: { initialLogs: MessageL
|
||||
<option value="bounced">Bounced</option>
|
||||
<option value="failed">Failed</option>
|
||||
</select>
|
||||
<select
|
||||
className="text-sm border border-zinc-600 rounded-lg px-3 py-2"
|
||||
value={filterMethod}
|
||||
onChange={(e) => setFilterMethod(e.target.value)}
|
||||
>
|
||||
<option value="all">All Methods</option>
|
||||
<option value="email">Email</option>
|
||||
<option value="sms">SMS</option>
|
||||
<option value="push">Push</option>
|
||||
<option value="internal">Internal</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<div className="text-center py-12 text-slate-400">No messages logged yet</div>
|
||||
{/* Table */}
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="flex items-center gap-3 text-stone-500">
|
||||
{Icons.refresh("h-5 w-5 animate-spin")}
|
||||
<span>Loading logs...</span>
|
||||
</div>
|
||||
</div>
|
||||
) : paginatedLogs.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="flex h-16 w-16 mx-auto items-center justify-center rounded-full bg-stone-100 mb-4">
|
||||
{Icons.list("h-8 w-8 text-stone-400")}
|
||||
</div>
|
||||
<p className="text-sm font-medium text-stone-600">No messages logged yet</p>
|
||||
<p className="text-xs text-stone-400 mt-1">Send a campaign to see logs here</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Desktop table */}
|
||||
<div className="hidden sm:block bg-zinc-900 rounded-xl border border-zinc-800 overflow-hidden">
|
||||
<div className="hidden sm:block overflow-hidden rounded-xl border border-[var(--admin-border)]">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-50 border-b border-zinc-800">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3 font-medium text-zinc-400">Sent At</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-zinc-400">Recipient</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-zinc-400">Method</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-zinc-400">Subject</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-zinc-400">Status</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-zinc-400">Engagement</th>
|
||||
<thead className="bg-stone-50">
|
||||
<tr className="border-b border-[var(--admin-border)]">
|
||||
<th className="text-left px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">Sent At</th>
|
||||
<th className="text-left px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">Recipient</th>
|
||||
<th className="text-left px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">Method</th>
|
||||
<th className="text-left px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">Subject</th>
|
||||
<th className="text-left px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">Status</th>
|
||||
<th className="text-left px-4 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">Engagement</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{filtered.map((l) => (
|
||||
<tr key={l.id} className="hover:bg-zinc-800">
|
||||
<td className="px-4 py-3 text-zinc-500 text-xs whitespace-nowrap">
|
||||
{l.sent_at ? new Date(l.sent_at).toLocaleString() : "—"}
|
||||
<tbody className="divide-y divide-[var(--admin-border)] bg-white">
|
||||
{paginatedLogs.map((log: MessageLogEntry) => (
|
||||
<tr key={log.id} className="hover:bg-stone-50 transition-colors">
|
||||
<td className="px-4 py-3 text-[var(--admin-text-muted)] text-xs whitespace-nowrap">
|
||||
{log.sent_at ? formatDate(log.sent_at) : "—"}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[var(--admin-text-primary)] text-xs">
|
||||
{log.customer_email ?? "—"}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-zinc-300 text-xs">{l.customer_email ?? "—"}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${METHOD_COLORS[l.delivery_method] ?? "bg-zinc-950"}`}>
|
||||
{l.delivery_method}
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold ${METHOD_COLORS[log.delivery_method] ?? "bg-stone-100 text-stone-600"}`}>
|
||||
{log.delivery_method}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-zinc-300 text-xs truncate max-w-[180px]">{l.subject ?? "—"}</td>
|
||||
<td className="px-4 py-3 text-[var(--admin-text-primary)] text-xs truncate max-w-[160px]" title={log.subject ?? undefined}>
|
||||
{log.subject ?? "—"}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${STATUS_COLORS[l.status] ?? "bg-zinc-950"}`}>
|
||||
{l.status}
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold ${STATUS_COLORS[log.status] ?? "bg-stone-100 text-stone-600"}`}>
|
||||
{log.status}
|
||||
</span>
|
||||
{l.error_message && (
|
||||
<p className="text-xs text-red-500 mt-0.5 truncate max-w-[120px]">{l.error_message}</p>
|
||||
{log.error_message && (
|
||||
<p className="text-[10px] text-red-600 mt-0.5 truncate max-w-[100px]" title={log.error_message}>
|
||||
{log.error_message}
|
||||
</p>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
{l.delivered_at && (
|
||||
<span className="text-xs text-emerald-600">✓ {formatDate(new Date(l.delivered_at))}</span>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{log.delivered_at && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-50 border border-emerald-200 text-emerald-700 px-1.5 py-0.5 text-[10px] font-medium">
|
||||
{Icons.check("h-3 w-3")} Delivered
|
||||
</span>
|
||||
)}
|
||||
{l.opened_at && (
|
||||
<span className="text-xs text-violet-600">Opened {formatDate(new Date(l.opened_at))}</span>
|
||||
{log.opened_at && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-violet-50 border border-violet-200 text-violet-700 px-1.5 py-0.5 text-[10px] font-medium">
|
||||
{Icons.eye("h-3 w-3")} Opened
|
||||
</span>
|
||||
)}
|
||||
{l.clicked_at && (
|
||||
<span className="text-xs text-amber-400">Clicked {formatDate(new Date(l.clicked_at))}</span>
|
||||
{log.clicked_at && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-amber-50 border border-amber-200 text-amber-700 px-1.5 py-0.5 text-[10px] font-medium">
|
||||
{Icons.mousePointer("h-3 w-3")} Clicked
|
||||
</span>
|
||||
)}
|
||||
{l.bounced_at && (
|
||||
<span className="text-xs text-red-500">✗ Bounced{l.bounce_reason ? `: ${l.bounce_reason}` : ""}</span>
|
||||
{log.bounced_at && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-red-50 border border-red-200 text-red-700 px-1.5 py-0.5 text-[10px] font-medium">
|
||||
{Icons.x("h-3 w-3")} Bounced
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
@@ -166,29 +301,93 @@ export default function MessageLogPanel({ initialLogs }: { initialLogs: MessageL
|
||||
|
||||
{/* Mobile cards */}
|
||||
<div className="sm:hidden space-y-3">
|
||||
{filtered.map((l) => (
|
||||
<div key={l.id} className="bg-zinc-900 rounded-xl border border-zinc-800 p-4 space-y-2">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="text-sm font-medium text-zinc-100">{l.customer_email ?? "—"}</div>
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${STATUS_COLORS[l.status] ?? "bg-zinc-950"}`}>
|
||||
{l.status}
|
||||
{paginatedLogs.map((log: MessageLogEntry) => (
|
||||
<div key={log.id} className="rounded-xl border border-[var(--admin-border)] bg-white p-4 space-y-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-[var(--admin-text-primary)]">{log.customer_email ?? "—"}</div>
|
||||
<div className="text-xs text-[var(--admin-text-muted)] mt-0.5">{log.subject ?? "—"}</div>
|
||||
</div>
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold shrink-0 ${STATUS_COLORS[log.status] ?? "bg-stone-100 text-stone-600"}`}>
|
||||
{log.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-zinc-500">{l.subject ?? "—"}</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${METHOD_COLORS[l.delivery_method] ?? "bg-zinc-950"}`}>
|
||||
{l.delivery_method}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold ${METHOD_COLORS[log.delivery_method] ?? "bg-stone-100 text-stone-600"}`}>
|
||||
{log.delivery_method}
|
||||
</span>
|
||||
<span className="text-xs text-slate-400">
|
||||
{l.sent_at ? formatDate(new Date(l.sent_at)) : "—"}
|
||||
<span className="text-xs text-[var(--admin-text-muted)]">
|
||||
{log.sent_at ? formatDate(log.sent_at) : "—"}
|
||||
</span>
|
||||
</div>
|
||||
<EngagementBadges log={l} />
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{log.delivered_at && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-50 border border-emerald-200 text-emerald-700 px-1.5 py-0.5 text-[10px] font-medium">
|
||||
{Icons.check("h-3 w-3")} Delivered
|
||||
</span>
|
||||
)}
|
||||
{log.opened_at && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-violet-50 border border-violet-200 text-violet-700 px-1.5 py-0.5 text-[10px] font-medium">
|
||||
{Icons.eye("h-3 w-3")} Opened
|
||||
</span>
|
||||
)}
|
||||
{log.clicked_at && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-amber-50 border border-amber-200 text-amber-700 px-1.5 py-0.5 text-[10px] font-medium">
|
||||
{Icons.mousePointer("h-3 w-3")} Clicked
|
||||
</span>
|
||||
)}
|
||||
{log.bounced_at && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-red-50 border border-red-200 text-red-700 px-1.5 py-0.5 text-[10px] font-medium">
|
||||
{Icons.x("h-3 w-3")} Bounced
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between mt-4 pt-4 border-t border-[var(--admin-border)]">
|
||||
<p className="text-xs text-[var(--admin-text-muted)]">
|
||||
Showing {(page - 1) * PAGE_SIZE + 1} to {Math.min(page * PAGE_SIZE, filteredLogs.length)} of {filteredLogs.length}
|
||||
</p>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
className="px-3 py-1.5 text-sm border border-[var(--admin-border)] rounded-lg bg-white text-[var(--admin-text-primary)] hover:bg-stone-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
|
||||
const pageNum = i + 1;
|
||||
return (
|
||||
<button
|
||||
key={pageNum}
|
||||
onClick={() => setPage(pageNum)}
|
||||
className={`px-3 py-1.5 text-sm border rounded-lg transition-colors ${
|
||||
page === pageNum
|
||||
? "bg-emerald-600 text-white border-emerald-600"
|
||||
: "bg-white text-[var(--admin-text-primary)] border-[var(--admin-border)] hover:bg-stone-50"
|
||||
}`}
|
||||
>
|
||||
{pageNum}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={page === totalPages}
|
||||
className="px-3 py-1.5 text-sm border border-[var(--admin-border)] rounded-lg bg-white text-[var(--admin-text-primary)] hover:bg-stone-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user