Initial commit - Route Commerce platform

This commit is contained in:
2026-06-01 19:40:55 +00:00
commit 53a9671461
617 changed files with 106132 additions and 0 deletions
@@ -0,0 +1,143 @@
"use client";
import { useState } from "react";
import { type CampaignAnalytics } from "@/actions/harvest-reach/campaigns";
type Props = {
analytics: CampaignAnalytics[];
};
type Period = "30" | "90" | "all";
function RateBar({ value }: { value: number }) {
return (
<div className="w-full bg-zinc-950 rounded-full h-1.5">
<div
className="bg-stone-900 h-1.5 rounded-full"
style={{ width: `${Math.min(100, value)}%` }}
/>
</div>
);
}
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>
);
}
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);
const avgOpenRate = totalDelivered > 0 ? (totalOpened / totalDelivered) * 100 : 0;
const avgClickRate = totalDelivered > 0 ? (totalClicked / totalDelivered) * 100 : 0;
return (
<div className="flex flex-col gap-5">
{/* Summary stat cards */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<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}
/>
<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">
{(["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"
}`}
>
{val === "30" ? "30 days" : val === "90" ? "90 days" : "All time"}
</button>
))}
</div>
</div>
{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>
) : (
<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>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
);
}
@@ -0,0 +1,505 @@
"use client";
import { useState } from "react";
import { type Campaign, type CampaignType } from "@/actions/harvest-reach/campaigns";
import { type Template } from "@/actions/communications/templates";
import type { Segment, SegmentRuleV2 } from "@/actions/harvest-reach/segments";
type Props = {
brandId: string;
campaigns: Campaign[];
templates: Template[];
segments: Segment[];
editCampaignId?: string;
};
type Step = 1 | 2 | 3 | 4;
const STEPS = ["Template", "Audience", "Preview", "Schedule"] as const;
const STATUS_COLORS: Record<string, string> = {
draft: "bg-zinc-950 text-zinc-400",
scheduled: "bg-blue-900/40 text-blue-700",
sending: "bg-amber-100 text-amber-700",
sent: "bg-green-900/40 text-green-400",
canceled: "bg-red-900/40 text-red-400",
};
const CAMPAIGN_TYPES: { value: CampaignType; label: string; desc: string }[] = [
{ value: "marketing", label: "Marketing", desc: "Promotions, newsletters" },
{ value: "operational", label: "Operational", desc: "Stop updates, reminders" },
{ value: "transactional", label: "Transactional", desc: "Receipts, confirmations" },
];
function statusBadge(status: string) {
return (
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${STATUS_COLORS[status] ?? "bg-zinc-950 text-zinc-400"}`}>
{status}
</span>
);
}
export default function CampaignComposerPage({ brandId, campaigns, templates, segments, editCampaignId }: Props) {
const editing = campaigns.find((c) => c.id === editCampaignId);
const [step, setStep] = useState<Step>(editing ? 4 : 1);
const [name, setName] = useState(editing?.name ?? "");
const [campaignType, setCampaignType] = useState<CampaignType>(editing?.campaign_type ?? "marketing");
const [selectedTemplateId, setSelectedTemplateId] = useState<string>(editing?.template_id ?? "");
const [subject, setSubject] = useState(editing?.subject ?? "");
const [bodyText, setBodyText] = useState(editing?.body_text ?? "");
const [bodyHtml, setBodyHtml] = useState(editing?.body_html ?? "");
const [selectedSegmentId, setSelectedSegmentId] = useState<string>("");
const [sendNow, setSendNow] = useState(!editing?.scheduled_at);
const [scheduledAt, setScheduledAt] = useState("");
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const [saved, setSaved] = useState(false);
const selectedTemplate = templates.find((t) => t.id === selectedTemplateId);
const selectedSegment = segments.find((s) => s.id === selectedSegmentId);
function handleTemplateSelect(template: Template) {
setSelectedTemplateId(template.id);
setSubject(template.subject ?? "");
setBodyText(template.body_text ?? "");
setBodyHtml(template.body_html ?? "");
setStep(2);
}
async function handleCreateOrSchedule() {
setError("");
setSaving(true);
const rules = selectedSegment?.rules ?? { combinator: "AND", filters: [] };
const { upsertCampaign } = await import("@/actions/communications/campaigns");
const { sendCampaign } = await import("@/actions/communications/send");
const result = await upsertCampaign({
id: editing?.id,
brand_id: brandId,
name: name || "Untitled Campaign",
subject,
body_text: bodyText,
body_html: bodyHtml,
template_id: selectedTemplateId || undefined,
campaign_type: campaignType,
status: sendNow ? "draft" : "scheduled",
audience_rules: rules as any,
scheduled_at: sendNow ? undefined : scheduledAt || undefined,
});
if (!result.success) {
setError(result.error ?? "Failed to save campaign");
setSaving(false);
return;
}
if (sendNow) {
const sendResult = await sendCampaign(result.campaign.id, brandId);
if (!sendResult.success) {
setError(sendResult.error ?? "Failed to send campaign");
setSaving(false);
return;
}
}
setSaved(true);
setSaving(false);
setTimeout(() => setSaved(false), 3000);
}
if (saved) {
return (
<div className="bg-zinc-900 rounded-xl border border-zinc-800 p-10 text-center">
<div className="w-12 h-12 rounded-full bg-green-900/40 flex items-center justify-center mx-auto mb-4">
<svg className="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<h2 className="text-xl font-semibold text-zinc-100 mb-2">
{sendNow ? "Campaign Sent!" : "Campaign Scheduled!"}
</h2>
<p className="text-sm text-zinc-500 mb-8">
{sendNow
? "Your campaign has been sent to all matching customers."
: `It will be sent on ${new Date(scheduledAt).toLocaleString()}.`}
</p>
<button
onClick={() => window.location.href = "/admin/communications"}
className="px-6 py-2.5 rounded-lg bg-stone-900 text-white text-sm font-medium hover:bg-stone-800"
>
Back to Campaigns
</button>
</div>
);
}
return (
<div className="flex flex-col gap-6">
{/* Step indicator */}
<div className="flex items-center">
{STEPS.map((label, i) => {
const n = (i + 1) as Step;
const done = step > n;
return (
<div key={label} className="flex items-center">
<div className={`flex items-center gap-2 px-4 py-2 rounded-full text-sm font-medium ${
step === n
? "bg-stone-900 text-white"
: done
? "bg-stone-200 text-stone-700"
: "bg-zinc-950 text-slate-400"
}`}>
<span className={`w-5 h-5 rounded-full flex items-center justify-center text-xs border ${
step === n ? "border-white" : done ? "border-stone-400" : "border-zinc-600"
}`}>
{done ? (
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
</svg>
) : n}
</span>
<span className="hidden sm:inline">{label}</span>
</div>
{i < STEPS.length - 1 && (
<div className={`h-0.5 w-6 ${done ? "bg-stone-300" : "bg-slate-200"} mx-1`} />
)}
</div>
);
})}
</div>
{/* Step content card */}
<div className="bg-zinc-900 rounded-xl border border-zinc-800 p-6">
{/* Step 1: Template */}
{step === 1 && (
<div className="flex flex-col gap-5">
<div>
<h2 className="text-base font-semibold text-zinc-100">Choose a Template</h2>
<p className="text-sm text-zinc-500 mt-0.5">Select a template or start from scratch.</p>
</div>
{templates.length === 0 ? (
<div className="flex flex-col gap-4">
<p className="text-sm text-zinc-500">No templates yet. Create your subject and body below.</p>
<div className="flex flex-col gap-3">
<input
type="text"
placeholder="Subject line"
value={subject}
onChange={(e) => setSubject(e.target.value)}
className="w-full border border-zinc-600 rounded-lg px-3 py-2.5 text-sm outline-none focus:border-slate-900"
/>
<textarea
placeholder="Email body text…"
value={bodyText}
onChange={(e) => setBodyText(e.target.value)}
rows={5}
className="w-full border border-zinc-600 rounded-lg px-3 py-2.5 text-sm resize-none outline-none focus:border-slate-900"
/>
</div>
<div className="flex justify-end">
<button
onClick={() => setStep(2)}
className="px-5 py-2.5 rounded-lg bg-stone-900 text-white text-sm font-medium hover:bg-stone-800"
>
Continue
</button>
</div>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{/* Start from scratch */}
<button
onClick={() => setStep(2)}
className="rounded-xl border-2 border-dashed border-zinc-600 p-5 text-left hover:border-stone-400 hover:bg-zinc-950 transition-all group"
>
<span className="text-2xl block mb-2">+</span>
<p className="text-sm font-medium text-zinc-300 group-hover:text-zinc-100">Start from scratch</p>
<p className="text-xs text-slate-400 mt-0.5">Write your own subject and body</p>
</button>
{/* Template cards */}
{templates.map((t) => (
<button
key={t.id}
onClick={() => handleTemplateSelect(t)}
className={`rounded-xl border p-4 text-left transition-all ${
selectedTemplateId === t.id
? "border-stone-900 bg-zinc-950"
: "border-zinc-800 hover:border-slate-400 hover:bg-zinc-800"
}`}
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-slate-800 truncate">{t.name}</p>
<p className="text-xs text-slate-400 mt-0.5 truncate">{t.subject}</p>
</div>
<span className="inline-flex items-center rounded-full bg-zinc-950 px-2 py-0.5 text-xs text-zinc-400 flex-shrink-0">
{t.template_type}
</span>
</div>
</button>
))}
</div>
)}
</div>
)}
{/* Step 2: Audience */}
{step === 2 && (
<div className="flex flex-col gap-5">
<div>
<h2 className="text-base font-semibold text-zinc-100">Audience</h2>
<p className="text-sm text-zinc-500 mt-0.5">Name your campaign and pick a segment.</p>
</div>
<div className="flex flex-col gap-4">
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1.5">Campaign Name</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. May Sweet Corn Promotion"
className="w-full border border-zinc-600 rounded-lg px-3 py-2.5 text-sm outline-none focus:border-slate-900"
/>
</div>
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1.5">Saved Segment</label>
<div className="flex gap-2">
<select
value={selectedSegmentId}
onChange={(e) => {
setSelectedSegmentId(e.target.value);
}}
className="flex-1 border border-zinc-600 rounded-lg px-3 py-2.5 text-sm bg-zinc-900 outline-none focus:border-slate-900"
>
<option value=""> Choose a saved segment </option>
{segments.map((s) => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
{selectedSegmentId && (
<button
onClick={() => setSelectedSegmentId("")}
className="px-3 text-xs text-slate-400 hover:text-red-500 border border-zinc-800 rounded-lg hover:border-red-200"
>
Clear
</button>
)}
</div>
</div>
</div>
{error && <p className="text-sm text-red-400">{error}</p>}
<div className="flex justify-between pt-2">
<button
onClick={() => setStep(1)}
className="px-4 py-2.5 text-sm text-zinc-500 hover:text-zinc-300 flex items-center gap-1"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" /></svg>
Back
</button>
<button
onClick={() => setStep(3)}
disabled={!name}
className="px-5 py-2.5 rounded-lg bg-stone-900 text-white text-sm font-medium hover:bg-stone-800 disabled:opacity-40"
>
Continue to Preview
</button>
</div>
</div>
)}
{/* Step 3: Preview */}
{step === 3 && (
<div className="flex flex-col gap-5">
<div>
<h2 className="text-base font-semibold text-zinc-100">Preview & Edit</h2>
<p className="text-sm text-zinc-500 mt-0.5">Review how your message will look.</p>
</div>
{/* Mock email frame */}
<div className="rounded-xl border border-zinc-800 bg-slate-50 overflow-hidden">
<div className="px-4 py-3 bg-zinc-900 border-b border-zinc-800 flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-stone-300" />
<div>
<p className="text-xs font-medium text-zinc-300">Your Brand</p>
<p className="text-xs text-slate-400">To: {"{{customer_name}}"}</p>
</div>
</div>
<div className="p-5">
<p className="text-base font-semibold text-zinc-100 mb-3">{subject || "(no subject)"}</p>
<div className="text-sm text-zinc-300 whitespace-pre-wrap">{bodyText || "(no body)"}</div>
</div>
</div>
{/* Editable subject */}
<div>
<label className="block text-xs font-medium text-zinc-500 uppercase tracking-wide mb-1.5">Subject Line</label>
<input
type="text"
value={subject}
onChange={(e) => setSubject(e.target.value)}
className="w-full border border-zinc-600 rounded-lg px-3 py-2.5 text-sm outline-none focus:border-slate-900"
/>
</div>
<div className="flex justify-between pt-2">
<button
onClick={() => setStep(2)}
className="px-4 py-2.5 text-sm text-zinc-500 hover:text-zinc-300 flex items-center gap-1"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" /></svg>
Back
</button>
<button
onClick={() => setStep(4)}
className="px-5 py-2.5 rounded-lg bg-stone-900 text-white text-sm font-medium hover:bg-stone-800"
>
Continue to Schedule
</button>
</div>
</div>
)}
{/* Step 4: Schedule */}
{step === 4 && (
<div className="flex flex-col gap-5">
<div>
<h2 className="text-base font-semibold text-zinc-100">Schedule & Send</h2>
<p className="text-sm text-zinc-500 mt-0.5">Set campaign type and timing.</p>
</div>
{/* Campaign type */}
<div>
<label className="block text-xs font-medium text-zinc-500 uppercase tracking-wide mb-2">Campaign Type</label>
<div className="grid grid-cols-3 gap-3">
{CAMPAIGN_TYPES.map((ct) => (
<button
key={ct.value}
onClick={() => setCampaignType(ct.value)}
className={`rounded-xl border py-3 px-4 text-left transition-all ${
campaignType === ct.value
? "border-stone-900 bg-stone-900 text-white"
: "border-zinc-800 hover:border-slate-400 hover:bg-zinc-800"
}`}
>
<p className={`text-sm font-medium ${campaignType === ct.value ? "text-white" : "text-zinc-300"}`}>{ct.label}</p>
<p className={`text-xs mt-0.5 ${campaignType === ct.value ? "text-stone-300" : "text-slate-400"}`}>{ct.desc}</p>
</button>
))}
</div>
</div>
{/* Send timing */}
<div>
<label className="block text-xs font-medium text-zinc-500 uppercase tracking-wide mb-2">Send</label>
<div className="flex gap-6">
<label className="flex items-center gap-2.5 cursor-pointer">
<input
type="radio"
checked={sendNow}
onChange={() => setSendNow(true)}
className="accent-stone-900 w-4 h-4"
/>
<span className="text-sm font-medium text-zinc-300">Send now</span>
</label>
<label className="flex items-center gap-2.5 cursor-pointer">
<input
type="radio"
checked={!sendNow}
onChange={() => setSendNow(false)}
className="accent-stone-900 w-4 h-4"
/>
<span className="text-sm font-medium text-zinc-300">Schedule for later</span>
</label>
</div>
</div>
{!sendNow && (
<div>
<label className="block text-xs font-medium text-zinc-500 uppercase tracking-wide mb-1.5">Send Date & Time</label>
<input
type="datetime-local"
value={scheduledAt}
onChange={(e) => setScheduledAt(e.target.value)}
className="border border-zinc-600 rounded-lg px-3 py-2.5 text-sm outline-none focus:border-slate-900"
min={new Date().toISOString().slice(0, 16)}
/>
</div>
)}
{/* Summary */}
<div className="rounded-xl bg-slate-50 border border-zinc-800 p-4">
<p className="text-xs font-medium text-zinc-500 uppercase tracking-wide mb-3">Summary</p>
<div className="grid grid-cols-2 gap-y-2 text-sm">
<span className="text-zinc-500">Campaign</span>
<span className="text-zinc-100 font-medium">{name || "—"}</span>
<span className="text-zinc-500">Template</span>
<span className="text-zinc-100">{selectedTemplate?.name ?? "Custom"}</span>
<span className="text-zinc-500">Audience</span>
<span className="text-zinc-100">{selectedSegment?.name ?? "—"}</span>
<span className="text-zinc-500">Type</span>
<span className="text-zinc-100 capitalize">{campaignType}</span>
</div>
</div>
{error && <p className="text-sm text-red-400">{error}</p>}
<div className="flex justify-between pt-2">
<button
onClick={() => setStep(3)}
className="px-4 py-2.5 text-sm text-zinc-500 hover:text-zinc-300 flex items-center gap-1"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" /></svg>
Back
</button>
<button
onClick={handleCreateOrSchedule}
disabled={saving || (!sendNow && !scheduledAt)}
className="px-5 py-2.5 rounded-lg bg-stone-900 text-white text-sm font-medium hover:bg-stone-800 transition-colors disabled:opacity-50"
>
{saving ? "Processing…" : sendNow ? "Send Campaign" : "Schedule Campaign"}
</button>
</div>
</div>
)}
</div>
{/* Recent campaigns */}
{campaigns.length > 0 && (
<div className="bg-zinc-900 rounded-xl border border-zinc-800 overflow-hidden">
<div className="px-6 py-4 border-b border-zinc-800">
<h3 className="text-sm font-semibold text-slate-800">Recent Campaigns</h3>
</div>
<table className="w-full text-sm">
<thead className="bg-slate-50 border-b border-zinc-800">
<tr>
{["Name", "Type", "Status", "Sent"].map((h) => (
<th key={h} className={`text-left px-6 py-3 font-medium text-zinc-400 ${h !== "Name" ? "hidden sm:table-cell" : ""}`}>{h}</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{campaigns.slice(0, 10).map((c) => (
<tr key={c.id} className="hover:bg-zinc-800 transition-colors">
<td className="px-6 py-3.5 font-medium text-slate-800">{c.name}</td>
<td className="px-6 py-3.5 hidden sm:table-cell text-zinc-500 capitalize">{c.campaign_type}</td>
<td className="px-6 py-3.5 hidden sm:table-cell">{statusBadge(c.status)}</td>
<td className="px-6 py-3.5 hidden sm:table-cell text-xs text-slate-400">
{c.sent_at ? new Date(c.sent_at).toLocaleDateString() : "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
@@ -0,0 +1,35 @@
"use client";
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" },
];
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>
);
}
@@ -0,0 +1,146 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { type SegmentRuleV2, type PreviewResult } from "@/actions/harvest-reach/segments";
type Props = {
brandId: string;
rules: SegmentRuleV2;
};
const DEBOUNCE_MS = 300;
export default function MatchingCustomersPanel({ brandId, rules }: Props) {
const [preview, setPreview] = useState<PreviewResult | null>(null);
const [loading, setLoading] = useState(false);
const [search, setSearch] = useState("");
const [page, setPage] = useState(0);
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
useEffect(() => {
if (rules.filters.length === 0) {
setPreview(null);
return;
}
setLoading(true);
clearTimeout(timerRef.current);
timerRef.current = setTimeout(async () => {
const { previewSegmentWithCustomers } = await import("@/actions/harvest-reach/segments");
const result = await previewSegmentWithCustomers(brandId, rules);
setPreview(result);
setLoading(false);
setPage(0);
}, DEBOUNCE_MS);
return () => clearTimeout(timerRef.current);
}, [brandId, rules]);
const PAGE_SIZE = 50;
const filtered = preview?.sample_customers ?? [];
const searched = search
? filtered.filter(
(c) =>
c.name.toLowerCase().includes(search.toLowerCase()) ||
c.email.toLowerCase().includes(search.toLowerCase())
)
: filtered;
const total = preview?.count ?? 0;
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="flex items-center justify-between">
<h3 className="text-sm font-semibold text-slate-800">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">
{total.toLocaleString()} total
</span>
)}
</div>
{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>
) : 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>
<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"
/>
{paged.length === 0 ? (
<p className="text-sm text-slate-400 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"
>
<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">
{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>
</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"
>
{tag}
</span>
))}
</div>
)}
</div>
))}
</div>
)}
{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">
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"
>
Prev
</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"
>
Next
</button>
</div>
</div>
)}
</>
)}
</div>
);
}
@@ -0,0 +1,118 @@
"use client";
import { useState } from "react";
import { type Segment, type SegmentRuleV2 } from "@/actions/harvest-reach/segments";
import SegmentBuilderPanel from "./SegmentBuilderPanel";
import MatchingCustomersPanel from "./MatchingCustomersPanel";
import SegmentListSidebar from "./SegmentListSidebar";
import SegmentEditModal from "./SegmentEditModal";
type Props = {
brandId: string;
initialSegments: Segment[];
};
export default function SegmentBuilderPage({ brandId, initialSegments }: Props) {
const [segments, setSegments] = useState<Segment[]>(initialSegments);
const [activeSegment, setActiveSegment] = useState<Segment | null>(null);
const [currentRules, setCurrentRules] = useState<SegmentRuleV2>({
combinator: "AND",
filters: [],
});
const [showEditModal, setShowEditModal] = useState(false);
function handleSegmentSelect(segment: Segment) {
setActiveSegment(segment);
setCurrentRules(segment.rules as SegmentRuleV2);
}
function handleNewSegment() {
setActiveSegment(null);
setCurrentRules({ combinator: "AND", filters: [] });
}
function handleRulesChange(rules: SegmentRuleV2) {
setCurrentRules(rules);
setActiveSegment(null);
}
async function handleSaveSegment(name: string, description: string) {
const { upsertHarvestReachSegment } = await import("@/actions/harvest-reach/segments");
const result = await upsertHarvestReachSegment({
id: activeSegment?.id,
brand_id: brandId,
name,
description,
rules: currentRules,
});
if (result.success) {
if (activeSegment) {
setSegments((prev) =>
prev.map((s) => (s.id === result.segment.id ? result.segment : s))
);
} else {
setSegments((prev) => [...prev, result.segment]);
}
setActiveSegment(result.segment);
setShowEditModal(false);
}
}
async function handleDeleteSegment(segmentId: string) {
const { deleteHarvestReachSegment } = await import("@/actions/harvest-reach/segments");
await deleteHarvestReachSegment(segmentId, brandId);
setSegments((prev) => prev.filter((s) => s.id !== segmentId));
if (activeSegment?.id === segmentId) {
handleNewSegment();
}
}
return (
<div className="flex flex-col gap-6">
{/* Page 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>
</div>
{/* Main layout */}
<div className="flex gap-5">
{/* Left sidebar */}
<div className="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-2 gap-5 min-h-[580px]">
<SegmentBuilderPanel
brandId={brandId}
rules={currentRules}
onChange={handleRulesChange}
onSave={() => setShowEditModal(true)}
hasActiveSegment={!!activeSegment}
/>
<MatchingCustomersPanel brandId={brandId} rules={currentRules} />
</div>
</div>
{showEditModal && (
<SegmentEditModal
initialName={activeSegment?.name ?? ""}
initialDescription={activeSegment?.description ?? ""}
onSave={handleSaveSegment}
onClose={() => setShowEditModal(false)}
/>
)}
</div>
);
}
@@ -0,0 +1,343 @@
"use client";
import { useState, useCallback } from "react";
import {
type SegmentRuleV2,
type SegmentFilter,
type SegmentFilterType,
type SegmentFilterParams,
} from "@/actions/harvest-reach/segments";
type Props = {
brandId: string;
rules: SegmentRuleV2;
onChange: (rules: SegmentRuleV2) => void;
onSave: () => void;
hasActiveSegment: boolean;
};
const FILTER_TYPES: { value: SegmentFilterType; label: string }[] = [
{ value: "all_customers", label: "All Customers" },
{ value: "stop", label: "Past Stop" },
{ value: "upcoming_stop", label: "Upcoming Stop" },
{ value: "product", label: "Product Purchased" },
{ value: "zip_code", label: "ZIP / City" },
{ value: "customer_history", label: "Order History" },
{ value: "tags", label: "Tags" },
];
const ORDER_HISTORY_OPTIONS = [
{ value: "all", label: "All customers" },
{ value: "first_order", label: "First-time buyers" },
{ value: "repeat", label: "Repeat buyers" },
];
function emptyFilter(type: SegmentFilterType): SegmentFilter {
const params: SegmentFilterParams = {};
if (type === "product") params.days_back = 90;
if (type === "customer_history") { params.order_history = "all"; params.days_back = 90; }
if (type === "stop" || type === "upcoming_stop") { params.date_from = ""; params.date_to = ""; }
if (type === "zip_code") { params.zip_codes = []; params.city = ""; }
if (type === "tags") params.tags = [];
return { type, params };
}
export default function SegmentBuilderPanel({ brandId, rules, onChange, onSave, hasActiveSegment }: Props) {
const setCombinator = useCallback((combinator: "AND" | "OR") => {
onChange({ ...rules, combinator });
}, [rules, onChange]);
function updateFilter(index: number, updates: Partial<SegmentFilter>) {
const newFilters = rules.filters.map((f, i) =>
i === index ? { ...f, ...updates, params: { ...f.params, ...(updates.params ?? {}) } } : f
);
onChange({ ...rules, filters: newFilters });
}
function removeFilter(index: number) {
onChange({ ...rules, filters: rules.filters.filter((_, i) => i !== index) });
}
function addFilter(type: SegmentFilterType) {
onChange({ ...rules, filters: [...rules.filters, emptyFilter(type)] });
}
return (
<div className="bg-zinc-900 rounded-xl border border-zinc-800 p-5 flex flex-col gap-5">
{/* Header */}
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-slate-800">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">
{(["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 ${
rules.combinator === c
? "bg-stone-900 text-white"
: "text-zinc-400 hover:bg-zinc-900"
}`}
>
{c}
</button>
))}
</div>
<span className="text-xs text-zinc-500">of the following</span>
</div>
</div>
{/* Filter blocks */}
<div className="flex flex-col gap-3">
{rules.filters.length === 0 && (
<div className="py-8 text-center">
<p className="text-sm text-slate-400">
No filters yet. Add a filter below to start building your segment.
</p>
</div>
)}
{rules.filters.map((filter, index) => (
<FilterBlock
key={index}
brandId={brandId}
filter={filter}
onChange={(updates) => updateFilter(index, updates)}
onRemove={() => removeFilter(index)}
/>
))}
</div>
{/* Add filter chips */}
<div className="flex gap-2 flex-wrap">
{FILTER_TYPES.map((ft) => (
<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"
>
+ {ft.label}
</button>
))}
</div>
{/* Save button */}
<div className="pt-1 border-t border-slate-100">
<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"
>
{hasActiveSegment ? "Update Segment" : "Save Segment"}
</button>
</div>
</div>
);
}
// ─── Filter Block ─────────────────────────────────────────────
type FilterBlockProps = {
brandId: string;
filter: SegmentFilter;
onChange: (updates: Partial<SegmentFilter>) => void;
onRemove: () => void;
};
function FilterBlock({ brandId, filter, onChange, onRemove }: FilterBlockProps) {
const [products, setProducts] = useState<{ id: string; name: string }[]>([]);
const [stops, setStops] = useState<{ id: string; city: string; date: string }[]>([]);
const [productsLoaded, setProductsLoaded] = useState(false);
const [stopsLoaded, setStopsLoaded] = useState(false);
function loadProducts() {
if (productsLoaded) return;
import("@/actions/harvest-reach/products").then((m) => {
m.getProductsForSegmentPicker(brandId).then((data) => {
setProducts(data);
setProductsLoaded(true);
});
});
}
function loadStops(past: boolean) {
if (stopsLoaded) return;
import("@/actions/harvest-reach/stops").then((m) => {
m.getStopsForSegmentPicker(brandId).then((data) => {
setStops(data.filter((s) => past ? s.is_past : s.is_upcoming));
setStopsLoaded(true);
});
});
}
return (
<div className="rounded-xl border border-zinc-800 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"
>
{FILTER_TYPES.map((ft) => (
<option key={ft.value} value={ft.value}>{ft.label}</option>
))}
</select>
<button
onClick={onRemove}
className="text-slate-400 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>
</button>
</div>
<div className="flex flex-col gap-2.5">
{/* Stop / Upcoming Stop */}
{(filter.type === "stop" || filter.type === "upcoming_stop") && (
<>
<select
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"
>
<option value="">Select {filter.type === "stop" ? "past" : "upcoming"} stop</option>
{stops.map((s) => (
<option key={s.id} value={s.id}>{s.city} {s.date}</option>
))}
</select>
<div className="grid grid-cols-2 gap-2">
<input
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"
/>
<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"
/>
</div>
</>
)}
{/* Product */}
{filter.type === "product" && (
<>
<select
value={filter.params.product_id ?? ""}
onChange={(e) => {
onChange({ params: { ...filter.params, product_id: e.target.value } });
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"
>
<option value="">Select a product</option>
{products.map((p) => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
<div className="flex items-center gap-2">
<label className="text-xs text-zinc-500">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"
/>
<span className="text-xs text-zinc-500">days</span>
</div>
</>
)}
{/* ZIP / City */}
{filter.type === "zip_code" && (
<>
<input
type="text"
placeholder="ZIP codes (comma-separated)"
value={(filter.params.zip_codes ?? []).join(", ")}
onChange={(e) =>
onChange({
params: {
...filter.params,
zip_codes: e.target.value.split(",").map((z) => z.trim()).filter(Boolean),
},
})
}
className="border border-zinc-800 rounded-lg px-3 py-2 text-sm outline-none focus:border-slate-900"
/>
<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"
/>
</>
)}
{/* Customer History */}
{filter.type === "customer_history" && (
<div className="flex flex-col gap-2">
<select
value={filter.params.order_history ?? "all"}
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"
>
{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>
<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"
/>
<span className="text-xs text-zinc-500">days</span>
</div>
</div>
)}
{/* Tags */}
{filter.type === "tags" && (
<input
type="text"
placeholder="Tags (comma-separated)"
value={(filter.params.tags ?? []).join(", ")}
onChange={(e) =>
onChange({
params: {
...filter.params,
tags: e.target.value.split(",").map((t) => t.trim()).filter(Boolean),
},
})
}
className="border border-zinc-800 rounded-lg px-3 py-2 text-sm outline-none focus:border-slate-900"
/>
)}
{/* All customers */}
{filter.type === "all_customers" && (
<p className="text-xs text-slate-400">Matches all customers in your brand.</p>
)}
</div>
</div>
);
}
@@ -0,0 +1,81 @@
"use client";
import { useState } from "react";
type Props = {
initialName?: string;
initialDescription?: string;
onSave: (name: string, description: string) => Promise<void>;
onClose: () => void;
};
export default function SegmentEditModal({ initialName = "", initialDescription = "", onSave, onClose }: Props) {
const [name, setName] = useState(initialName);
const [description, setDescription] = useState(initialDescription);
const [saving, setSaving] = useState(false);
async function handleSave() {
if (!name.trim()) return;
setSaving(true);
await onSave(name.trim(), description.trim());
setSaving(false);
}
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>
</button>
</div>
<div className="flex flex-col gap-3">
<div>
<label className="block text-sm font-medium text-zinc-300 mb-1">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"
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>
<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"
/>
</div>
</div>
<div className="flex gap-3 pt-2">
<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"
>
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"
>
{saving ? "Saving…" : "Save Segment"}
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,100 @@
"use client";
import { useState } from "react";
import type { Segment } from "@/actions/harvest-reach/segments";
type Props = {
segments: Segment[];
activeSegmentId?: string;
onSelect: (segment: Segment) => void;
onNew: () => void;
onDelete: (segmentId: string) => void;
};
export default function SegmentListSidebar({ segments, activeSegmentId, onSelect, onNew, onDelete }: Props) {
const [search, setSearch] = useState("");
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
const filtered = segments.filter((s) =>
s.name.toLowerCase().includes(search.toLowerCase())
);
return (
<div className="bg-zinc-900 rounded-xl border border-zinc-800 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>
<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"
aria-label="New segment"
>
+
</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="flex flex-col gap-1.5">
{filtered.length === 0 && (
<p className="text-xs text-slate-400 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="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"
>
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"
>
Cancel
</button>
</div>
</div>
) : (
<div
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"
}`}
>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-slate-800 truncate">{segment.name}</p>
{segment.description && (
<p className="text-xs text-slate-400 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"
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>
</button>
</div>
)}
</div>
))}
</div>
</div>
);
}