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:
2026-06-02 02:21:11 +00:00
parent 809e0061ca
commit 15e939ad7e
116 changed files with 14991 additions and 5326 deletions
+316 -117
View File
@@ -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>
);
}
}