Files
route-commerce/src/components/admin/MessageLogPanel.tsx
T

498 lines
23 KiB
TypeScript

"use client";
/* eslint-disable react-hooks/set-state-in-effect */
import { useState, useEffect, useCallback, useRef } from "react";
import { getMessageLogs, type MessageLogEntry } from "@/actions/communications/send";
import { formatDate } from "@/lib/format-date";
import AdminButton from "./design-system/AdminButton";
const PAGE_SIZE = 20;
// 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>
),
send: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="22" y1="2" x2="11" y2="13"/>
<polygon points="22 2 15 22 11 13 2 9 22 2"/>
</svg>
),
mail: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/>
<polyline points="22,6 12,13 2,6"/>
</svg>
),
clock: (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"/>
<polyline points="12 6 12 12 16 14"/>
</svg>
),
};
// Status color mapping
const STATUS_COLORS: Record<string, { bg: string; text: string; dot: string }> = {
queued: { bg: "bg-stone-100", text: "text-stone-600", dot: "bg-stone-400" },
sent: { bg: "bg-blue-100", text: "text-blue-700", dot: "bg-blue-500" },
delivered: { bg: "bg-emerald-100", text: "text-emerald-700", dot: "bg-emerald-500" },
opened: { bg: "bg-purple-100", text: "text-purple-700", dot: "bg-purple-500" },
clicked: { bg: "bg-amber-100", text: "text-amber-700", dot: "bg-amber-500" },
bounced: { bg: "bg-red-100", text: "text-red-600", dot: "bg-red-500" },
failed: { bg: "bg-red-100", text: "text-red-600", dot: "bg-red-500" },
unsubscribed: { bg: "bg-orange-100", text: "text-orange-700", dot: "bg-orange-500" },
};
// Method color mapping
const METHOD_COLORS: Record<string, { bg: string; text: string; dot: string }> = {
email: { bg: "bg-blue-100", text: "text-blue-700", dot: "bg-blue-500" },
sms: { bg: "bg-green-100", text: "text-green-700", dot: "bg-green-500" },
push: { bg: "bg-amber-100", text: "text-amber-700", dot: "bg-amber-500" },
internal: { bg: "bg-stone-100", text: "text-stone-600", dot: "bg-stone-400" },
};
// Empty state component
function LogsEmptyState({ hasFilters }: { hasFilters: boolean }) {
return (
<div className="text-center py-16 px-4">
<div className="w-20 h-20 mx-auto mb-4 rounded-2xl bg-gradient-to-br from-stone-100 to-stone-50 flex items-center justify-center">
<svg className="w-10 h-10 text-stone-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" 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>
</div>
<h3 className="text-lg font-semibold text-stone-800">
{hasFilters ? "No messages match your filters" : "No message logs yet"}
</h3>
<p className="text-sm text-stone-500 mt-1 max-w-sm mx-auto">
{hasFilters
? "Try adjusting your search or status filter to find messages."
: "Send your first campaign to start tracking message delivery and engagement."}
</p>
</div>
);
}
// Loading skeleton (rendered as a div so it doesn't get parsed as table rows)
function LogSkeleton() {
return (
<div aria-live="polite" aria-busy="true" aria-label="Loading messages" className="overflow-hidden rounded-xl border border-[var(--admin-border)] bg-white">
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className="flex items-center gap-4 border-b border-[var(--admin-border)] px-4 py-3 last:border-b-0">
<div className="h-3 w-20 bg-stone-200 rounded animate-pulse" />
<div className="h-3 w-32 bg-stone-200 rounded animate-pulse" />
<div className="h-5 w-14 bg-stone-200 rounded-full animate-pulse" />
<div className="h-3 w-40 bg-stone-200 rounded animate-pulse" />
<div className="h-5 w-20 bg-stone-200 rounded-full animate-pulse" />
<div className="h-5 w-24 bg-stone-200 rounded-full animate-pulse" />
</div>
))}
</div>
);
}
// Status badge component
function StatusBadge({ status }: { status: string }) {
const colors = STATUS_COLORS[status] || STATUS_COLORS.queued;
return (
<span className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-semibold capitalize ${colors.bg} ${colors.text}`}>
<span className={`w-1.5 h-1.5 rounded-full ${colors.dot}`} />
{status}
</span>
);
}
// Method badge component
function MethodBadge({ method }: { method: string }) {
const colors = METHOD_COLORS[method] || METHOD_COLORS.internal;
return (
<span className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-semibold capitalize ${colors.bg} ${colors.text}`}>
<span className={`w-1.5 h-1.5 rounded-full ${colors.dot}`} />
{method}
</span>
);
}
// Engagement indicator
function EngagementIndicator({ log }: { log: MessageLogEntry }) {
const hasEngagement = log.delivered_at || log.opened_at || log.clicked_at || log.bounced_at;
if (!hasEngagement) {
return <span className="text-xs text-stone-400">No engagement yet</span>;
}
return (
<div className="flex items-center gap-1.5">
{log.delivered_at && (
<span className="inline-flex items-center gap-1 text-xs text-emerald-600 font-medium" title="Delivered">
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
<polyline points="20 6 9 17 4 12"/>
</svg>
Delivered
</span>
)}
{log.opened_at && (
<span className="inline-flex items-center gap-1 text-xs text-purple-600 font-medium" title="Opened">
{Icons.eye("w-3.5 h-3.5")}
Opened
</span>
)}
{log.clicked_at && (
<span className="inline-flex items-center gap-1 text-xs text-amber-600 font-medium" title="Clicked">
{Icons.mousePointer("w-3.5 h-3.5")}
Clicked
</span>
)}
{log.bounced_at && (
<span className="inline-flex items-center gap-1 text-xs text-red-600 font-medium" title="Bounced">
{Icons.x("w-3.5 h-3.5")}
Bounced
</span>
)}
</div>
);
}
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);
// Whether a fetch is currently in flight. Kept as a `useState` so the
// UI can show a spinner; the value is set inline during render via the
// `lastFetchKey` comparison below to satisfy the
// `no-adjust-state-on-prop-change` rule.
const [isLoading, setIsLoading] = useState(false);
// Track the last (brandId|statusFilter) signature we kicked off a
// fetch for. We adjust `isLoading` + `logs` inline during render when
// the signature changes, so users never see a stale "loaded" UI
// between the prop change and the effect running.
const lastFetchKeyRef = useRef<string | null>(null);
const fetchKey = brandId ? `${brandId}|${statusFilter}` : null;
if (fetchKey !== lastFetchKeyRef.current) {
lastFetchKeyRef.current = fetchKey;
setIsLoading(Boolean(fetchKey));
setLogs([]);
}
const loadLogs = useCallback(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);
}, [brandId, statusFilter]);
useEffect(() => {
if (!brandId) return;
void loadLogs();
}, [loadLogs, brandId]);
// 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 = useCallback(() => {
setPage(1);
// Re-trigger the data load by resetting the fetch key — the inline
// `if (fetchKey !== lastFetchKeyRef.current)` block will re-run on
// the next render and reset isLoading + logs.
lastFetchKeyRef.current = null;
void loadLogs();
}, [loadLogs]);
const hasFilters = search.length > 0 || statusFilter !== "all";
return (
<div className="p-4 sm:p-6">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6">
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-gradient-to-br from-emerald-500 to-emerald-600 shadow-lg shadow-emerald-500/20">
{Icons.messageSquare("h-6 h-6 text-white")}
</div>
<div>
<h2 className="text-lg font-bold text-stone-900">Message Logs</h2>
<p className="text-sm text-stone-500">
{isLoading ? "Loading..." : `${filteredLogs.length} message${filteredLogs.length !== 1 ? "s" : ""}`}
</p>
</div>
</div>
<AdminButton variant="secondary" onClick={handleRefresh} disabled={isLoading} icon={Icons.refresh("w-4 h-4" + (isLoading ? " animate-spin" : ""))}>
Refresh
</AdminButton>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-6">
<div className="bg-white rounded-xl border border-stone-200 p-4 relative overflow-hidden">
<div className="absolute top-0 right-0 w-12 h-12 bg-stone-50 rounded-bl-full" />
<p className="text-xs font-semibold text-stone-500 uppercase tracking-wider">Total</p>
<p className="text-2xl font-bold text-stone-800 mt-1">{stats.total}</p>
</div>
<div className="bg-white rounded-xl border border-stone-200 p-4 relative overflow-hidden">
<div className="absolute top-0 right-0 w-12 h-12 bg-emerald-50 rounded-bl-full" />
<p className="text-xs font-semibold text-stone-500 uppercase tracking-wider">Delivered</p>
<p className="text-2xl font-bold text-emerald-600 mt-1">{stats.delivered}</p>
</div>
<div className="bg-white rounded-xl border border-stone-200 p-4 relative overflow-hidden">
<div className="absolute top-0 right-0 w-12 h-12 bg-red-50 rounded-bl-full" />
<p className="text-xs font-semibold text-stone-500 uppercase tracking-wider">Failed</p>
<p className="text-2xl font-bold text-red-600 mt-1">{stats.failed}</p>
</div>
<div className="bg-white rounded-xl border border-stone-200 p-4 relative overflow-hidden">
<div className="absolute top-0 right-0 w-12 h-12 bg-blue-50 rounded-bl-full" />
<p className="text-xs font-semibold text-stone-500 uppercase tracking-wider">Pending</p>
<p className="text-2xl font-bold text-blue-600 mt-1">{stats.pending}</p>
</div>
</div>
{/* Filters */}
<div className="flex flex-col sm:flex-row gap-3 mb-6">
<div className="relative flex-1">
<div className="absolute inset-y-0 left-3.5 flex items-center pointer-events-none">
{Icons.search("h-5 w-5 text-stone-400")}
</div>
<input aria-label="Search By Email Or Subject..."
type="text"
placeholder="Search by email or subject..."
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPage(1);
}}
className="w-full pl-11 pr-4 py-2.5 text-sm border border-stone-200 rounded-xl bg-white text-stone-900 placeholder:text-stone-400 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-transparent transition-all"
/>
</div>
<select aria-label="Select"
value={statusFilter}
onChange={(e) => {
setStatusFilter(e.target.value);
setPage(1);
}}
className="px-4 py-2.5 text-sm border border-stone-200 rounded-xl bg-white text-stone-900 focus:outline-none focus:ring-2 focus:ring-emerald-500 transition-all"
>
<option value="all">All Statuses</option>
<option value="queued">Queued</option>
<option value="sent">Sent</option>
<option value="delivered">Delivered</option>
<option value="opened">Opened</option>
<option value="clicked">Clicked</option>
<option value="bounced">Bounced</option>
<option value="failed">Failed</option>
</select>
</div>
{/* Table */}
{isLoading ? (
<div className="overflow-hidden rounded-xl border border-stone-200">
<table className="w-full text-sm">
<thead className="bg-stone-50 border-b border-stone-200">
<tr>
<th className="text-left px-4 py-3.5 font-semibold text-stone-500 text-xs uppercase tracking-wider">Sent At</th>
<th className="text-left px-4 py-3.5 font-semibold text-stone-500 text-xs uppercase tracking-wider">Recipient</th>
<th className="text-left px-4 py-3.5 font-semibold text-stone-500 text-xs uppercase tracking-wider">Method</th>
<th className="text-left px-4 py-3.5 font-semibold text-stone-500 text-xs uppercase tracking-wider">Subject</th>
<th className="text-left px-4 py-3.5 font-semibold text-stone-500 text-xs uppercase tracking-wider">Status</th>
<th className="text-left px-4 py-3.5 font-semibold text-stone-500 text-xs uppercase tracking-wider">Engagement</th>
</tr>
</thead>
{isLoading ? (
<tbody className="bg-white" aria-busy="true"><tr><td colSpan={6}><LogSkeleton /></td></tr></tbody>
) : null}
</table>
</div>
) : paginatedLogs.length === 0 ? (
<div className="overflow-hidden rounded-xl border border-stone-200">
<LogsEmptyState hasFilters={hasFilters} />
</div>
) : (
<>
{/* Desktop table */}
<div className="hidden sm:block overflow-hidden rounded-xl border border-stone-200">
<table className="w-full text-sm">
<thead className="bg-stone-50 border-b border-stone-200">
<tr>
<th className="text-left px-4 py-3.5 font-semibold text-stone-500 text-xs uppercase tracking-wider">Sent At</th>
<th className="text-left px-4 py-3.5 font-semibold text-stone-500 text-xs uppercase tracking-wider">Recipient</th>
<th className="text-left px-4 py-3.5 font-semibold text-stone-500 text-xs uppercase tracking-wider">Method</th>
<th className="text-left px-4 py-3.5 font-semibold text-stone-500 text-xs uppercase tracking-wider">Subject</th>
<th className="text-left px-4 py-3.5 font-semibold text-stone-500 text-xs uppercase tracking-wider">Status</th>
<th className="text-left px-4 py-3.5 font-semibold text-stone-500 text-xs uppercase tracking-wider">Engagement</th>
</tr>
</thead>
<tbody className="divide-y divide-stone-100 bg-white">
{paginatedLogs.map((log: MessageLogEntry) => (
<tr key={log.id} className="hover:bg-stone-50 transition-colors">
<td className="px-4 py-3.5 text-stone-500 text-xs whitespace-nowrap">
{log.sent_at ? formatDate(log.sent_at) : "—"}
</td>
<td className="px-4 py-3.5 text-stone-800 text-sm font-medium">
{log.customer_email ?? "—"}
</td>
<td className="px-4 py-3.5">
<MethodBadge method={log.delivery_method} />
</td>
<td className="px-4 py-3.5 text-stone-700 text-sm truncate max-w-[180px]" title={log.subject ?? undefined}>
{log.subject ?? "—"}
</td>
<td className="px-4 py-3.5">
<div className="flex flex-col gap-1">
<StatusBadge status={log.status} />
{log.error_message && (
<p className="text-[10px] text-red-600 mt-0.5 truncate max-w-[120px]" title={log.error_message}>
{log.error_message}
</p>
)}
</div>
</td>
<td className="px-4 py-3.5">
<EngagementIndicator log={log} />
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Mobile cards */}
<div className="sm:hidden space-y-3">
{paginatedLogs.map((log: MessageLogEntry) => (
<div key={log.id} className="rounded-xl border border-stone-200 bg-white p-4 space-y-3">
<div className="flex items-start justify-between gap-2">
<div>
<div className="text-sm font-semibold text-stone-800">{log.customer_email ?? "—"}</div>
<div className="text-xs text-stone-500 mt-0.5 truncate max-w-[200px]">{log.subject ?? "—"}</div>
</div>
<StatusBadge status={log.status} />
</div>
<div className="flex flex-wrap items-center gap-2">
<MethodBadge method={log.delivery_method} />
<span className="text-xs text-stone-500">
{log.sent_at ? formatDate(log.sent_at) : "—"}
</span>
</div>
<EngagementIndicator log={log} />
</div>
))}
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between mt-6 pt-4 border-t border-stone-100">
<p className="text-sm text-stone-500">
Showing {(page - 1) * PAGE_SIZE + 1} to {Math.min(page * PAGE_SIZE, filteredLogs.length)} of {filteredLogs.length}
</p>
<div className="flex gap-1">
<button type="button"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
className="px-4 py-2 text-sm border border-stone-200 rounded-xl bg-white text-stone-700 hover:bg-stone-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
aria-label="Previous">
Previous
</button>
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
const pageNum = i + 1;
return (
<button type="button"
key={pageNum}
onClick={() => setPage(pageNum)}
className={`px-4 py-2 text-sm border rounded-xl transition-colors ${
page === pageNum
? "bg-emerald-600 text-white border-emerald-600"
: "bg-white text-stone-700 border-stone-200 hover:bg-stone-50"
}`}
>
{pageNum}
</button>
);
})}
<button type="button"
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
className="px-4 py-2 text-sm border border-stone-200 rounded-xl bg-white text-stone-700 hover:bg-stone-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
aria-label="Next">
Next
</button>
</div>
</div>
)}
</>
)}
</div>
);
}