Admin AI Tools page: unified design system styling, provider selector with OpenAI turd emoji, free-text model input with exact ID warning

This commit is contained in:
2026-06-02 02:21:11 +00:00
parent 809e0061ca
commit 15e939ad7e
116 changed files with 14991 additions and 5326 deletions
@@ -9,11 +9,11 @@ type Props = {
type Period = "30" | "90" | "all";
function RateBar({ value }: { value: number }) {
function RateBar({ value, color = "bg-emerald-500" }: { value: number; color?: string }) {
return (
<div className="w-full bg-zinc-950 rounded-full h-1.5">
<div className="w-full bg-stone-100 rounded-full h-1.5">
<div
className="bg-stone-900 h-1.5 rounded-full"
className={`h-1.5 rounded-full ${color}`}
style={{ width: `${Math.min(100, value)}%` }}
/>
</div>
@@ -22,10 +22,10 @@ function RateBar({ value }: { value: number }) {
function StatCard({ label, value, sub }: { label: string; value: string | number; sub?: string }) {
return (
<div className="bg-zinc-900 rounded-xl border border-zinc-800 p-5 flex flex-col gap-1.5">
<p className="text-xs font-medium text-zinc-500 uppercase tracking-wide">{label}</p>
<p className="text-2xl font-bold text-zinc-100">{value}</p>
{sub && <p className="text-xs text-slate-400">{sub}</p>}
<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>
);
}
@@ -42,30 +42,45 @@ export default function AnalyticsDashboard({ analytics }: Props) {
const avgClickRate = totalDelivered > 0 ? (totalClicked / totalDelivered) * 100 : 0;
return (
<div className="flex flex-col gap-5">
<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>
<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>
</div>
</div>
{/* Summary stat cards */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<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="Delivered"
value={totalSent > 0 ? `${Math.round((totalDelivered / totalSent) * 100)}%` : "—"}
sub={totalDelivered > 0 ? `${totalDelivered.toLocaleString()} emails` : undefined}
sub={totalDelivered > 0 ? `${totalDelivered.toLocaleString()} messages` : undefined}
/>
<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>
{/* Campaign performance table */}
<div className="bg-zinc-900 rounded-xl border border-zinc-800 overflow-hidden">
<div className="px-6 py-4 border-b border-zinc-800 flex items-center justify-between">
<h3 className="text-sm font-semibold text-slate-800">Campaign Performance</h3>
<div className="flex rounded-lg border border-zinc-800 bg-slate-50 p-0.5">
<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 text-xs font-medium rounded-md transition-colors ${
period === val ? "bg-stone-900 text-white" : "text-zinc-400 hover:bg-zinc-900"
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"}
@@ -76,66 +91,79 @@ export default function AnalyticsDashboard({ analytics }: Props) {
{analytics.length === 0 ? (
<div className="px-6 py-12 text-center">
<p className="text-sm text-slate-400">No campaign analytics yet.</p>
<p className="text-xs text-slate-400 mt-1">Send a campaign to start tracking engagement.</p>
<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>
) : (
<table className="w-full text-sm">
<thead className="bg-slate-50 border-b border-zinc-800">
<tr>
{["Campaign", "Sent", "Delivered", "Opened", "Clicked", "Bounced", "Engagement"].map((h) => (
<th key={h} className={`text-left px-6 py-3 font-medium text-zinc-400 ${h !== "Campaign" ? "text-right" : ""}`}>{h}</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{analytics.map((a) => (
<tr key={a.campaign_id} className="hover:bg-zinc-800 transition-colors">
<td className="px-6 py-3.5">
<div className="flex flex-col">
<span className="font-medium text-slate-800">{a.campaign_name}</span>
<span className="text-xs text-slate-400">
{a.sent_at ? new Date(a.sent_at).toLocaleDateString() : "—"}
</span>
</div>
</td>
<td className="px-6 py-3.5 text-right text-zinc-300">{a.total_sent.toLocaleString()}</td>
<td className="px-6 py-3.5 text-right">
<span className="text-zinc-300">{a.total_delivered.toLocaleString()}</span>
<span className="text-xs text-slate-400 ml-1">({a.delivered_rate}%)</span>
</td>
<td className="px-6 py-3.5 text-right">
<span className="text-zinc-300">{a.total_opened.toLocaleString()}</span>
<span className="text-xs text-slate-400 ml-1">({a.open_rate}%)</span>
</td>
<td className="px-6 py-3.5 text-right">
<span className="text-zinc-300">{a.total_clicked.toLocaleString()}</span>
<span className="text-xs text-slate-400 ml-1">({a.click_rate}%)</span>
</td>
<td className="px-6 py-3.5 text-right">
<span className={a.total_bounced > 0 ? "text-red-400" : "text-slate-400"}>
{a.total_bounced.toLocaleString()}
</span>
<span className="text-xs text-slate-400 ml-1">({a.bounce_rate}%)</span>
</td>
<td className="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-zinc-500">Open</span>
<span className="font-medium text-zinc-300">{a.open_rate}%</span>
</div>
<RateBar value={Number(a.open_rate)} />
<div className="flex items-center justify-between text-xs">
<span className="text-zinc-500">Click</span>
<span className="font-medium text-zinc-300">{a.click_rate}%</span>
</div>
<RateBar value={Number(a.click_rate)} />
</div>
</td>
<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>
</tr>
))}
</tbody>
</table>
</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">
<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>
</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>
<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>
<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>
<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>
<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>
<RateBar value={Number(a.click_rate)} color="bg-amber-500" />
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
@@ -3,9 +3,40 @@
import { useState, useEffect, useRef } from "react";
import { type SegmentRuleV2, type PreviewResult } from "@/actions/harvest-reach/segments";
type Props = {
brandId: string;
rules: SegmentRuleV2;
// Icon components
const Icons = {
users: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M22 21v-2a4 4 0 0 0-3-3.87"/>
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
),
search: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="11" cy="11" r="8"/>
<path d="m21 21-4.3-4.3"/>
</svg>
),
spinner: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="10" strokeOpacity="0.25"/>
<path d="M12 2a10 10 0 0 1 10 10" strokeLinecap="round">
<animateTransform attributeName="transform" type="rotate" from="0 12 12" to="360 12 12" dur="1s" repeatCount="indefinite"/>
</path>
</svg>
),
chevronLeft: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m15 18-6-6 6-6"/>
</svg>
),
chevronRight: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m9 18 6-6-6-6"/>
</svg>
),
};
const DEBOUNCE_MS = 300;
@@ -47,11 +78,11 @@ export default function MatchingCustomersPanel({ brandId, rules }: Props) {
const paged = searched.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
return (
<div className="bg-zinc-900 rounded-xl border border-zinc-800 p-5 flex flex-col gap-4">
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-4 sm:p-5 flex flex-col gap-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-slate-800">Matching Customers</h3>
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">Matching Customers</h3>
{preview && (
<span className="inline-flex items-center rounded-full bg-stone-100 px-2.5 py-0.5 text-xs font-medium text-stone-700">
<span className="inline-flex items-center rounded-full bg-emerald-100 px-2.5 py-0.5 text-xs font-semibold text-emerald-700">
{total.toLocaleString()} total
</span>
)}
@@ -59,52 +90,57 @@ export default function MatchingCustomersPanel({ brandId, rules }: Props) {
{rules.filters.length === 0 ? (
<div className="flex-1 flex items-center justify-center py-12">
<p className="text-sm text-slate-400 text-center">
Add filters to see matching customers
</p>
<div className="text-center">
{Icons.users("w-10 h-10 text-[var(--admin-text-muted)] mx-auto mb-3")}
<p className="text-sm text-[var(--admin-text-muted)]">
Add filters to see matching customers
</p>
</div>
</div>
) : loading ? (
<div className="flex-1 flex items-center justify-center py-12">
<div className="flex items-center gap-2.5 text-slate-400">
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
<div className="flex items-center gap-2.5 text-[var(--admin-text-muted)]">
{Icons.spinner("h-5 w-5 animate-spin")}
<span className="text-sm">Loading</span>
</div>
</div>
) : (
<>
<input
type="search"
placeholder="Search by name or email…"
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(0); }}
className="border border-zinc-800 rounded-lg px-3 py-2 text-sm outline-none focus:border-slate-900"
/>
<div className="relative">
<div className="absolute inset-y-0 left-3 flex items-center pointer-events-none">
{Icons.search("h-4 w-4 text-[var(--admin-text-muted)]")}
</div>
<input
type="search"
placeholder="Search by name or email…"
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(0); }}
className="w-full pl-10 pr-3 py-2 text-sm border border-[var(--admin-border)] rounded-lg bg-white text-[var(--admin-text-primary)] focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 outline-none"
/>
</div>
{paged.length === 0 ? (
<p className="text-sm text-slate-400 text-center py-6">No customers match these filters.</p>
<p className="text-sm text-[var(--admin-text-muted)] text-center py-6">No customers match these filters.</p>
) : (
<div className="flex-1 overflow-y-auto max-h-[460px] flex flex-col gap-1">
{paged.map((c) => (
<div
key={c.id}
className="flex items-center gap-3 px-3 py-2.5 rounded-lg hover:bg-zinc-800 transition-colors"
className="flex items-center gap-3 px-3 py-2.5 rounded-lg hover:bg-[var(--admin-card-hover)] transition-colors"
>
<div className="w-8 h-8 rounded-full bg-stone-200 flex items-center justify-center text-xs font-medium text-stone-600 flex-shrink-0">
<div className="w-8 h-8 rounded-full bg-emerald-100 flex items-center justify-center text-xs font-semibold text-emerald-700 flex-shrink-0">
{c.name ? c.name.slice(0, 2).toUpperCase() : "??"}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-slate-800 truncate">{c.name || "(no name)"}</p>
<p className="text-xs text-slate-400 truncate">{c.email}</p>
<p className="text-sm font-medium text-[var(--admin-text-primary)] truncate">{c.name || "(no name)"}</p>
<p className="text-xs text-[var(--admin-text-muted)] truncate">{c.email}</p>
</div>
{c.tags.length > 0 && (
<div className="flex gap-1 flex-shrink-0">
{c.tags.slice(0, 2).map((tag) => (
<span
key={tag}
className="inline-flex items-center rounded-full bg-blue-900/30 text-blue-400 px-2 py-0.5 text-xs"
className="inline-flex items-center rounded-full bg-blue-100 text-blue-700 px-2 py-0.5 text-xs"
>
{tag}
</span>
@@ -117,24 +153,26 @@ export default function MatchingCustomersPanel({ brandId, rules }: Props) {
)}
{searched.length > PAGE_SIZE && (
<div className="flex items-center justify-between pt-3 border-t border-slate-100">
<span className="text-xs text-slate-400">
<div className="flex items-center justify-between pt-3 border-t border-[var(--admin-border)]">
<span className="text-xs text-[var(--admin-text-muted)]">
Showing {page * PAGE_SIZE + 1}{Math.min((page + 1) * PAGE_SIZE, searched.length)} of {searched.length}
</span>
<div className="flex gap-2">
<button
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={page === 0}
className="px-3 py-1 text-xs rounded-lg border border-zinc-800 text-zinc-400 hover:bg-zinc-800 disabled:opacity-40"
className="inline-flex items-center gap-1 px-3 py-1 text-xs rounded-lg border border-[var(--admin-border)] text-[var(--admin-text-muted)] hover:bg-[var(--admin-card-hover)] disabled:opacity-50 transition-colors"
>
Prev
{Icons.chevronLeft("h-4 w-4")}
<span>Prev</span>
</button>
<button
onClick={() => setPage((p) => p + 1)}
disabled={(page + 1) * PAGE_SIZE >= searched.length}
className="px-3 py-1 text-xs rounded-lg border border-zinc-800 text-zinc-400 hover:bg-zinc-800 disabled:opacity-40"
className="inline-flex items-center gap-1 px-3 py-1 text-xs rounded-lg border border-[var(--admin-border)] text-[var(--admin-text-muted)] hover:bg-[var(--admin-card-hover)] disabled:opacity-50 transition-colors"
>
Next
<span>Next</span>
{Icons.chevronRight("h-4 w-4")}
</button>
</div>
</div>
@@ -143,4 +181,9 @@ export default function MatchingCustomersPanel({ brandId, rules }: Props) {
)}
</div>
);
}
}
type Props = {
brandId: string;
rules: SegmentRuleV2;
};
@@ -12,6 +12,17 @@ type Props = {
initialSegments: Segment[];
};
// Icon components
const Icons = {
layers: (className: string) => (
<svg className={className} 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>
),
};
export default function SegmentBuilderPage({ brandId, initialSegments }: Props) {
const [segments, setSegments] = useState<Segment[]>(initialSegments);
const [activeSegment, setActiveSegment] = useState<Segment | null>(null);
@@ -68,21 +79,26 @@ export default function SegmentBuilderPage({ brandId, initialSegments }: Props)
}
return (
<div className="flex flex-col gap-6">
{/* Page header */}
<div className="p-4 sm:p-6 space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-zinc-100">Segment Builder</h2>
<p className="text-sm text-zinc-500 mt-0.5">
Build filters to define your audience, then save and reuse the segment.
</p>
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-[var(--admin-text-primary)]">
{Icons.layers("w-4 h-4 text-[var(--admin-bg)]")}
</div>
<div>
<h2 className="text-base sm:text-lg font-bold text-[var(--admin-text-primary)]">Segment Builder</h2>
<p className="text-[10px] sm:text-xs text-[var(--admin-text-muted)]">
Build filters to define your audience, then save and reuse the segment.
</p>
</div>
</div>
</div>
{/* Main layout */}
<div className="flex gap-5">
<div className="flex flex-col lg:flex-row gap-4">
{/* Left sidebar */}
<div className="w-72 flex-shrink-0">
<div className="lg:w-72 flex-shrink-0">
<SegmentListSidebar
segments={segments}
activeSegmentId={activeSegment?.id}
@@ -93,7 +109,7 @@ export default function SegmentBuilderPage({ brandId, initialSegments }: Props)
</div>
{/* Main content: builder + preview */}
<div className="flex-1 grid grid-cols-2 gap-5 min-h-[580px]">
<div className="flex-1 grid grid-cols-1 lg:grid-cols-2 gap-4">
<SegmentBuilderPanel
brandId={brandId}
rules={currentRules}
@@ -8,6 +8,15 @@ import {
type SegmentFilterParams,
} from "@/actions/harvest-reach/segments";
// Icon components
const Icons = {
x: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M18 6 6 18M6 6l12 12"/>
</svg>
),
};
type Props = {
brandId: string;
rules: SegmentRuleV2;
@@ -63,28 +72,28 @@ export default function SegmentBuilderPanel({ brandId, rules, onChange, onSave,
}
return (
<div className="bg-zinc-900 rounded-xl border border-zinc-800 p-5 flex flex-col gap-5">
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-4 sm:p-5 flex flex-col gap-4">
{/* Header */}
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-slate-800">Filter Rules</h3>
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">Filter Rules</h3>
<div className="flex items-center gap-2">
<span className="text-xs text-zinc-500">Match</span>
<div className="flex rounded-lg border border-zinc-800 bg-slate-50 p-0.5">
<span className="text-xs text-[var(--admin-text-muted)]">Match</span>
<div className="flex rounded-lg border border-[var(--admin-border)] bg-[var(--admin-card)] p-0.5">
{(["AND", "OR"] as const).map((c) => (
<button
key={c}
onClick={() => setCombinator(c)}
className={`px-3 py-1 text-xs font-medium rounded-md transition-colors ${
className={`px-3 py-1 text-xs font-semibold rounded-md transition-colors ${
rules.combinator === c
? "bg-stone-900 text-white"
: "text-zinc-400 hover:bg-zinc-900"
? "bg-emerald-600 text-white"
: "text-[var(--admin-text-muted)] hover:bg-[var(--admin-card-hover)]"
}`}
>
{c}
</button>
))}
</div>
<span className="text-xs text-zinc-500">of the following</span>
<span className="text-xs text-[var(--admin-text-muted)]">of the following</span>
</div>
</div>
@@ -92,7 +101,7 @@ export default function SegmentBuilderPanel({ brandId, rules, onChange, onSave,
<div className="flex flex-col gap-3">
{rules.filters.length === 0 && (
<div className="py-8 text-center">
<p className="text-sm text-slate-400">
<p className="text-sm text-[var(--admin-text-muted)]">
No filters yet. Add a filter below to start building your segment.
</p>
</div>
@@ -114,7 +123,7 @@ export default function SegmentBuilderPanel({ brandId, rules, onChange, onSave,
<button
key={ft.value}
onClick={() => addFilter(ft.value)}
className="px-3 py-1.5 text-xs font-medium rounded-full border border-zinc-600 text-zinc-400 hover:bg-zinc-800 hover:border-slate-400 transition-colors"
className="px-3 py-1.5 text-xs font-medium rounded-full border border-[var(--admin-border)] text-[var(--admin-text-muted)] hover:bg-emerald-50 hover:border-emerald-200 hover:text-emerald-700 transition-colors"
>
+ {ft.label}
</button>
@@ -122,11 +131,11 @@ export default function SegmentBuilderPanel({ brandId, rules, onChange, onSave,
</div>
{/* Save button */}
<div className="pt-1 border-t border-slate-100">
<div className="pt-1 border-t border-[var(--admin-border)]">
<button
onClick={onSave}
disabled={rules.filters.length === 0}
className="w-full py-2.5 rounded-lg bg-stone-900 text-white text-sm font-medium hover:bg-stone-800 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
className="w-full py-2.5 rounded-lg bg-emerald-600 text-white text-sm font-semibold hover:bg-emerald-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{hasActiveSegment ? "Update Segment" : "Save Segment"}
</button>
@@ -171,14 +180,14 @@ function FilterBlock({ brandId, filter, onChange, onRemove }: FilterBlockProps)
}
return (
<div className="rounded-xl border border-zinc-800 p-4 flex flex-col gap-3">
<div className="rounded-xl border border-[var(--admin-border)] bg-[var(--admin-card)] p-4 flex flex-col gap-3">
<div className="flex items-center justify-between">
<select
value={filter.type}
onChange={(e) =>
onChange({ type: e.target.value as SegmentFilterType, params: emptyFilter(e.target.value as SegmentFilterType).params })
}
className="text-sm font-medium border border-zinc-800 rounded-lg px-2.5 py-1.5 bg-zinc-900 outline-none focus:border-slate-900"
className="text-sm font-medium border border-[var(--admin-border)] rounded-lg px-2.5 py-1.5 bg-white text-[var(--admin-text-primary)] outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
>
{FILTER_TYPES.map((ft) => (
<option key={ft.value} value={ft.value}>{ft.label}</option>
@@ -186,12 +195,10 @@ function FilterBlock({ brandId, filter, onChange, onRemove }: FilterBlockProps)
</select>
<button
onClick={onRemove}
className="text-slate-400 hover:text-red-500 transition-colors"
className="p-1.5 rounded-lg hover:bg-red-50 text-[var(--admin-text-muted)] hover:text-red-500 transition-colors"
aria-label="Remove filter"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
{Icons.x("w-4 h-4")}
</button>
</div>
@@ -203,7 +210,7 @@ function FilterBlock({ brandId, filter, onChange, onRemove }: FilterBlockProps)
value={filter.params.stop_id ?? ""}
onChange={(e) => onChange({ params: { ...filter.params, stop_id: e.target.value } })}
onMouseEnter={() => loadStops(filter.type === "stop")}
className="border border-zinc-800 rounded-lg px-3 py-2 text-sm bg-zinc-900 w-full outline-none focus:border-slate-900"
className="border border-[var(--admin-border)] rounded-lg px-3 py-2 text-sm bg-white w-full outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
>
<option value="">Select {filter.type === "stop" ? "past" : "upcoming"} stop</option>
{stops.map((s) => (
@@ -215,13 +222,13 @@ function FilterBlock({ brandId, filter, onChange, onRemove }: FilterBlockProps)
type="date"
value={filter.params.date_from ?? ""}
onChange={(e) => onChange({ params: { ...filter.params, date_from: e.target.value } })}
className="border border-zinc-800 rounded-lg px-3 py-2 text-sm outline-none focus:border-slate-900"
className="border border-[var(--admin-border)] rounded-lg px-3 py-2 text-sm bg-white outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
/>
<input
type="date"
value={filter.params.date_to ?? ""}
onChange={(e) => onChange({ params: { ...filter.params, date_to: e.target.value } })}
className="border border-zinc-800 rounded-lg px-3 py-2 text-sm outline-none focus:border-slate-900"
className="border border-[var(--admin-border)] rounded-lg px-3 py-2 text-sm bg-white outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
/>
</div>
</>
@@ -237,7 +244,7 @@ function FilterBlock({ brandId, filter, onChange, onRemove }: FilterBlockProps)
if (e.target.value) loadProducts();
}}
onMouseEnter={loadProducts}
className="border border-zinc-800 rounded-lg px-3 py-2 text-sm bg-zinc-900 w-full outline-none focus:border-slate-900"
className="border border-[var(--admin-border)] rounded-lg px-3 py-2 text-sm bg-white w-full outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
>
<option value="">Select a product</option>
{products.map((p) => (
@@ -245,16 +252,16 @@ function FilterBlock({ brandId, filter, onChange, onRemove }: FilterBlockProps)
))}
</select>
<div className="flex items-center gap-2">
<label className="text-xs text-zinc-500">In the last</label>
<label className="text-xs text-[var(--admin-text-muted)]">In the last</label>
<input
type="number"
min={1}
max={365}
value={filter.params.days_back ?? 90}
onChange={(e) => onChange({ params: { ...filter.params, days_back: parseInt(e.target.value) } })}
className="border border-zinc-800 rounded-lg px-2.5 py-1.5 text-sm w-20 outline-none focus:border-slate-900"
className="border border-[var(--admin-border)] rounded-lg px-2.5 py-1.5 text-sm w-20 bg-white outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
/>
<span className="text-xs text-zinc-500">days</span>
<span className="text-xs text-[var(--admin-text-muted)]">days</span>
</div>
</>
)}
@@ -274,14 +281,14 @@ function FilterBlock({ brandId, filter, onChange, onRemove }: FilterBlockProps)
},
})
}
className="border border-zinc-800 rounded-lg px-3 py-2 text-sm outline-none focus:border-slate-900"
className="border border-[var(--admin-border)] rounded-lg px-3 py-2 text-sm bg-white outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
/>
<input
type="text"
placeholder="City (optional)"
value={filter.params.city ?? ""}
onChange={(e) => onChange({ params: { ...filter.params, city: e.target.value } })}
className="border border-zinc-800 rounded-lg px-3 py-2 text-sm outline-none focus:border-slate-900"
className="border border-[var(--admin-border)] rounded-lg px-3 py-2 text-sm bg-white outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
/>
</>
)}
@@ -294,23 +301,23 @@ function FilterBlock({ brandId, filter, onChange, onRemove }: FilterBlockProps)
onChange={(e) =>
onChange({ params: { ...filter.params, order_history: e.target.value as "all" | "first_order" | "repeat" } })
}
className="border border-zinc-800 rounded-lg px-3 py-2 text-sm bg-zinc-900 outline-none focus:border-slate-900"
className="border border-[var(--admin-border)] rounded-lg px-3 py-2 text-sm bg-white outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
>
{ORDER_HISTORY_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
<div className="flex items-center gap-2">
<label className="text-xs text-zinc-500">In the last</label>
<label className="text-xs text-[var(--admin-text-muted)]">In the last</label>
<input
type="number"
min={1}
max={365}
value={filter.params.days_back ?? 90}
onChange={(e) => onChange({ params: { ...filter.params, days_back: parseInt(e.target.value) } })}
className="border border-zinc-800 rounded-lg px-2.5 py-1.5 text-sm w-20 outline-none focus:border-slate-900"
className="border border-[var(--admin-border)] rounded-lg px-2.5 py-1.5 text-sm w-20 bg-white outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
/>
<span className="text-xs text-zinc-500">days</span>
<span className="text-xs text-[var(--admin-text-muted)]">days</span>
</div>
</div>
)}
@@ -329,13 +336,13 @@ function FilterBlock({ brandId, filter, onChange, onRemove }: FilterBlockProps)
},
})
}
className="border border-zinc-800 rounded-lg px-3 py-2 text-sm outline-none focus:border-slate-900"
className="border border-[var(--admin-border)] rounded-lg px-3 py-2 text-sm bg-white outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500"
/>
)}
{/* All customers */}
{filter.type === "all_customers" && (
<p className="text-xs text-slate-400">Matches all customers in your brand.</p>
<p className="text-xs text-[var(--admin-text-muted)]">Matches all customers in your brand.</p>
)}
</div>
</div>
@@ -2,6 +2,22 @@
import { useState } from "react";
// Icon components
const Icons = {
x: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M18 6 6 18M6 6l12 12"/>
</svg>
),
layers: (className: string) => (
<svg className={className} 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>
),
};
type Props = {
initialName?: string;
initialDescription?: string;
@@ -22,55 +38,78 @@ export default function SegmentEditModal({ initialName = "", initialDescription
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-zinc-900 rounded-xl border border-zinc-800 w-full max-w-md p-6 flex flex-col gap-4 shadow-xl">
<div className="flex items-center justify-between">
<h2 className="text-base font-semibold text-zinc-100">
{initialName ? "Update Segment" : "Save Segment"}
</h2>
<button onClick={onClose} className="text-slate-400 hover:text-zinc-400 transition-colors">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={onClose}
/>
{/* Modal */}
<div className="relative w-full max-w-md mx-4 bg-white rounded-2xl shadow-2xl border border-[var(--admin-border)]">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-[var(--admin-border)]">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-emerald-600">
{Icons.layers("h-5 w-5 text-white")}
</div>
<div>
<h2 className="text-lg font-bold text-[var(--admin-text-primary)]">
{initialName ? "Update Segment" : "Save Segment"}
</h2>
<p className="text-xs text-[var(--admin-text-muted)]">
{initialName ? "Update segment details" : "Create a new audience segment"}
</p>
</div>
</div>
<button
onClick={onClose}
className="p-2 rounded-lg hover:bg-[var(--admin-card-hover)] text-[var(--admin-text-muted)] hover:text-[var(--admin-text-primary)] transition-colors"
>
{Icons.x("h-5 w-5")}
</button>
</div>
<div className="flex flex-col gap-3">
{/* Content */}
<div className="p-6 flex flex-col gap-4">
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1">Segment Name</label>
<label className="block text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">Segment Name</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSave()}
placeholder="e.g. Fort Pierce Regulars"
className="w-full border border-zinc-600 rounded-lg px-3 py-2 text-sm"
className="w-full border border-[var(--admin-border)] rounded-lg px-3 py-2.5 text-sm bg-white text-[var(--admin-text-primary)] focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 outline-none"
autoFocus
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1">Description <span className="text-slate-400 font-normal">(optional)</span></label>
<label className="block text-sm font-medium text-[var(--admin-text-primary)] mb-1.5">
Description <span className="text-[var(--admin-text-muted)] font-normal">(optional)</span>
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="e.g. Customers who pick up at the Fort Pierce stop regularly"
rows={3}
className="w-full border border-zinc-600 rounded-lg px-3 py-2 text-sm resize-none"
className="w-full border border-[var(--admin-border)] rounded-lg px-3 py-2.5 text-sm bg-white text-[var(--admin-text-primary)] resize-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 outline-none"
/>
</div>
</div>
<div className="flex gap-3 pt-2">
{/* Footer */}
<div className="flex gap-3 px-6 py-4 border-t border-[var(--admin-border)]">
<button
onClick={onClose}
className="flex-1 py-2.5 rounded-lg border border-zinc-600 text-sm font-medium text-zinc-400 hover:bg-zinc-800 transition-colors"
className="flex-1 py-2.5 rounded-lg border border-[var(--admin-border)] text-sm font-medium text-[var(--admin-text-muted)] hover:bg-[var(--admin-card-hover)] transition-colors"
>
Cancel
</button>
<button
onClick={handleSave}
disabled={!name.trim() || saving}
className="flex-1 py-2.5 rounded-lg bg-stone-900 text-white text-sm font-medium hover:bg-stone-800 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
className="flex-1 py-2.5 rounded-lg bg-emerald-600 text-white text-sm font-semibold hover:bg-emerald-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{saving ? "Saving…" : "Save Segment"}
</button>
@@ -3,6 +3,29 @@
import { useState } from "react";
import type { Segment } from "@/actions/harvest-reach/segments";
// Icon components
const Icons = {
plus: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 5v14M5 12h14" />
</svg>
),
trash: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
<line x1="10" y1="11" x2="10" y2="17"/>
<line x1="14" y1="11" x2="14" y2="17"/>
</svg>
),
search: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="11" cy="11" r="8"/>
<path d="m21 21-4.3-4.3"/>
</svg>
),
};
type Props = {
segments: Segment[];
activeSegmentId?: string;
@@ -20,47 +43,52 @@ export default function SegmentListSidebar({ segments, activeSegmentId, onSelect
);
return (
<div className="bg-zinc-900 rounded-xl border border-zinc-800 p-4 flex flex-col gap-4">
<div className="rounded-xl border border-[var(--admin-border)] bg-white p-4 flex flex-col gap-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-slate-800">Saved Segments</h3>
<h3 className="text-sm font-semibold text-[var(--admin-text-primary)]">Saved Segments</h3>
<button
onClick={onNew}
className="w-7 h-7 rounded-lg bg-stone-900 text-white flex items-center justify-center text-base leading-none hover:bg-stone-800 transition-colors"
className="w-7 h-7 rounded-lg bg-emerald-600 text-white flex items-center justify-center hover:bg-emerald-700 transition-colors"
aria-label="New segment"
>
+
{Icons.plus("w-4 h-4")}
</button>
</div>
<input
type="search"
placeholder="Search segments…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="border border-zinc-800 rounded-lg px-3 py-2 text-sm outline-none focus:border-slate-900"
/>
<div className="relative">
<div className="absolute inset-y-0 left-3 flex items-center pointer-events-none">
{Icons.search("h-4 w-4 text-[var(--admin-text-muted)]")}
</div>
<input
type="search"
placeholder="Search segments…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full pl-10 pr-3 py-2 text-sm border border-[var(--admin-border)] rounded-lg bg-white text-[var(--admin-text-primary)] focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 outline-none"
/>
</div>
<div className="flex flex-col gap-1.5">
{filtered.length === 0 && (
<p className="text-xs text-slate-400 text-center py-4">
<p className="text-xs text-[var(--admin-text-muted)] text-center py-4">
{search ? "No segments match." : "No saved segments yet."}
</p>
)}
{filtered.map((segment) => (
<div key={segment.id} className="group relative">
{confirmDelete === segment.id ? (
<div className="rounded-xl border border-red-200 bg-red-900/30 p-3 flex flex-col gap-2">
<p className="text-xs text-red-400 font-medium">Delete &ldquo;{segment.name}&rdquo;?</p>
<div className="rounded-xl border border-red-200 bg-red-50 p-3 flex flex-col gap-2">
<p className="text-xs text-red-600 font-medium">Delete "{segment.name}"?</p>
<div className="flex gap-2">
<button
onClick={() => { onDelete(segment.id); setConfirmDelete(null); }}
className="flex-1 py-1.5 text-xs rounded-lg bg-red-600 text-white hover:bg-red-700 font-medium"
className="flex-1 py-1.5 text-xs rounded-lg bg-red-600 text-white hover:bg-red-700 font-semibold transition-colors"
>
Delete
</button>
<button
onClick={() => setConfirmDelete(null)}
className="flex-1 py-1.5 text-xs rounded-lg border border-zinc-600 bg-zinc-900 hover:bg-zinc-800 font-medium"
className="flex-1 py-1.5 text-xs rounded-lg border border-[var(--admin-border)] bg-white text-[var(--admin-text-muted)] hover:bg-[var(--admin-card-hover)] font-medium transition-colors"
>
Cancel
</button>
@@ -71,24 +99,22 @@ export default function SegmentListSidebar({ segments, activeSegmentId, onSelect
onClick={() => onSelect(segment)}
className={`px-3 py-2.5 rounded-xl cursor-pointer flex items-center justify-between gap-2 transition-all ${
activeSegmentId === segment.id
? "bg-stone-100 border border-stone-300"
: "hover:bg-zinc-800 border border-transparent"
? "bg-emerald-50 border border-emerald-200"
: "hover:bg-[var(--admin-card-hover)] border border-transparent"
}`}
>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-slate-800 truncate">{segment.name}</p>
<p className="text-sm font-medium text-[var(--admin-text-primary)] truncate">{segment.name}</p>
{segment.description && (
<p className="text-xs text-slate-400 truncate mt-0.5">{segment.description}</p>
<p className="text-xs text-[var(--admin-text-muted)] truncate mt-0.5">{segment.description}</p>
)}
</div>
<button
onClick={(e) => { e.stopPropagation(); setConfirmDelete(segment.id); }}
className="opacity-0 group-hover:opacity-100 text-slate-400 hover:text-red-500 transition-all p-1"
className="opacity-0 group-hover:opacity-100 p-1.5 rounded-lg hover:bg-red-50 text-red-500 hover:text-red-600 transition-all"
aria-label="Delete segment"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
{Icons.trash("w-4 h-4")}
</button>
</div>
)}