Initial commit - Route Commerce platform
This commit is contained in:
@@ -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 “{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"
|
||||
>
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user