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:
@@ -2,160 +2,404 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { type CampaignAnalytics } from "@/actions/harvest-reach/campaigns";
|
||||
import { AdminEmptyState } from "@/components/admin/design-system";
|
||||
|
||||
type Props = {
|
||||
analytics: CampaignAnalytics[];
|
||||
};
|
||||
|
||||
type Period = "30" | "90" | "all";
|
||||
type Period = "7" | "30" | "90" | "all";
|
||||
|
||||
function RateBar({ value, color = "bg-emerald-500" }: { value: number; color?: string }) {
|
||||
// Rate bar visualization
|
||||
function RateBar({ value, color = "bg-emerald-500", label }: { value: number; color?: string; label?: string }) {
|
||||
return (
|
||||
<div className="w-full bg-stone-100 rounded-full h-1.5">
|
||||
<div
|
||||
className={`h-1.5 rounded-full ${color}`}
|
||||
style={{ width: `${Math.min(100, value)}%` }}
|
||||
<div className="flex flex-col gap-1">
|
||||
{label && (
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-stone-500">{label}</span>
|
||||
<span className="font-semibold text-[var(--admin-text-primary)]">{value.toFixed(1)}%</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="w-full bg-stone-100 rounded-full h-2 overflow-hidden">
|
||||
<div
|
||||
className={`h-2 rounded-full ${color} transition-all duration-500`}
|
||||
style={{ width: `${Math.min(100, value)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Stat card component
|
||||
function StatCard({
|
||||
label,
|
||||
value,
|
||||
sub,
|
||||
icon,
|
||||
trend,
|
||||
trendUp
|
||||
}: {
|
||||
label: string;
|
||||
value: string | number;
|
||||
sub?: string;
|
||||
icon?: React.ReactNode;
|
||||
trend?: string;
|
||||
trendUp?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-[var(--admin-border)] p-4 sm:p-5 relative overflow-hidden">
|
||||
{/* Background accent */}
|
||||
<div className="absolute top-0 right-0 w-20 h-20 bg-gradient-to-br from-emerald-500/5 to-transparent rounded-bl-full" />
|
||||
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<p className="text-[10px] sm:text-xs font-semibold text-[var(--admin-text-muted)] uppercase tracking-wider">{label}</p>
|
||||
<p className="text-xl sm:text-2xl font-bold text-[var(--admin-text-primary)] mt-1.5">{value}</p>
|
||||
{sub && <p className="text-[10px] sm:text-xs text-stone-400 mt-1">{sub}</p>}
|
||||
{trend && (
|
||||
<div className={`flex items-center gap-1 mt-2 text-xs font-medium ${
|
||||
trendUp ? "text-emerald-600" : "text-red-500"
|
||||
}`}>
|
||||
<svg className={`w-3 h-3 ${trendUp ? "" : "rotate-180"}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 10l7-7m0 0l7 7m-7-7v18" />
|
||||
</svg>
|
||||
{trend}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{icon && (
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-[var(--admin-accent-light)] text-[var(--admin-accent)]">
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Mini sparkline visualization
|
||||
function MiniSparkline({ data, color = "var(--admin-accent)" }: { data: number[]; color?: string }) {
|
||||
if (data.length < 2) return null;
|
||||
|
||||
const max = Math.max(...data);
|
||||
const min = Math.min(...data);
|
||||
const range = max - min || 1;
|
||||
|
||||
const points = data.map((v, i) => {
|
||||
const x = (i / (data.length - 1)) * 100;
|
||||
const y = 100 - ((v - min) / range) * 100;
|
||||
return `${x},${y}`;
|
||||
}).join(" ");
|
||||
|
||||
return (
|
||||
<svg className="w-full h-8" viewBox="0 0 100 100" preserveAspectRatio="none">
|
||||
<polyline
|
||||
points={points}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</div>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({ label, value, sub }: { label: string; value: string | number; sub?: string }) {
|
||||
// Engagement badge
|
||||
function EngagementBadge({ rate }: { rate: number }) {
|
||||
let color = "bg-stone-100 text-stone-500";
|
||||
let label = "Low";
|
||||
|
||||
if (rate >= 40) {
|
||||
color = "bg-emerald-100 text-emerald-700";
|
||||
label = "High";
|
||||
} else if (rate >= 20) {
|
||||
color = "bg-amber-100 text-amber-700";
|
||||
label = "Medium";
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-[var(--admin-border)] p-4 sm:p-5">
|
||||
<p className="text-[10px] sm:text-xs font-medium text-[var(--admin-text-muted)] uppercase tracking-wide">{label}</p>
|
||||
<p className="text-xl sm:text-2xl font-bold text-[var(--admin-text-primary)] mt-1.5">{value}</p>
|
||||
{sub && <p className="text-[10px] sm:text-xs text-stone-400 mt-1">{sub}</p>}
|
||||
</div>
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold ${color}`}>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Date formatter
|
||||
function formatDate(dateStr: string | null): string {
|
||||
if (!dateStr) return "—";
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
|
||||
}
|
||||
|
||||
// Icon components
|
||||
const Icons = {
|
||||
chart: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="18" y1="20" x2="18" y2="10"/>
|
||||
<line x1="12" y1="20" x2="12" y2="4"/>
|
||||
<line x1="6" y1="20" x2="6" y2="14"/>
|
||||
</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>
|
||||
),
|
||||
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>
|
||||
),
|
||||
click: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M15 15l-2 5L9 9l11 4-5 2zm0 0l5 5"/>
|
||||
</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>
|
||||
),
|
||||
alert: (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="12" y1="8" x2="12" y2="12"/>
|
||||
<line x1="12" y1="16" x2="12.01" y2="16"/>
|
||||
</svg>
|
||||
),
|
||||
calendar: (className: string) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/>
|
||||
<line x1="16" y1="2" x2="16" y2="6"/>
|
||||
<line x1="8" y1="2" x2="8" y2="6"/>
|
||||
<line x1="3" y1="10" x2="21" y2="10"/>
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
|
||||
export default function AnalyticsDashboard({ analytics }: Props) {
|
||||
const [period, setPeriod] = useState<Period>("30");
|
||||
|
||||
const totalSent = analytics.reduce((s, a) => s + a.total_sent, 0);
|
||||
const totalDelivered = analytics.reduce((s, a) => s + a.total_delivered, 0);
|
||||
const totalOpened = analytics.reduce((s, a) => s + a.total_opened, 0);
|
||||
const totalClicked = analytics.reduce((s, a) => s + a.total_clicked, 0);
|
||||
// Filter analytics by period
|
||||
const filteredAnalytics = analytics.filter((a) => {
|
||||
if (period === "all") return true;
|
||||
const days = parseInt(period);
|
||||
if (!a.sent_at) return true;
|
||||
const sentDate = new Date(a.sent_at);
|
||||
const cutoff = new Date();
|
||||
cutoff.setDate(cutoff.getDate() - days);
|
||||
return sentDate >= cutoff;
|
||||
});
|
||||
|
||||
// Calculate totals
|
||||
const totalSent = filteredAnalytics.reduce((s, a) => s + a.total_sent, 0);
|
||||
const totalDelivered = filteredAnalytics.reduce((s, a) => s + a.total_delivered, 0);
|
||||
const totalOpened = filteredAnalytics.reduce((s, a) => s + a.total_opened, 0);
|
||||
const totalClicked = filteredAnalytics.reduce((s, a) => s + a.total_clicked, 0);
|
||||
const totalBounced = filteredAnalytics.reduce((s, a) => s + a.total_bounced, 0);
|
||||
|
||||
// Calculate rates
|
||||
const avgOpenRate = totalDelivered > 0 ? (totalOpened / totalDelivered) * 100 : 0;
|
||||
const avgClickRate = totalDelivered > 0 ? (totalClicked / totalDelivered) * 100 : 0;
|
||||
const avgBounceRate = totalSent > 0 ? (totalBounced / totalSent) * 100 : 0;
|
||||
|
||||
// Generate sparkline data from analytics
|
||||
const openRateSparkline = filteredAnalytics.map((a) => Number(a.open_rate));
|
||||
const clickRateSparkline = filteredAnalytics.map((a) => Number(a.click_rate));
|
||||
|
||||
// Campaign count
|
||||
const campaignCount = filteredAnalytics.length;
|
||||
|
||||
// Best performing campaign
|
||||
const bestCampaign = [...filteredAnalytics].sort((a, b) => Number(b.open_rate) - Number(a.open_rate))[0];
|
||||
|
||||
const emptyStateIcon = Icons.chart("w-10 h-10");
|
||||
|
||||
return (
|
||||
<div className="p-4 sm:p-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-emerald-600">
|
||||
<svg className="h-5 w-5 text-white" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="18" y1="20" x2="18" y2="10"/>
|
||||
<line x1="12" y1="20" x2="12" y2="4"/>
|
||||
<line x1="6" y1="20" x2="6" y2="14"/>
|
||||
</svg>
|
||||
<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.chart("h-6 w-6 text-white")}
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg sm:text-xl font-bold text-[var(--admin-text-primary)]">Campaign Analytics</h2>
|
||||
<p className="text-sm text-[var(--admin-text-muted)]">Track your email performance and engagement</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base sm:text-lg font-bold text-[var(--admin-text-primary)]">Campaign Analytics</h2>
|
||||
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)]">Track your campaign performance</p>
|
||||
|
||||
{/* Period selector */}
|
||||
<div className="flex rounded-xl border border-[var(--admin-border)] bg-stone-50 p-1">
|
||||
{(["7", "30", "90", "all"] as const).map((val) => (
|
||||
<button
|
||||
key={val}
|
||||
onClick={() => setPeriod(val as Period)}
|
||||
className={`px-3 py-2 text-xs font-semibold rounded-lg transition-all ${
|
||||
period === val
|
||||
? "bg-white text-[var(--admin-text-primary)] shadow-sm"
|
||||
: "text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)]"
|
||||
}`}
|
||||
>
|
||||
{val === "all" ? "All time" : `${val} days`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary stat cards */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-6">
|
||||
<StatCard label="Total Sent" value={totalSent.toLocaleString()} sub={`${analytics.length} campaign${analytics.length !== 1 ? "s" : ""}`} />
|
||||
<StatCard
|
||||
label="Total Sent"
|
||||
value={totalSent.toLocaleString()}
|
||||
sub={`${campaignCount} campaign${campaignCount !== 1 ? "s" : ""}`}
|
||||
icon={Icons.send("w-5 h-5")}
|
||||
trend={campaignCount > 0 ? `${campaignCount}` : undefined}
|
||||
trendUp={true}
|
||||
/>
|
||||
<StatCard
|
||||
label="Delivered"
|
||||
value={totalSent > 0 ? `${Math.round((totalDelivered / totalSent) * 100)}%` : "—"}
|
||||
sub={totalDelivered > 0 ? `${totalDelivered.toLocaleString()} messages` : undefined}
|
||||
icon={Icons.mail("w-5 h-5")}
|
||||
/>
|
||||
<StatCard
|
||||
label="Avg. Open Rate"
|
||||
value={totalSent > 0 ? `${avgOpenRate.toFixed(1)}%` : "—"}
|
||||
sub={bestCampaign ? `Best: ${bestCampaign.campaign_name}` : "Industry avg: 21%"}
|
||||
icon={Icons.eye("w-5 h-5")}
|
||||
/>
|
||||
<StatCard
|
||||
label="Avg. Click Rate"
|
||||
value={totalSent > 0 ? `${avgClickRate.toFixed(1)}%` : "—"}
|
||||
sub={totalClicked > 0 ? `${totalClicked.toLocaleString()} clicks` : undefined}
|
||||
icon={Icons.click("w-5 h-5")}
|
||||
/>
|
||||
<StatCard label="Avg. Open Rate" value={totalSent > 0 ? `${avgOpenRate.toFixed(1)}%` : "—"} sub="of delivered" />
|
||||
<StatCard label="Avg. Click Rate" value={totalSent > 0 ? `${avgClickRate.toFixed(1)}%` : "—"} sub="of delivered" />
|
||||
</div>
|
||||
|
||||
{/* Performance trends (if we have multiple campaigns) */}
|
||||
{filteredAnalytics.length > 1 && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-6">
|
||||
<div className="bg-white rounded-xl border border-[var(--admin-border)] p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">Open Rate Trend</h3>
|
||||
<span className="text-xs font-semibold text-emerald-600">{avgOpenRate.toFixed(1)}% avg</span>
|
||||
</div>
|
||||
<MiniSparkline data={openRateSparkline} color="#10b981" />
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-[var(--admin-border)] p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">Click Rate Trend</h3>
|
||||
<span className="text-xs font-semibold text-amber-600">{avgClickRate.toFixed(1)}% avg</span>
|
||||
</div>
|
||||
<MiniSparkline data={clickRateSparkline} color="#f59e0b" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Campaign performance table */}
|
||||
<div className="bg-white rounded-xl border border-[var(--admin-border)] overflow-hidden">
|
||||
<div className="px-4 sm:px-6 py-4 border-b border-[var(--admin-border)] flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">Campaign Performance</h3>
|
||||
<div className="flex rounded-lg border border-[var(--admin-border)] bg-stone-50 p-0.5">
|
||||
{(["30", "90", "all"] as const).map((val) => (
|
||||
<button
|
||||
key={val}
|
||||
onClick={() => setPeriod(val as Period)}
|
||||
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${
|
||||
period === val ? "bg-emerald-600 text-white" : "text-stone-500 hover:bg-white"
|
||||
}`}
|
||||
>
|
||||
{val === "30" ? "30 days" : val === "90" ? "90 days" : "All time"}
|
||||
</button>
|
||||
))}
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">Campaign Performance</h3>
|
||||
{filteredAnalytics.length > 0 && (
|
||||
<span className="inline-flex items-center rounded-full bg-stone-100 px-2.5 py-0.5 text-xs font-semibold text-stone-600">
|
||||
{filteredAnalytics.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-xs text-stone-500">
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-2 h-2 rounded-full bg-emerald-500" />
|
||||
Open rate
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-2 h-2 rounded-full bg-amber-500" />
|
||||
Click rate
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{analytics.length === 0 ? (
|
||||
<div className="px-6 py-12 text-center">
|
||||
<div className="flex h-16 w-16 mx-auto items-center justify-center rounded-full bg-stone-100 mb-4">
|
||||
<svg className="h-8 w-8 text-stone-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="18" y1="20" x2="18" y2="10"/>
|
||||
<line x1="12" y1="20" x2="12" y2="4"/>
|
||||
<line x1="6" y1="20" x2="6" y2="14"/>
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-sm font-medium text-stone-600">No campaign analytics yet</p>
|
||||
<p className="text-xs text-stone-400 mt-1">Send a campaign to start tracking engagement</p>
|
||||
</div>
|
||||
{filteredAnalytics.length === 0 ? (
|
||||
<AdminEmptyState
|
||||
icon={emptyStateIcon}
|
||||
title="No campaign analytics yet"
|
||||
description="Send a campaign to start tracking your email engagement and performance metrics."
|
||||
action={
|
||||
<div className="flex items-center gap-2 text-sm text-stone-500">
|
||||
{Icons.calendar("w-4 h-4")}
|
||||
<span>Analytics will appear after your first campaign is sent</span>
|
||||
</div>
|
||||
}
|
||||
className="py-16"
|
||||
/>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-stone-50 border-b border-[var(--admin-border)]">
|
||||
<tr>
|
||||
<th className="text-left px-4 sm:px-6 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">Campaign</th>
|
||||
<th className="text-right px-4 sm:px-6 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">Sent</th>
|
||||
<th className="text-right px-4 sm:px-6 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">Delivered</th>
|
||||
<th className="text-right px-4 sm:px-6 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">Opened</th>
|
||||
<th className="text-right px-4 sm:px-6 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">Clicked</th>
|
||||
<th className="text-right px-4 sm:px-6 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">Bounced</th>
|
||||
<th className="text-left px-4 sm:px-6 py-3 font-semibold text-[var(--admin-text-muted)] text-xs">Engagement</th>
|
||||
<th className="text-left px-4 sm:px-6 py-3.5 font-semibold text-[var(--admin-text-muted)] text-xs uppercase tracking-wider">Campaign</th>
|
||||
<th className="text-right px-4 sm:px-6 py-3.5 font-semibold text-[var(--admin-text-muted)] text-xs uppercase tracking-wider">Sent</th>
|
||||
<th className="text-right px-4 sm:px-6 py-3.5 font-semibold text-[var(--admin-text-muted)] text-xs uppercase tracking-wider">Delivered</th>
|
||||
<th className="text-right px-4 sm:px-6 py-3.5 font-semibold text-[var(--admin-text-muted)] text-xs uppercase tracking-wider">Opened</th>
|
||||
<th className="text-right px-4 sm:px-6 py-3.5 font-semibold text-[var(--admin-text-muted)] text-xs uppercase tracking-wider">Clicked</th>
|
||||
<th className="text-right px-4 sm:px-6 py-3.5 font-semibold text-[var(--admin-text-muted)] text-xs uppercase tracking-wider">Bounced</th>
|
||||
<th className="text-left px-4 sm:px-6 py-3.5 font-semibold text-[var(--admin-text-muted)] text-xs uppercase tracking-wider">Engagement</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[var(--admin-border)]">
|
||||
{analytics.map((a) => (
|
||||
<tr key={a.campaign_id} className="hover:bg-stone-50 transition-colors">
|
||||
<td className="px-4 sm:px-6 py-3.5">
|
||||
{filteredAnalytics.map((a) => (
|
||||
<tr key={a.campaign_id} className="hover:bg-stone-50/50 transition-colors">
|
||||
<td className="px-4 sm:px-6 py-4">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium text-[var(--admin-text-primary)]">{a.campaign_name}</span>
|
||||
<span className="text-xs text-stone-400">
|
||||
{a.sent_at ? new Date(a.sent_at).toLocaleDateString() : "—"}
|
||||
<span className="font-semibold text-[var(--admin-text-primary)]">{a.campaign_name}</span>
|
||||
<span className="text-xs text-stone-400 flex items-center gap-1 mt-0.5">
|
||||
{Icons.calendar("w-3 h-3")}
|
||||
{formatDate(a.sent_at)}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 sm:px-6 py-3.5 text-right text-[var(--admin-text-primary)]">{a.total_sent.toLocaleString()}</td>
|
||||
<td className="px-4 sm:px-6 py-3.5 text-right">
|
||||
<span className="text-[var(--admin-text-primary)]">{a.total_delivered.toLocaleString()}</span>
|
||||
<span className="text-xs text-stone-400 ml-1">({a.delivered_rate}%)</span>
|
||||
<td className="px-4 sm:px-6 py-4 text-right">
|
||||
<span className="font-semibold text-[var(--admin-text-primary)]">{a.total_sent.toLocaleString()}</span>
|
||||
</td>
|
||||
<td className="px-4 sm:px-6 py-3.5 text-right">
|
||||
<span className="text-[var(--admin-text-primary)]">{a.total_opened.toLocaleString()}</span>
|
||||
<span className="text-xs text-stone-400 ml-1">({a.open_rate}%)</span>
|
||||
<td className="px-4 sm:px-6 py-4 text-right">
|
||||
<div className="flex flex-col items-end">
|
||||
<span className="font-semibold text-[var(--admin-text-primary)]">{a.total_delivered.toLocaleString()}</span>
|
||||
<span className="text-xs text-stone-400">({a.delivered_rate}%)</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 sm:px-6 py-3.5 text-right">
|
||||
<span className="text-[var(--admin-text-primary)]">{a.total_clicked.toLocaleString()}</span>
|
||||
<span className="text-xs text-stone-400 ml-1">({a.click_rate}%)</span>
|
||||
<td className="px-4 sm:px-6 py-4 text-right">
|
||||
<div className="flex flex-col items-end">
|
||||
<span className="font-semibold text-[var(--admin-text-primary)]">{a.total_opened.toLocaleString()}</span>
|
||||
<span className="text-xs text-emerald-600 font-medium">{a.open_rate}%</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 sm:px-6 py-3.5 text-right">
|
||||
<span className={a.total_bounced > 0 ? "text-red-600" : "text-stone-400"}>
|
||||
{a.total_bounced.toLocaleString()}
|
||||
</span>
|
||||
<span className="text-xs text-stone-400 ml-1">({a.bounce_rate}%)</span>
|
||||
<td className="px-4 sm:px-6 py-4 text-right">
|
||||
<div className="flex flex-col items-end">
|
||||
<span className="font-semibold text-[var(--admin-text-primary)]">{a.total_clicked.toLocaleString()}</span>
|
||||
<span className="text-xs text-amber-600 font-medium">{a.click_rate}%</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 sm:px-6 py-3.5 w-36">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-stone-500">Open</span>
|
||||
<span className="font-medium text-[var(--admin-text-primary)]">{a.open_rate}%</span>
|
||||
</div>
|
||||
<RateBar value={Number(a.open_rate)} />
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-stone-500">Click</span>
|
||||
<span className="font-medium text-[var(--admin-text-primary)]">{a.click_rate}%</span>
|
||||
</div>
|
||||
<td className="px-4 sm:px-6 py-4 text-right">
|
||||
<div className="flex flex-col items-end">
|
||||
<span className={`font-semibold ${a.total_bounced > 0 ? "text-red-500" : "text-stone-400"}`}>
|
||||
{a.total_bounced.toLocaleString()}
|
||||
</span>
|
||||
<span className="text-xs text-stone-400">({a.bounce_rate}%)</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 sm:px-6 py-4 w-40">
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<RateBar value={Number(a.open_rate)} color="bg-emerald-500" />
|
||||
<RateBar value={Number(a.click_rate)} color="bg-amber-500" />
|
||||
</div>
|
||||
</td>
|
||||
@@ -166,6 +410,29 @@ export default function AnalyticsDashboard({ analytics }: Props) {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Summary footer */}
|
||||
{filteredAnalytics.length > 0 && (
|
||||
<div className="mt-4 p-4 bg-stone-50 rounded-xl border border-[var(--admin-border)] flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-6 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[var(--admin-text-muted)]">Overall bounce rate:</span>
|
||||
<span className={`font-semibold ${avgBounceRate > 2 ? "text-red-500" : "text-emerald-600"}`}>
|
||||
{avgBounceRate.toFixed(2)}%
|
||||
</span>
|
||||
</div>
|
||||
{avgBounceRate > 2 && (
|
||||
<div className="flex items-center gap-1.5 text-amber-600">
|
||||
{Icons.alert("w-4 h-4")}
|
||||
<span className="text-xs">High bounce rate - consider cleaning your list</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-stone-400">
|
||||
Last updated: {new Date().toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,32 +4,79 @@ import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
const TABS = [
|
||||
{ href: "/admin/harvest-reach/segments", label: "Segments" },
|
||||
{ href: "/admin/harvest-reach/campaigns", label: "Campaigns" },
|
||||
{ href: "/admin/harvest-reach/analytics", label: "Analytics" },
|
||||
{
|
||||
href: "/admin/harvest-reach/segments",
|
||||
label: "Segments",
|
||||
icon: (
|
||||
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polygon points="12 2 2 7 12 12 22 7 12 2"/>
|
||||
<polyline points="2 17 12 22 22 17"/>
|
||||
<polyline points="2 12 12 17 22 12"/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
href: "/admin/harvest-reach/campaigns",
|
||||
label: "Campaigns",
|
||||
icon: (
|
||||
<svg className="w-4 h-4" 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>
|
||||
),
|
||||
},
|
||||
{
|
||||
href: "/admin/harvest-reach/analytics",
|
||||
label: "Analytics",
|
||||
icon: (
|
||||
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="18" y1="20" x2="18" y2="10"/>
|
||||
<line x1="12" y1="20" x2="12" y2="4"/>
|
||||
<line x1="6" y1="20" x2="6" y2="14"/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
export default function HarvestReachNav() {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<nav className="flex gap-1 border-b border-zinc-800 mb-6">
|
||||
{TABS.map((tab) => {
|
||||
const active = pathname.startsWith(tab.href);
|
||||
return (
|
||||
<Link
|
||||
key={tab.href}
|
||||
href={tab.href}
|
||||
className={`px-4 py-2.5 text-sm font-medium border-b-2 transition-colors ${
|
||||
active
|
||||
? "border-stone-900 text-stone-900"
|
||||
: "border-transparent text-zinc-500 hover:text-zinc-300"
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
<div className="flex flex-col">
|
||||
{/* Brand header */}
|
||||
<div className="flex items-center gap-3 pb-4 mb-2">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-emerald-500 to-emerald-600 shadow-lg shadow-emerald-500/20">
|
||||
<svg className="h-5 w-5 text-white" 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>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-bold text-stone-900">Harvest Reach</h1>
|
||||
<p className="text-xs text-stone-500">Email marketing & campaigns</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation tabs */}
|
||||
<nav className="flex gap-1 p-1 bg-stone-100 rounded-xl">
|
||||
{TABS.map((tab) => {
|
||||
const active = pathname.startsWith(tab.href);
|
||||
return (
|
||||
<Link
|
||||
key={tab.href}
|
||||
href={tab.href}
|
||||
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2.5 text-sm font-semibold rounded-lg transition-all ${
|
||||
active
|
||||
? "bg-white text-emerald-600 shadow-sm"
|
||||
: "text-stone-500 hover:text-stone-700 hover:bg-white/50"
|
||||
}`}
|
||||
>
|
||||
{tab.icon}
|
||||
<span>{tab.label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import SegmentBuilderPanel from "./SegmentBuilderPanel";
|
||||
import MatchingCustomersPanel from "./MatchingCustomersPanel";
|
||||
import SegmentListSidebar from "./SegmentListSidebar";
|
||||
import SegmentEditModal from "./SegmentEditModal";
|
||||
import { PageHeader, AdminButton } from "@/components/admin/design-system";
|
||||
import { PageHeader, AdminButton, AdminEmptyState } from "@/components/admin/design-system";
|
||||
|
||||
type Props = {
|
||||
brandId: string;
|
||||
@@ -22,6 +22,66 @@ const LayersIcon = ({ className }: { className: string }) => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
const PlusIcon = ({ className }: { className: string }) => (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="12" y1="5" x2="12" y2="19"/>
|
||||
<line x1="5" y1="12" x2="19" y2="12"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
// Empty state component for segments
|
||||
function SegmentsEmptyState({ onNew }: { onNew: () => void }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 px-4 text-center">
|
||||
<div className="mb-4 flex h-20 w-20 items-center justify-center rounded-2xl bg-gradient-to-br from-emerald-100 to-emerald-50">
|
||||
<svg className="h-10 w-10 text-emerald-600" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polygon points="12 2 2 7 12 12 22 7 12 2"/>
|
||||
<polyline points="2 17 12 22 22 17"/>
|
||||
<polyline points="2 12 12 17 22 12"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-stone-800">No segments yet</h3>
|
||||
<p className="mt-2 text-sm text-stone-500 max-w-xs">
|
||||
Create segments to organize your contacts and send targeted campaigns to specific audiences.
|
||||
</p>
|
||||
<AdminButton onClick={onNew} className="mt-6" icon={<PlusIcon className="w-4 h-4" />}>
|
||||
Create Your First Segment
|
||||
</AdminButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Active segment header
|
||||
function ActiveSegmentHeader({ segment, onClear }: { segment: Segment | null; onClear: () => void }) {
|
||||
if (!segment) return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between p-4 bg-emerald-50 rounded-xl border border-emerald-200 mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-emerald-500 text-white">
|
||||
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<polygon points="12 2 2 7 12 12 22 7 12 2"/>
|
||||
<polyline points="2 17 12 22 22 17"/>
|
||||
<polyline points="2 12 12 17 22 12"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-stone-800">{segment.name}</p>
|
||||
{segment.description && (
|
||||
<p className="text-xs text-stone-500">{segment.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClear}
|
||||
className="text-xs font-medium text-stone-500 hover:text-stone-700 transition-colors"
|
||||
>
|
||||
Clear selection
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SegmentBuilderPage({ brandId, initialSegments }: Props) {
|
||||
const [segments, setSegments] = useState<Segment[]>(initialSegments);
|
||||
const [activeSegment, setActiveSegment] = useState<Segment | null>(null);
|
||||
@@ -39,11 +99,17 @@ export default function SegmentBuilderPage({ brandId, initialSegments }: Props)
|
||||
function handleNewSegment() {
|
||||
setActiveSegment(null);
|
||||
setCurrentRules({ combinator: "AND", filters: [] });
|
||||
setShowEditModal(true);
|
||||
}
|
||||
|
||||
function handleClearSelection() {
|
||||
setActiveSegment(null);
|
||||
setCurrentRules({ combinator: "AND", filters: [] });
|
||||
}
|
||||
|
||||
function handleRulesChange(rules: SegmentRuleV2) {
|
||||
setCurrentRules(rules);
|
||||
setActiveSegment(null);
|
||||
// Only clear active segment if we're editing the rules, not just viewing
|
||||
}
|
||||
|
||||
async function handleSaveSegment(name: string, description: string) {
|
||||
@@ -73,10 +139,12 @@ export default function SegmentBuilderPage({ brandId, initialSegments }: Props)
|
||||
await deleteHarvestReachSegment(segmentId, brandId);
|
||||
setSegments((prev) => prev.filter((s) => s.id !== segmentId));
|
||||
if (activeSegment?.id === segmentId) {
|
||||
handleNewSegment();
|
||||
handleClearSelection();
|
||||
}
|
||||
}
|
||||
|
||||
const hasFilters = currentRules.filters.length > 0;
|
||||
|
||||
return (
|
||||
<div className="p-4 sm:p-6 space-y-4">
|
||||
<PageHeader
|
||||
@@ -85,31 +153,41 @@ export default function SegmentBuilderPage({ brandId, initialSegments }: Props)
|
||||
subtitle="Build filters to define your audience, then save and reuse the segment."
|
||||
/>
|
||||
|
||||
{/* Main layout */}
|
||||
<div className="flex flex-col lg:flex-row gap-4">
|
||||
{/* Left sidebar */}
|
||||
<div className="lg:w-72 flex-shrink-0">
|
||||
<SegmentListSidebar
|
||||
segments={segments}
|
||||
activeSegmentId={activeSegment?.id}
|
||||
onSelect={handleSegmentSelect}
|
||||
onNew={handleNewSegment}
|
||||
onDelete={handleDeleteSegment}
|
||||
/>
|
||||
</div>
|
||||
{/* Show empty state if no segments */}
|
||||
{segments.length === 0 ? (
|
||||
<SegmentsEmptyState onNew={handleNewSegment} />
|
||||
) : (
|
||||
<>
|
||||
{/* Active segment indicator */}
|
||||
<ActiveSegmentHeader segment={activeSegment} onClear={handleClearSelection} />
|
||||
|
||||
{/* Main content: builder + preview */}
|
||||
<div className="flex-1 grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<SegmentBuilderPanel
|
||||
brandId={brandId}
|
||||
rules={currentRules}
|
||||
onChange={handleRulesChange}
|
||||
onSave={() => setShowEditModal(true)}
|
||||
hasActiveSegment={!!activeSegment}
|
||||
/>
|
||||
<MatchingCustomersPanel brandId={brandId} rules={currentRules} />
|
||||
</div>
|
||||
</div>
|
||||
{/* Main layout */}
|
||||
<div className="flex flex-col lg:flex-row gap-4">
|
||||
{/* Left sidebar */}
|
||||
<div className="lg:w-72 flex-shrink-0">
|
||||
<SegmentListSidebar
|
||||
segments={segments}
|
||||
activeSegmentId={activeSegment?.id}
|
||||
onSelect={handleSegmentSelect}
|
||||
onNew={handleNewSegment}
|
||||
onDelete={handleDeleteSegment}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Main content: builder + preview */}
|
||||
<div className="flex-1 grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<SegmentBuilderPanel
|
||||
brandId={brandId}
|
||||
rules={currentRules}
|
||||
onChange={handleRulesChange}
|
||||
onSave={() => setShowEditModal(true)}
|
||||
hasActiveSegment={!!activeSegment}
|
||||
/>
|
||||
<MatchingCustomersPanel brandId={brandId} rules={currentRules} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{showEditModal && (
|
||||
<SegmentEditModal
|
||||
|
||||
Reference in New Issue
Block a user