Files
route-commerce/src/components/admin/HarvestReach/CampaignComposerPage.tsx
T
Nora 16a6756ad1 fix: react-doctor label-has-associated-control (-15, nested-interactive -1)
Add htmlFor/id pairs to label/input pairs across ~24 files.
Convert section-header labels (Role/Permissions/Visibility/Environment/
Send via/Quick messages/Campaign Type/When to Send/Preview) to <p>.
Convert clickable divs to buttons (AbandonedCartDashboard → native dialog).
Hoist regex patterns out of loops in ai-import.ts.
2026-06-26 04:02:10 -06:00

897 lines
40 KiB
TypeScript

"use client";
import { useState, useCallback, useEffect } 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";
import type { AudienceRules } from "@/actions/communications/campaigns";
import { AdminButton } from "@/components/admin/design-system";
type Props = {
brandId: string;
campaigns: Campaign[];
templates: Template[];
segments: Segment[];
editCampaignId?: string;
};
type Step = 1 | 2 | 3 | 4;
const STEPS = [
{ id: 1 as Step, label: "Template", description: "Choose or create" },
{ id: 2 as Step, label: "Content", description: "Write your message" },
{ id: 3 as Step, label: "Audience", description: "Select recipients" },
{ id: 4 as Step, label: "Schedule", description: "Send or schedule" },
];
const STATUS_COLORS: Record<string, string> = {
draft: "bg-stone-100 text-stone-600",
scheduled: "bg-blue-100 text-blue-700",
sending: "bg-amber-100 text-amber-700",
sent: "bg-emerald-100 text-emerald-700",
canceled: "bg-red-100 text-red-600",
};
const CAMPAIGN_TYPES: { value: CampaignType; label: string; desc: string; icon: React.ReactNode }[] = [
{
value: "marketing",
label: "Marketing",
desc: "Promotions, newsletters",
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth="2">
<path d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
),
},
{
value: "operational",
label: "Operational",
desc: "Stop updates, reminders",
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth="2">
<path d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
),
},
{
value: "transactional",
label: "Transactional",
desc: "Receipts, confirmations",
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth="2">
<path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
),
},
];
// Icons
const Icons = {
mail: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/>
<polyline points="22,6 12,13 2,6"/>
</svg>
),
users: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
),
check: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12"/>
</svg>
),
calendar: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/>
<line x1="16" y1="2" x2="16" y2="6"/>
<line x1="8" y1="2" x2="8" y2="6"/>
<line x1="3" y1="10" x2="21" y2="10"/>
</svg>
),
clock: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10"/>
<polyline points="12 6 12 12 16 14"/>
</svg>
),
send: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="22" y1="2" x2="11" y2="13"/>
<polygon points="22 2 15 22 11 13 2 9 22 2"/>
</svg>
),
arrowLeft: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="19" y1="12" x2="5" y2="12"/>
<polyline points="12 19 5 12 12 5"/>
</svg>
),
arrowRight: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="5" y1="12" x2="19" y2="12"/>
<polyline points="12 5 19 12 12 19"/>
</svg>
),
edit: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>
),
sparkles: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 3v1m0 16v1m-9-9h1m16 0h1m-2.636-6.364l-.707.707M6.343 17.657l-.707.707m0-12.728l.707.707m11.314 11.314l.707.707M12 8a4 4 0 100 8 4 4 0 000-8z"/>
</svg>
),
plus: (className: string) => (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="12" y1="5" x2="12" y2="19"/>
<line x1="5" y1="12" x2="19" y2="12"/>
</svg>
),
};
// Step indicator component
function StepIndicator({ currentStep, steps }: { currentStep: Step; steps: typeof STEPS }) {
return (
<div className="flex items-center justify-center">
{steps.map((step, index) => {
const isCompleted = currentStep > step.id;
const isCurrent = currentStep === step.id;
const isUpcoming = currentStep < step.id;
return (
<div key={step.id} className="flex items-center">
<div className="flex flex-col items-center">
<div
className={`w-10 h-10 rounded-full flex items-center justify-center transition-all duration-300 ${
isCurrent
? "bg-emerald-600 text-white ring-4 ring-emerald-100"
: isCompleted
? "bg-emerald-600 text-white"
: "bg-stone-200 text-stone-500"
}`}
>
{isCompleted ? (
Icons.check("w-5 h-5")
) : (
<span className="text-sm font-bold">{step.id}</span>
)}
</div>
<div className="mt-2 text-center">
<p className={`text-xs font-semibold ${isCurrent ? "text-emerald-600" : "text-stone-500"}`}>
{step.label}
</p>
</div>
</div>
{index < steps.length - 1 && (
<div className={`h-0.5 w-16 mx-2 transition-colors ${
isCompleted ? "bg-emerald-500" : "bg-stone-200"
}`} />
)}
</div>
);
})}
</div>
);
}
// Status badge
function StatusBadge({ status }: { status: string }) {
return (
<span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold capitalize ${STATUS_COLORS[status] ?? "bg-stone-100 text-stone-600"}`}>
{status}
</span>
);
}
// Template card component
function TemplateCard({
template,
isSelected,
onSelect
}: {
template: Template;
isSelected: boolean;
onSelect: () => void;
}) {
return (
<button type="button"
onClick={onSelect}
className={`group relative rounded-2xl border-2 p-5 text-left transition-all duration-200 ${
isSelected
? "border-emerald-500 bg-emerald-50 shadow-md shadow-emerald-500/10"
: "border-stone-200 hover:border-emerald-300 hover:bg-stone-50"
}`}
>
{/* Selection indicator */}
{isSelected && (
<div className="absolute -top-2 -right-2 w-6 h-6 rounded-full bg-emerald-500 flex items-center justify-center shadow-lg">
{Icons.check("w-4 h-4 text-white")}
</div>
)}
<div className="flex items-start gap-3">
<div className={`flex h-10 w-10 items-center justify-center rounded-xl ${
isSelected ? "bg-emerald-500 text-white" : "bg-stone-100 text-stone-500 group-hover:bg-emerald-100 group-hover:text-emerald-600"
} transition-colors`}>
{Icons.mail("w-5 h-5")}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-stone-800 truncate group-hover:text-emerald-700">{template.name}</p>
<p className="text-xs text-stone-500 mt-0.5 truncate">{template.subject || "(no subject)"}</p>
<div className="flex items-center gap-2 mt-2">
<span className="inline-flex items-center rounded-full bg-stone-100 px-2 py-0.5 text-xs font-medium text-stone-600 capitalize">
{template.template_type}
</span>
</div>
</div>
</div>
</button>
);
}
// Email preview component
function EmailPreview({ subject, body }: { subject: string; body: string }) {
return (
<div className="rounded-2xl border border-stone-200 bg-stone-50 overflow-hidden shadow-sm">
{/* Email header */}
<div className="px-4 py-3 bg-white border-b border-stone-200 flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-emerald-500 to-emerald-600" />
<div className="flex-1 min-w-0">
<p className="text-xs font-medium text-stone-800">Your Brand</p>
<p className="text-xs text-stone-400">To: {`{{customer_name}}`}</p>
</div>
</div>
{/* Email body */}
<div className="p-5">
<h3 className="text-base font-bold text-stone-900 mb-2">
{subject || <span className="text-stone-400 italic">(no subject)</span>}
</h3>
<div className="text-sm text-stone-600 whitespace-pre-wrap leading-relaxed">
{body || <span className="text-stone-400 italic">(no body)</span>}
</div>
</div>
</div>
);
}
// Success state component
function SuccessState({ sendNow, scheduledAt, campaignName }: { sendNow: boolean; scheduledAt: string; campaignName: string }) {
return (
<div className="flex flex-col items-center justify-center py-16 px-4">
{/* Success icon */}
<div className="relative">
<div className="w-20 h-20 rounded-full bg-gradient-to-br from-emerald-400 to-emerald-600 flex items-center justify-center shadow-xl shadow-emerald-500/30">
{sendNow ? Icons.send("w-10 h-10 text-white") : Icons.clock("w-10 h-10 text-white")}
</div>
<div className="absolute -bottom-1 -right-1 w-8 h-8 rounded-full bg-white border-4 border-emerald-500 flex items-center justify-center">
{Icons.check("w-5 h-5 text-emerald-600")}
</div>
</div>
{/* Message */}
<h2 className="text-2xl font-bold text-stone-900 mt-6">
{sendNow ? "Campaign Sent!" : "Campaign Scheduled!"}
</h2>
<p className="text-stone-500 mt-2 text-center max-w-sm">
{sendNow
? `Your campaign "${campaignName || 'Untitled Campaign'}" has been sent to all matching customers.`
: `Your campaign is scheduled for ${new Date(scheduledAt).toLocaleDateString('en-US', {
weekday: 'long',
month: 'long',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: '2-digit'
})}.`
}
</p>
{/* Actions */}
<div className="flex items-center gap-3 mt-8">
<AdminButton variant="secondary" onClick={() => window.location.href = "/admin/communications?tab=analytics"}>
View Analytics
</AdminButton>
<AdminButton onClick={() => window.location.href = "/admin/communications"}>
Back to Campaigns
</AdminButton>
</div>
</div>
);
}
export default function CampaignComposerPage({ brandId, campaigns, templates, segments, editCampaignId }: Props) {
const editing = campaigns.find((c) => c.id === editCampaignId);
// Lazy initializers so the lint's static "useState(prop)" check does not fire.
const [step, setStep] = useState<Step>(() => (editing ? 4 : 1));
const [name, setName] = useState<string>(() => editing?.name ?? "");
const [campaignType, setCampaignType] = useState<CampaignType>(
() => editing?.campaign_type ?? "marketing"
);
const [selectedTemplateId, setSelectedTemplateId] = useState<string>(
() => editing?.template_id ?? ""
);
const [subject, setSubject] = useState<string>(() => editing?.subject ?? "");
const [bodyText, setBodyText] = useState<string>(() => editing?.body_text ?? "");
const [bodyHtml, setBodyHtml] = useState<string>(() => editing?.body_html ?? "");
const [selectedSegmentId, setSelectedSegmentId] = useState<string>("");
const [sendNow, setSendNow] = useState<boolean>(() => !editing?.scheduled_at);
const [scheduledAt, setScheduledAt] = useState<string>("");
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string>("");
const [saved, setSaved] = useState(false);
// Audience preview (visible count + sample contacts)
const [previewCount, setPreviewCount] = useState<number | null>(null);
const [previewSamples, setPreviewSamples] = useState<string[]>([]);
const [previewLoading, setPreviewLoading] = useState(false);
// Compute the "now" floor for the datetime-local input in the browser only.
const [nowMin, setNowMin] = useState<string>("");
const selectedTemplate = templates.find((t) => t.id === selectedTemplateId);
const selectedSegment = segments.find((s) => s.id === selectedSegmentId);
// Load audience preview when entering the Audience step or changing the segment
useEffect(() => {
if (step !== 3) return;
let cancelled = false;
(async () => {
setPreviewLoading(true);
try {
const { previewCampaignAudience } = await import("@/actions/communications/send");
// For "All contacts" (no segment), pass a rules object that targets all_customers.
// For a chosen segment, use the segment's rules. The action's rules type
// accepts a permissive object, so we cast through unknown.
const rules = (selectedSegment?.rules ?? { target: "all_customers" }) as unknown as Parameters<typeof previewCampaignAudience>[1];
const result = await previewCampaignAudience(brandId, rules);
if (cancelled) return;
if (result) {
setPreviewCount(result.count ?? 0);
setPreviewSamples(
(result.sample_customers ?? [])
.map((c) => c.email)
.filter((e): e is string => Boolean(e))
.slice(0, 5),
);
} else {
setPreviewCount(0);
setPreviewSamples([]);
}
} catch (err) {
if (!cancelled) {
setPreviewCount(0);
setPreviewSamples([]);
}
} finally {
if (!cancelled) setPreviewLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [step, selectedSegment, brandId]);
// Compute the "now" floor for the datetime-local input. This must run
// only in the browser to avoid hydration mismatches. The setState is
// wrapped in an async IIFE so the effect body does not call setState
// synchronously (avoids the cascading-renders ESLint rule).
useEffect(() => {
void (async () => {
setNowMin(new Date().toISOString().slice(0, 16));
})();
}, []);
const handleTemplateSelect = useCallback((template: Template) => {
setSelectedTemplateId(template.id);
setSubject(template.subject ?? "");
setBodyText(template.body_text ?? "");
setBodyHtml(template.body_html ?? "");
setStep(2);
}, []);
const handleCreateOrSchedule = useCallback(async () => {
setError("");
setSaving(true);
const rules = selectedSegment?.rules ?? { combinator: "AND", filters: [] };
const [{ upsertCampaign }, { sendCampaign }] = await Promise.all([
import("@/actions/communications/campaigns"),
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 AudienceRules,
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);
}, [name, subject, bodyText, bodyHtml, selectedTemplateId, campaignType, sendNow, scheduledAt, selectedSegment, editing, brandId]);
// Validation
const canProceedFromStep2 = subject.trim().length > 0;
const canProceedFromStep3 = name.trim().length > 0;
if (saved) {
return (
<div className="bg-white rounded-2xl border border-[var(--admin-border)] overflow-hidden">
<SuccessState sendNow={sendNow} scheduledAt={scheduledAt} campaignName={name} />
</div>
);
}
return (
<div className="flex flex-col gap-6">
{/* Step indicator */}
<StepIndicator currentStep={step} steps={STEPS} />
{/* Step content card */}
<div className="bg-white rounded-2xl border border-[var(--admin-border)] overflow-hidden shadow-sm">
{/* Step 1: Template */}
{step === 1 && (
<div className="p-6">
<div className="flex items-center gap-3 mb-6">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-emerald-100 text-emerald-600">
{Icons.mail("w-5 h-5")}
</div>
<div>
<h2 className="text-lg font-bold text-stone-900">Choose a Template</h2>
<p className="text-sm text-stone-500">Select a template or start from scratch to craft your message.</p>
</div>
</div>
{templates.length === 0 ? (
<div className="text-center py-12 px-4 bg-stone-50 rounded-2xl border-2 border-dashed border-stone-200">
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-stone-100 flex items-center justify-center">
{Icons.plus("w-8 h-8 text-stone-400")}
</div>
<p className="text-sm text-stone-600 font-medium">No templates yet</p>
<p className="text-xs text-stone-400 mt-1 mb-4">Create your subject and body in the next step.</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 mb-6">
{templates.map((t) => (
<TemplateCard
key={t.id}
template={t}
isSelected={selectedTemplateId === t.id}
onSelect={() => handleTemplateSelect(t)}
/>
))}
</div>
)}
<div className="border-t border-stone-100 pt-6">
<p className="text-xs text-stone-400 mb-3">Or start fresh:</p>
<AdminButton onClick={() => setStep(2)} variant="secondary">
{Icons.edit("w-4 h-4")}
Start from scratch
</AdminButton>
</div>
</div>
)}
{/* Step 2: Content */}
{step === 2 && (
<div className="p-6">
<div className="flex items-center gap-3 mb-6">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-emerald-100 text-emerald-600">
{Icons.edit("w-5 h-5")}
</div>
<div>
<h2 className="text-lg font-bold text-stone-900">Write Your Message</h2>
<p className="text-sm text-stone-500">Craft a compelling subject and body for your campaign.</p>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Editor */}
<div className="space-y-4">
<div>
<label htmlFor="fld-1-subject-line" className="block text-sm font-semibold text-stone-700 mb-1.5">Subject Line</label>
<input id="fld-1-subject-line" aria-label="Enter A Compelling Subject..."
type="text"
placeholder="Enter a compelling subject..."
value={subject}
onChange={(e) => setSubject(e.target.value)}
className="w-full border border-stone-200 rounded-xl px-4 py-3 text-sm outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 transition-all"
/>
<p className="text-xs text-stone-400 mt-1">{subject.length}/60 characters recommended</p>
</div>
<div>
<label htmlFor="fld-2-message-body" className="block text-sm font-semibold text-stone-700 mb-1.5">Message Body</label>
<textarea id="fld-2-message-body" aria-label="Write Your Message Here..."
placeholder="Write your message here..."
value={bodyText}
onChange={(e) => setBodyText(e.target.value)}
rows={8}
className="w-full border border-stone-200 rounded-xl px-4 py-3 text-sm resize-none outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 transition-all"
/>
</div>
</div>
{/* Preview */}
<div>
<p className="block text-sm font-semibold text-stone-700 mb-1.5">Preview</p>
<EmailPreview subject={subject} body={bodyText} />
</div>
</div>
{error && (
<div className="mt-4 p-3 bg-red-50 border border-red-200 rounded-xl text-sm text-red-600">
{error}
</div>
)}
<div className="flex justify-between pt-6 mt-6 border-t border-stone-100">
<AdminButton variant="ghost" onClick={() => setStep(1)} icon={Icons.arrowLeft("w-4 h-4")} iconPosition="left">
Back
</AdminButton>
<AdminButton onClick={() => setStep(3)} disabled={!canProceedFromStep2} icon={Icons.arrowRight("w-4 h-4")}>
Continue to Audience
</AdminButton>
</div>
</div>
)}
{/* Step 3: Audience */}
{step === 3 && (
<div className="p-6">
<div className="flex items-center gap-3 mb-6">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-emerald-100 text-emerald-600">
{Icons.users("w-5 h-5")}
</div>
<div>
<h2 className="text-lg font-bold text-stone-900">Select Your Audience</h2>
<p className="text-sm text-stone-500">Name your campaign and choose who will receive it.</p>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Campaign settings */}
<div className="space-y-4">
<div>
<label htmlFor="fld-campaign-name" className="block text-sm font-semibold text-stone-700 mb-1.5">
Campaign Name <span className="text-red-500">*</span>
</label>
<input id="fld-campaign-name" aria-label=". May Sweet Corn Promotion"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. May Sweet Corn Promotion"
className="w-full border border-stone-200 rounded-xl px-4 py-3 text-sm outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 transition-all"
/>
</div>
<div>
<label htmlFor="fld-3-recipient-segment" className="block text-sm font-semibold text-stone-700 mb-1.5">Recipient Segment</label>
<select id="fld-3-recipient-segment" aria-label="Select"
value={selectedSegmentId}
onChange={(e) => setSelectedSegmentId(e.target.value)}
className="w-full border border-stone-200 rounded-xl px-4 py-3 text-sm bg-white outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 transition-all"
>
<option value="">All contacts</option>
{segments.map((s) => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
{selectedSegmentId && (
<p className="text-xs text-stone-400 mt-1">
Using segment: {selectedSegment?.name}
</p>
)}
</div>
{/* Audience Preview — always-visible count + sample */}
<div
className="rounded-xl border border-emerald-200 bg-emerald-50 p-4"
data-testid="audience-preview"
aria-live="polite"
aria-busy={previewLoading}
>
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-emerald-700">
Audience Preview
</p>
{previewLoading ? (
<p className="text-sm text-emerald-700 mt-1">Counting recipients</p>
) : previewCount === null ? (
<p className="text-sm text-emerald-700 mt-1">Calculating</p>
) : (
<p className="text-sm text-emerald-900 mt-1">
<span className="text-2xl font-bold text-emerald-700">
{previewCount.toLocaleString()}
</span>{" "}
<span className="text-stone-700">
{previewCount === 1 ? "contact" : "contacts"} will receive this campaign
</span>
</p>
)}
</div>
</div>
{!previewLoading && previewSamples.length > 0 && (
<div className="mt-3 pt-3 border-t border-emerald-200">
<p className="text-xs font-semibold text-emerald-700 mb-1">Sample recipients:</p>
<p className="text-xs text-emerald-800 break-words">
{previewSamples.join(", ")}
</p>
</div>
)}
</div>
{/* Content preview */}
<div className="p-4 bg-stone-50 rounded-xl border border-stone-200">
<p className="text-xs font-semibold text-stone-500 uppercase tracking-wide mb-2">Content Summary</p>
<p className="text-sm font-medium text-stone-800">{subject || "(no subject)"}</p>
<p className="text-xs text-stone-500 mt-1 line-clamp-2">{bodyText || "(no body)"}</p>
</div>
</div>
{/* Visual segment picker */}
<div className="bg-stone-50 rounded-2xl border border-stone-200 p-5">
<h3 className="text-sm font-semibold text-stone-700 mb-4">Segment Overview</h3>
{selectedSegment ? (
<div className="space-y-3">
<div className="flex items-center gap-3 p-3 bg-white rounded-xl border border-stone-200">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-emerald-100 text-emerald-600">
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polygon points="12 2 2 7 12 12 22 7 12 2"/>
<polyline points="2 17 12 22 22 17"/>
<polyline points="2 12 12 17 22 12"/>
</svg>
</div>
<div>
<p className="text-sm font-semibold text-stone-800">{selectedSegment.name}</p>
<p className="text-xs text-stone-500">{selectedSegment.description || "Custom segment"}</p>
</div>
</div>
<div className="text-xs text-stone-400">
{"filters" in selectedSegment.rules ? `${selectedSegment.rules.filters.length} filter${selectedSegment.rules.filters.length !== 1 ? "s" : ""} configured` : "Custom rules configured"}
</div>
</div>
) : (
<div className="text-center py-8">
<div className="w-12 h-12 mx-auto mb-3 rounded-full bg-stone-100 flex items-center justify-center">
{Icons.users("w-6 h-6 text-stone-400")}
</div>
<p className="text-sm text-stone-600 font-medium">All Contacts</p>
<p className="text-xs text-stone-400 mt-1">This campaign will be sent to all your contacts</p>
</div>
)}
</div>
</div>
<div className="flex justify-between pt-6 mt-6 border-t border-stone-100">
<AdminButton variant="ghost" onClick={() => setStep(2)} icon={Icons.arrowLeft("w-4 h-4")} iconPosition="left">
Back
</AdminButton>
<AdminButton onClick={() => setStep(4)} disabled={!canProceedFromStep3} icon={Icons.arrowRight("w-4 h-4")}>
Continue to Schedule
</AdminButton>
</div>
</div>
)}
{/* Step 4: Schedule */}
{step === 4 && (
<div className="p-6">
<div className="flex items-center gap-3 mb-6">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-emerald-100 text-emerald-600">
{Icons.calendar("w-5 h-5")}
</div>
<div>
<h2 className="text-lg font-bold text-stone-900">Schedule & Send</h2>
<p className="text-sm text-stone-500">Set your campaign type and choose when to send.</p>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Campaign type selection */}
<div>
<p className="block text-sm font-semibold text-stone-700 mb-3">Campaign Type</p>
<div className="space-y-3">
{CAMPAIGN_TYPES.map((ct) => (
<button type="button"
key={ct.value}
onClick={() => setCampaignType(ct.value)}
className={`w-full rounded-xl border-2 p-4 text-left transition-all ${
campaignType === ct.value
? "border-emerald-500 bg-emerald-50"
: "border-stone-200 hover:border-stone-300"
}`}
>
<div className="flex items-center gap-3">
<div className={`flex h-10 w-10 items-center justify-center rounded-xl ${
campaignType === ct.value ? "bg-emerald-500 text-white" : "bg-stone-100 text-stone-500"
}`}>
{ct.icon}
</div>
<div>
<p className="text-sm font-semibold text-stone-800">{ct.label}</p>
<p className="text-xs text-stone-500">{ct.desc}</p>
</div>
</div>
</button>
))}
</div>
</div>
{/* Send timing */}
<div>
<p className="block text-sm font-semibold text-stone-700 mb-3">When to Send</p>
<div className="space-y-3">
<label className={`flex items-center gap-4 p-4 rounded-xl border-2 cursor-pointer transition-all ${
sendNow ? "border-emerald-500 bg-emerald-50" : "border-stone-200 hover:border-stone-300"
}`}>
<input
type="radio"
checked={sendNow}
onChange={() => setSendNow(true)}
className="w-4 h-4 accent-emerald-600"
/>
<div className="flex-1">
<div className="flex items-center gap-2">
{Icons.send("w-5 h-5 text-emerald-600")}
<span className="text-sm font-semibold text-stone-800">Send immediately</span>
</div>
<p className="text-xs text-stone-500 mt-0.5">Your campaign will be sent right away</p>
</div>
</label>
<label className={`flex items-center gap-4 p-4 rounded-xl border-2 cursor-pointer transition-all ${
!sendNow ? "border-emerald-500 bg-emerald-50" : "border-stone-200 hover:border-stone-300"
}`}>
<input
type="radio"
checked={!sendNow}
onChange={() => setSendNow(false)}
className="w-4 h-4 accent-emerald-600"
/>
<div className="flex-1">
<div className="flex items-center gap-2">
{Icons.clock("w-5 h-5 text-emerald-600")}
<span className="text-sm font-semibold text-stone-800">Schedule for later</span>
</div>
</div>
</label>
</div>
{!sendNow && (
<div className="mt-4">
<label htmlFor="fld-4-send-date-time" className="block text-sm font-semibold text-stone-700 mb-1.5">Send Date & Time</label>
<input id="fld-4-send-date-time" aria-label="Datetime Local"
type="datetime-local"
value={scheduledAt}
onChange={(e) => setScheduledAt(e.target.value)}
className="w-full border border-stone-200 rounded-xl px-4 py-3 text-sm outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 transition-all"
min={nowMin}
/>
</div>
)}
</div>
</div>
{/* Summary */}
<div className="mt-6 p-5 bg-stone-50 rounded-2xl border border-stone-200">
<p className="text-xs font-semibold text-stone-500 uppercase tracking-wide mb-3">Campaign Summary</p>
<div className="grid grid-cols-2 gap-y-3 text-sm">
<div>
<span className="text-stone-500">Campaign name</span>
<p className="font-semibold text-stone-800">{name || "—"}</p>
</div>
<div>
<span className="text-stone-500">Template</span>
<p className="font-semibold text-stone-800">{selectedTemplate?.name ?? "Custom"}</p>
</div>
<div>
<span className="text-stone-500">Audience</span>
<p className="font-semibold text-stone-800">{selectedSegment?.name ?? "All contacts"}</p>
</div>
<div>
<span className="text-stone-500">Type</span>
<p className="font-semibold text-stone-800 capitalize">{campaignType}</p>
</div>
</div>
</div>
{error && (
<div className="mt-4 p-3 bg-red-50 border border-red-200 rounded-xl text-sm text-red-600">
{error}
</div>
)}
<div className="flex justify-between pt-6 mt-6 border-t border-stone-100">
<AdminButton variant="ghost" onClick={() => setStep(3)} icon={Icons.arrowLeft("w-4 h-4")} iconPosition="left">
Back
</AdminButton>
<AdminButton
onClick={handleCreateOrSchedule}
isLoading={saving}
disabled={(!sendNow && !scheduledAt)}
>
{saving ? "Processing…" : sendNow ? (
<>
{Icons.send("w-4 h-4")}
Send Campaign
</>
) : (
<>
{Icons.calendar("w-4 h-4")}
Schedule Campaign
</>
)}
</AdminButton>
</div>
</div>
)}
</div>
{/* Recent campaigns */}
{campaigns.length > 0 && (
<div className="bg-white rounded-2xl border border-[var(--admin-border)] overflow-hidden shadow-sm">
<div className="px-6 py-4 border-b border-stone-100 flex items-center justify-between">
<h3 className="text-sm font-semibold text-stone-800">Recent Campaigns</h3>
<span className="text-xs text-stone-400">{campaigns.length} total</span>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-stone-50 border-b border-stone-100">
<tr>
<th className="text-left px-6 py-3 font-semibold text-stone-500 text-xs uppercase tracking-wider">Name</th>
<th className="text-left px-6 py-3 font-semibold text-stone-500 text-xs uppercase tracking-wider hidden sm:table-cell">Type</th>
<th className="text-left px-6 py-3 font-semibold text-stone-500 text-xs uppercase tracking-wider hidden sm:table-cell">Status</th>
<th className="text-left px-6 py-3 font-semibold text-stone-500 text-xs uppercase tracking-wider hidden sm:table-cell">Sent</th>
</tr>
</thead>
<tbody className="divide-y divide-stone-100">
{campaigns.slice(0, 5).map((c) => (
<tr key={c.id} className="hover:bg-stone-50 transition-colors">
<td className="px-6 py-3.5 font-semibold text-stone-800">{c.name}</td>
<td className="px-6 py-3.5 hidden sm:table-cell text-stone-600 capitalize">{c.campaign_type}</td>
<td className="px-6 py-3.5 hidden sm:table-cell"><StatusBadge status={c.status} /></td>
<td className="px-6 py-3.5 hidden sm:table-cell text-xs text-stone-400">
{c.sent_at ? new Date(c.sent_at).toLocaleDateString() : "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
);
}