feat: comprehensive frontend polish - UI/UX improvements across all pages
Public Pages: - Landing page with server/client split and metadata export - Cart page with ARIA accessibility and loading states - Checkout page with form accessibility and proper design system - Contact page with SEO metadata and improved accessibility - Pricing page with enhanced FAQ accessibility - Login page with Suspense boundaries and secure patterns Brand Storefronts: - Premium loading skeletons for Tuxedo and Indian River Direct - Branded error boundaries with animations - Loading.tsx for about, contact, FAQ, stops pages - Error.tsx for all storefront subpages Admin Dashboard: - AdminSidebar: ARIA labels, keyboard navigation, mobile improvements - DashboardClient: Stats cards, quick actions, usage progress - Admin layout: Toast provider integration Admin Orders/Products/Stops: - Toast notification system with auto-dismiss - Skeleton loading components (Table, Card, Stats, Form) - Bulk actions (mark picked up, publish stops) - Form validation with error styling Admin Communications: - AnalyticsDashboard: sparklines, stat cards, engagement badges - CampaignComposerPage: step wizard, template selection, email preview - SegmentBuilderPage: empty state, active segment handling - ContactListPanel: loading skeletons, professional empty states - MessageLogPanel: stats cards, engagement indicators - HarvestReachNav: branded tab navigation - All pages: metadata exports and loading.tsx Admin Settings: - SquareSyncSettingsClient: design system colors, save/cancel UX - ShippingSettingsForm: validation, dirty state tracking - Integrations page: proper layout with AI and communications sections - Billing: improved plan comparison, add-on cards - PaymentSettings: toggle components, validation Wholesale Portal: - Portal: loading skeletons, quantity stepper, search/filter - Login/Register: FormField validation, success states - Success/Cancel pages: animated checkmarks - Employee portal: skeletons, empty states, mobile responsive Water Log: - FieldClient: loading step, progress indicator, spinner - AdminClient: loading skeletons - Admin pages: loading.tsx files New Components: - Toast.tsx/ToastContainer.tsx: comprehensive notification system - Skeleton.tsx: shimmer loading components - AdminToggle.tsx: consistent toggle/switch - CommunicationsLoading.tsx: loading skeleton for comms - ToastExport.ts: exports CSS Improvements: - Shimmer animation keyframes - Toast slide-in animation Accessibility: - ARIA labels throughout - Keyboard navigation - Focus states - Semantic HTML - Screen reader support
This commit is contained in:
@@ -3,7 +3,9 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { getMessageLogs, type MessageLogEntry } from "@/actions/communications/send";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import AdminBadge from "./design-system/AdminBadge";
|
||||
import { AdminButton } from "./design-system";
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
// Icon components
|
||||
const Icons = {
|
||||
@@ -60,35 +62,158 @@ const Icons = {
|
||||
<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 to badge variant mapping
|
||||
const getStatusBadgeProps = (status: string): { variant: "default" | "success" | "warning" | "danger" | "info"; dot: boolean } => {
|
||||
const map: Record<string, { variant: "default" | "success" | "warning" | "danger" | "info"; dot: boolean }> = {
|
||||
queued: { variant: "default", dot: true },
|
||||
sent: { variant: "info", dot: true },
|
||||
delivered: { variant: "success", dot: true },
|
||||
opened: { variant: "info", dot: true },
|
||||
clicked: { variant: "warning", dot: true },
|
||||
bounced: { variant: "danger", dot: true },
|
||||
failed: { variant: "danger", dot: true },
|
||||
unsubscribed: { variant: "warning", dot: true },
|
||||
};
|
||||
return map[status] ?? { variant: "default", dot: true };
|
||||
// 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 to badge variant mapping
|
||||
const getMethodBadgeProps = (method: string): { variant: "default" | "success" | "warning" | "danger" | "info"; dot: boolean } => {
|
||||
const map: Record<string, { variant: "default" | "success" | "warning" | "danger" | "info"; dot: boolean }> = {
|
||||
email: { variant: "info", dot: true },
|
||||
sms: { variant: "success", dot: true },
|
||||
push: { variant: "warning", dot: true },
|
||||
internal: { variant: "default", dot: true },
|
||||
};
|
||||
return map[method] ?? { variant: "default", dot: true };
|
||||
// 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" },
|
||||
};
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
// 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
|
||||
function LogSkeleton() {
|
||||
return (
|
||||
<>
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<tr key={i} className="border-b border-[var(--admin-border)]">
|
||||
<td className="px-4 py-3">
|
||||
<div className="h-3 w-20 bg-stone-200 rounded animate-pulse" />
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="h-3 w-32 bg-stone-200 rounded animate-pulse" />
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="h-5 w-14 bg-stone-200 rounded-full animate-pulse" />
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="h-3 w-40 bg-stone-200 rounded animate-pulse" />
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="h-5 w-20 bg-stone-200 rounded-full animate-pulse" />
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="h-5 w-24 bg-stone-200 rounded-full animate-pulse" />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// 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[]>([]);
|
||||
@@ -141,55 +266,58 @@ export default function MessageLogPanel({ brandId }: { brandId?: string }) {
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const hasFilters = search.length > 0 || statusFilter !== "all";
|
||||
|
||||
return (
|
||||
<div className="p-4 sm:p-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<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-10 w-10 items-center justify-center rounded-xl bg-emerald-600">
|
||||
{Icons.messageSquare("h-5 w-5 text-white")}
|
||||
<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-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)]">
|
||||
<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>
|
||||
<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>
|
||||
<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-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 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-[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 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-[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 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-[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 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-4">
|
||||
<div className="flex flex-col sm:flex-row gap-3 mb-6">
|
||||
<div className="relative flex-1">
|
||||
{Icons.search("absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-stone-400")}
|
||||
<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
|
||||
type="text"
|
||||
placeholder="Search by email or subject..."
|
||||
@@ -198,7 +326,7 @@ export default function MessageLogPanel({ brandId }: { brandId?: string }) {
|
||||
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"
|
||||
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
|
||||
@@ -207,7 +335,7 @@ export default function MessageLogPanel({ brandId }: { brandId?: string }) {
|
||||
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"
|
||||
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>
|
||||
@@ -222,80 +350,70 @@ export default function MessageLogPanel({ brandId }: { brandId?: string }) {
|
||||
|
||||
{/* 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 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>
|
||||
<tbody className="bg-white">
|
||||
<LogSkeleton />
|
||||
</tbody>
|
||||
</table>
|
||||
</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 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-[var(--admin-border)]">
|
||||
<div className="hidden sm:block overflow-hidden rounded-xl border border-stone-200">
|
||||
<table className="w-full text-sm">
|
||||
<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>
|
||||
<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-[var(--admin-border)] bg-white">
|
||||
<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 text-[var(--admin-text-muted)] text-xs whitespace-nowrap">
|
||||
<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 text-[var(--admin-text-primary)] text-xs">
|
||||
<td className="px-4 py-3.5 text-stone-800 text-sm font-medium">
|
||||
{log.customer_email ?? "—"}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{(() => {
|
||||
const props = getMethodBadgeProps(log.delivery_method);
|
||||
return <AdminBadge variant={props.variant} dot>{log.delivery_method}</AdminBadge>;
|
||||
})()}
|
||||
<td className="px-4 py-3.5">
|
||||
<MethodBadge method={log.delivery_method} />
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[var(--admin-text-primary)] text-xs truncate max-w-[160px]" title={log.subject ?? undefined}>
|
||||
<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">
|
||||
{(() => {
|
||||
const props = getStatusBadgeProps(log.status);
|
||||
return <AdminBadge variant={props.variant} dot>{log.status}</AdminBadge>;
|
||||
})()}
|
||||
{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-wrap gap-1">
|
||||
{log.delivered_at && (
|
||||
<AdminBadge variant="success" dot>Delivered</AdminBadge>
|
||||
)}
|
||||
{log.opened_at && (
|
||||
<AdminBadge variant="info" dot>Opened</AdminBadge>
|
||||
)}
|
||||
{log.clicked_at && (
|
||||
<AdminBadge variant="warning" dot>Clicked</AdminBadge>
|
||||
)}
|
||||
{log.bounced_at && (
|
||||
<AdminBadge variant="danger" dot>Bounced</AdminBadge>
|
||||
<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>
|
||||
@@ -305,55 +423,36 @@ export default function MessageLogPanel({ brandId }: { brandId?: string }) {
|
||||
{/* Mobile cards */}
|
||||
<div className="sm:hidden space-y-3">
|
||||
{paginatedLogs.map((log: MessageLogEntry) => (
|
||||
<div key={log.id} className="rounded-xl border border-[var(--admin-border)] bg-white p-4 space-y-3">
|
||||
<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-[var(--admin-text-primary)]">{log.customer_email ?? "—"}</div>
|
||||
<div className="text-xs text-[var(--admin-text-muted)] mt-0.5">{log.subject ?? "—"}</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>
|
||||
{(() => {
|
||||
const props = getStatusBadgeProps(log.status);
|
||||
return <AdminBadge variant={props.variant} dot>{log.status}</AdminBadge>;
|
||||
})()}
|
||||
<StatusBadge status={log.status} />
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{(() => {
|
||||
const props = getMethodBadgeProps(log.delivery_method);
|
||||
return <AdminBadge variant={props.variant} dot>{log.delivery_method}</AdminBadge>;
|
||||
})()}
|
||||
<span className="text-xs text-[var(--admin-text-muted)]">
|
||||
<MethodBadge method={log.delivery_method} />
|
||||
<span className="text-xs text-stone-500">
|
||||
{log.sent_at ? formatDate(log.sent_at) : "—"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{log.delivered_at && (
|
||||
<AdminBadge variant="success" dot>Delivered</AdminBadge>
|
||||
)}
|
||||
{log.opened_at && (
|
||||
<AdminBadge variant="info" dot>Opened</AdminBadge>
|
||||
)}
|
||||
{log.clicked_at && (
|
||||
<AdminBadge variant="warning" dot>Clicked</AdminBadge>
|
||||
)}
|
||||
{log.bounced_at && (
|
||||
<AdminBadge variant="danger" dot>Bounced</AdminBadge>
|
||||
)}
|
||||
</div>
|
||||
<EngagementIndicator log={log} />
|
||||
</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)]">
|
||||
<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
|
||||
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"
|
||||
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"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
@@ -363,10 +462,10 @@ export default function MessageLogPanel({ brandId }: { brandId?: string }) {
|
||||
<button
|
||||
key={pageNum}
|
||||
onClick={() => setPage(pageNum)}
|
||||
className={`px-3 py-1.5 text-sm border rounded-lg transition-colors ${
|
||||
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-[var(--admin-text-primary)] border-[var(--admin-border)] hover:bg-stone-50"
|
||||
: "bg-white text-stone-700 border-stone-200 hover:bg-stone-50"
|
||||
}`}
|
||||
>
|
||||
{pageNum}
|
||||
@@ -376,7 +475,7 @@ export default function MessageLogPanel({ brandId }: { brandId?: string }) {
|
||||
<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"
|
||||
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"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user