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:
2026-06-02 05:19:34 +00:00
parent fb25c5ee22
commit b845d69aba
97 changed files with 10698 additions and 3487 deletions
@@ -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