qualifier / frontend /src /components /campaigns /CreateCampaignWizard.jsx
Sethu0330's picture
Add FTL RFQ Qualifier agent + full SALES CRM app for HF Spaces testing
005df22 unverified
Raw History Blame Contribute Delete
73.4 kB
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { apiFetch } from '@/lib/api';
import { mergeSequenceIntoContacts } from '@/lib/mergeSequenceIntoContacts';
import { buildGenerateSequencesUrl } from '@/lib/campaignGenerationStream';
import { createPortal } from 'react-dom';
import {
X,
Upload,
ArrowRight,
ArrowLeft,
Sparkles,
Loader2,
CheckCircle2,
Search,
Filter,
Mail,
UserPlus,
MessageCircle,
Clock,
ChevronRight,
AlertTriangle,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Progress } from '@/components/ui/progress';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { cn } from '@/lib/utils';
import CampaignSequenceBuilder, { createDefaultSequenceSteps } from '@/components/campaigns/CampaignSequenceBuilder';
import ProductSelector from '@/components/products/ProductSelector';
import PromptEditor, {
DEFAULT_TEMPLATES,
LINKEDIN_DEFAULT_TEMPLATES,
} from '@/components/prompts/PromptEditor';
import SequenceCard from '@/components/sequences/SequenceCard';
import { formatWaitStep } from '@/lib/waitStepUtils';
const STEPS = [
{ id: 1, label: 'Upload & Select' },
{ id: 2, label: 'Configure Sequence' },
{ id: 3, label: 'Generate Contents' },
{ id: 4, label: 'Review & Launch' },
];
const WIZARD_SEQUENCE_TAG = '🔒 CAMPAIGN WIZARD — SEQUENCE';
function defaultEmailSendingSettings() {
let timezone = '';
try {
timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || '';
} catch {
timezone = '';
}
return {
timezone,
businessHoursStart: 8,
businessHoursEnd: 18,
businessDays: [1, 2, 3, 4, 5],
intervalSeconds: 60,
};
}
function estimateCsvRows(file) {
if (!file) return 0;
return new Promise((resolve) => {
const reader = new FileReader();
reader.onload = (e) => {
const text = String(e.target?.result || '');
const lines = text.split(/\r?\n/).filter((line) => line.trim().length > 0);
resolve(Math.max(0, lines.length - 1));
};
reader.onerror = () => resolve(0);
reader.readAsText(file.slice(0, Math.min(file.size, 512 * 1024)));
});
}
function mailboxLabelForAppendixStep(step, mailboxDefaults) {
if (step.channel !== 'gmail' || !mailboxDefaults) return '';
const ref =
step.unipile_account_ref_id ?? mailboxDefaults.default_mailbox_unipile_account_ref_id ?? null;
if (ref == null) return '';
const a = (mailboxDefaults.accounts || []).find((x) => Number(x.id) === Number(ref));
return ((a?.display_name || a?.label || '') || '').trim();
}
function buildCampaignSequenceAppendix(steps, mailboxDefaults = null, parallelTracks = false) {
const lines = [
WIZARD_SEQUENCE_TAG,
'(AUTHORITATIVE — OVERRIDES ANY FIXED “N EMAILS” OR “N MESSAGES” COUNT IN THIS PROMPT)',
'',
'The campaign sequence is fixed. Generate content only for the channels below; the system maps rows to Gmail vs LinkedIn.',
'Respect wait steps as pacing context between touches (do not invent extra touches for waits).',
'',
];
if (parallelTracks) {
lines.push(
'Execution note: Gmail and LinkedIn run on separate timelines — a wait after an email does not delay LinkedIn steps (and vice versa).',
''
);
}
let n = 0;
(steps || []).forEach((s) => {
if (s.type === 'wait') {
lines.push(`• ${formatWaitStep(s)}`);
}
if (s.type === 'action') {
n += 1;
if (s.channel === 'gmail') {
const mb = mailboxLabelForAppendixStep(s, mailboxDefaults);
lines.push(
`${n}. Gmail — ${s.title || 'Email'}${mb ? ` (sending mailbox: ${mb})` : ''}`
);
} else if (s.channel === 'linkedin') {
const kind =
s.action === 'linkedin_connect'
? 'LinkedIn connection request (must be the first LinkedIn output before any DM)'
: 'LinkedIn DM / follow-up';
lines.push(`${n}. ${kind} — ${s.title || ''}`);
}
}
});
lines.push(
'',
'LinkedIn: the first LinkedIn message in your output must be the connection request whenever the sequence includes a connection step before DMs.',
'Write exactly one labeled LinkedIn message (Message i) per LinkedIn action listed above — same count and order; do not add extra LinkedIn messages beyond those actions.',
'Keep narrative continuity with Gmail touches in the same campaign week where relevant.'
);
return lines.join('\n');
}
function progressFromGenerationStatus(st) {
if (st?.is_complete) return 100;
const total = st.total_contacts || 0;
if (!total) return 0;
const done = st.completed_count || 0;
const exp =
st.campaign_sequence_actions && total > 0
? total * st.campaign_sequence_actions
: st.has_linkedin_prompts
? total * 2
: total;
return exp ? Math.min(100, Math.round((done / exp) * 100)) : 0;
}
function sequenceListToContacts(list) {
let contacts = [];
(list || []).forEach((row) => {
contacts = mergeSequenceIntoContacts(contacts, {
id: row.id,
emailNumber: row.emailNumber,
firstName: row.firstName,
lastName: row.lastName,
email: row.email,
company: row.company,
title: row.title,
product: row.product,
subject: row.subject,
emailContent: row.emailContent,
channel: row.channel || 'email',
stepOrder: row.stepOrder != null ? row.stepOrder : undefined,
});
});
contacts.sort((a, b) => (a.id || 0) - (b.id || 0));
return contacts;
}
function WizardSequencePreview({
contacts,
sequences,
isGenerating,
generationComplete,
generationStarted,
progress,
contactCount,
selectedProducts,
sequenceHasLinkedin,
genPhase,
hasFailures,
}) {
const [searchQuery, setSearchQuery] = useState('');
const [filterProduct, setFilterProduct] = useState('all');
const [displayedCount, setDisplayedCount] = useState(50);
useEffect(() => {
setDisplayedCount(50);
}, [searchQuery, filterProduct]);
const filteredContacts = contacts.filter((contact) => {
const matchesSearch =
searchQuery === '' ||
contact.firstName?.toLowerCase().includes(searchQuery.toLowerCase()) ||
contact.lastName?.toLowerCase().includes(searchQuery.toLowerCase()) ||
contact.company?.toLowerCase().includes(searchQuery.toLowerCase()) ||
contact.email?.toLowerCase().includes(searchQuery.toLowerCase());
const matchesFilter = filterProduct === 'all' || contact.product === filterProduct;
return matchesSearch && matchesFilter;
});
const displayedContacts = filteredContacts.slice(0, displayedCount);
const hasMore = filteredContacts.length > displayedCount;
const showProgress =
generationStarted && (isGenerating || !generationComplete);
const phaseLabel =
sequenceHasLinkedin && genPhase === 'linkedin' && isGenerating
? 'LinkedIn sequences'
: 'Email sequences';
return (
<div className="w-full space-y-4">
{generationStarted ? (
<div className="mb-2 rounded-2xl border border-slate-200 bg-white p-6">
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-3">
{generationComplete && hasFailures ? (
<div className="rounded-xl bg-amber-100 p-3">
<AlertTriangle className="h-6 w-6 text-amber-600" />
</div>
) : generationComplete ? (
<div className="rounded-xl bg-green-100 p-3">
<CheckCircle2 className="h-6 w-6 text-green-600" />
</div>
) : (
<div className="rounded-xl bg-violet-100 p-3">
<Loader2 className="h-6 w-6 animate-spin text-violet-600" />
</div>
)}
<div>
<h3 className="font-semibold text-slate-800">
{generationComplete
? hasFailures
? 'Generation finished with errors'
: 'Generation complete!'
: `Generating ${phaseLabel}…`}
</h3>
<p className="text-sm text-slate-500">
{contacts.length} contacts · {sequences.length} generated rows
{contactCount ? ` · ~${contactCount} contacts in file` : ''}
</p>
</div>
</div>
</div>
{showProgress ? <Progress value={progress} className="h-2" /> : null}
</div>
) : null}
{sequences.length > 0 && (
<div className="flex flex-col gap-3 sm:flex-row">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<Input
placeholder="Search contacts…"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10"
/>
</div>
<Select value={filterProduct} onValueChange={setFilterProduct}>
<SelectTrigger className="w-full sm:w-48">
<Filter className="mr-2 h-4 w-4 text-slate-400" />
<SelectValue placeholder="Filter by product" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Products</SelectItem>
{selectedProducts.map((product) => (
<SelectItem key={product.id} value={product.name}>
{product.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div className="custom-scrollbar max-h-[min(420px,50vh)] space-y-3 overflow-y-auto pr-1">
{displayedContacts.map((contact, index) => (
<SequenceCard
key={contact.id || `${contact.firstName}-${contact.lastName}-${contact.email}`}
contact={contact}
index={index}
/>
))}
{hasMore && (
<div className="py-3 text-center">
<Button
type="button"
variant="outline"
onClick={() => setDisplayedCount((c) => Math.min(c + 50, filteredContacts.length))}
>
Load more ({filteredContacts.length - displayedCount} remaining)
</Button>
</div>
)}
</div>
{!generationStarted && contacts.length === 0 && sequences.length === 0 && (
<div className="rounded-xl border border-dashed border-slate-200 bg-slate-50/80 py-12 text-center text-sm text-slate-500">
Generated messages will appear here after you click Generate. You can continue to the next step
while generation runs in the background.
</div>
)}
</div>
);
}
function ReviewSequenceTimeline({ steps, parallelTracks }) {
if (!steps?.length) {
return <p className="text-sm text-slate-500">No sequence steps.</p>;
}
return (
<div>
{parallelTracks ? (
<p className="mb-3 text-xs text-slate-600">
Parallel tracks: Gmail and LinkedIn advance on separate timers — waits only apply within the same
channel.
</p>
) : null}
<div className="flex flex-wrap items-center gap-1 py-2">
{steps.map((s, i) => (
<React.Fragment key={s.id || i}>
{i > 0 ? <ChevronRight className="mx-0.5 h-4 w-4 shrink-0 text-slate-300" /> : null}
{s.type === 'wait' ? (
<span className="inline-flex items-center gap-1 rounded-lg border border-dashed border-blue-200 bg-sky-50 px-2.5 py-1.5 text-xs font-medium text-slate-700">
<Clock className="h-3.5 w-3.5 text-blue-600" />
Wait {formatWaitStep(s, { short: true })}
</span>
) : (
<span
className={cn(
'inline-flex max-w-[200px] items-center gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs font-medium',
s.channel === 'linkedin'
? 'border-sky-200 bg-sky-50 text-sky-900'
: 'border-violet-200 bg-violet-50 text-violet-900'
)}
>
{s.action === 'linkedin_connect' ? (
<UserPlus className="h-3.5 w-3.5 shrink-0" />
) : s.channel === 'linkedin' ? (
<MessageCircle className="h-3.5 w-3.5 shrink-0" />
) : (
<Mail className="h-3.5 w-3.5 shrink-0" />
)}
<span className="truncate">{s.title || (s.channel === 'gmail' ? 'Email' : 'LinkedIn')}</span>
</span>
)}
</React.Fragment>
))}
</div>
</div>
);
}
export default function CreateCampaignWizard({
open,
onOpenChange,
onComplete,
onDraftPersist,
initialCampaign = null,
}) {
const [step, setStep] = useState(1);
const [campaignName, setCampaignName] = useState('');
const [prospectFile, setProspectFile] = useState(null);
const [wizardUpload, setWizardUpload] = useState(null);
const [uploadingCsv, setUploadingCsv] = useState(false);
const [dragOver, setDragOver] = useState(false);
const [estimatedContacts, setEstimatedContacts] = useState(0);
const [sequenceSteps, setSequenceSteps] = useState(() => createDefaultSequenceSteps());
const [parallelTracks, setParallelTracks] = useState(false);
const [emailSendingSettings, setEmailSendingSettings] = useState(() => defaultEmailSendingSettings());
const [linkedinDefaults, setLinkedinDefaults] = useState(null);
const [mailboxDefaults, setMailboxDefaults] = useState(null);
const [selectedProducts, setSelectedProducts] = useState([]);
const [prompts, setPrompts] = useState({});
const [linkedinPrompts, setLinkedinPrompts] = useState({});
const [genSequences, setGenSequences] = useState([]);
const [genContacts, setGenContacts] = useState([]);
const [genProgress, setGenProgress] = useState(0);
const [genRunning, setGenRunning] = useState(false);
const [genComplete, setGenComplete] = useState(false);
const [genPhase, setGenPhase] = useState('email');
const [genRunId, setGenRunId] = useState(0);
const [genPreviewOnly, setGenPreviewOnly] = useState(false);
const [genFailedContacts, setGenFailedContacts] = useState([]);
const [genStopping, setGenStopping] = useState(false);
const [genStopTakingLong, setGenStopTakingLong] = useState(false);
const genStopTimerRef = useRef(null);
useEffect(() => () => clearTimeout(genStopTimerRef.current), []);
const eventSourceRef = useRef(null);
const promptEditorRef = useRef(null);
const prevGenRunIdRef = useRef(null);
const previewTriggeredRef = useRef(false);
const genRunOptionsRef = useRef({ reset: true, preview: false });
const hydratedCampaignIdRef = useRef(null);
const openRef = useRef(open);
openRef.current = open;
const sequenceHasLinkedin = useMemo(
() =>
sequenceSteps.some(
(s) => s.type === 'action' && s.channel === 'linkedin' && s.action !== 'email'
),
[sequenceSteps]
);
const sequenceHasGmail = useMemo(
() => sequenceSteps.some((s) => s.type === 'action' && s.channel === 'gmail'),
[sequenceSteps]
);
const reset = useCallback(() => {
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
setStep(1);
setCampaignName('');
setProspectFile(null);
setWizardUpload(null);
setUploadingCsv(false);
setEstimatedContacts(0);
setDragOver(false);
setSequenceSteps(createDefaultSequenceSteps());
setParallelTracks(false);
setEmailSendingSettings(defaultEmailSendingSettings());
setSelectedProducts([]);
setPrompts({});
setLinkedinPrompts({});
setGenSequences([]);
setGenContacts([]);
setGenProgress(0);
setGenRunning(false);
setGenComplete(false);
setGenPhase('email');
setGenRunId(0);
setGenPreviewOnly(false);
setGenFailedContacts([]);
setGenStopping(false);
setGenStopTakingLong(false);
clearTimeout(genStopTimerRef.current);
prevGenRunIdRef.current = null;
previewTriggeredRef.current = false;
}, []);
useEffect(() => {
if (!open) {
if (genRunning) return;
reset();
}
}, [open, genRunning, reset]);
/** Fresh "Create" session — clear any in-component state (including orphan streams). */
useEffect(() => {
if (open && !initialCampaign?.id) {
reset();
}
}, [open, initialCampaign?.id, reset]);
// Debounced backend auto-save of campaign identity — fires on name edits and every step
// transition, not only when the wizard is explicitly closed, so an abrupt tab close never
// strands already-generated server-side content with no way back to it.
useEffect(() => {
if (!open || !wizardUpload?.fileId) return;
const fileId = wizardUpload.fileId;
const name = campaignName.trim() || 'Untitled campaign';
const timer = setTimeout(() => {
apiFetch(`/api/campaign-files/${encodeURIComponent(fileId)}/meta`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ file_id: fileId, campaign_name: name, status: 'draft' }),
}).catch((e) => console.error(e));
}, 800);
return () => clearTimeout(timer);
}, [open, wizardUpload?.fileId, campaignName, step]);
useEffect(() => {
if (!open || !initialCampaign?.id) return;
if (hydratedCampaignIdRef.current === initialCampaign.id) return;
hydratedCampaignIdRef.current = initialCampaign.id;
const incomingFile = initialCampaign.fileId || null;
const currentFile = wizardUpload?.fileId ?? null;
if (eventSourceRef.current) {
const keepStream =
!!incomingFile && !!currentFile && incomingFile === currentFile;
if (!keepStream) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
}
setGenSequences([]);
setGenContacts([]);
setGenRunId(0);
prevGenRunIdRef.current = null;
setCampaignName(initialCampaign.name || '');
setProspectFile(null);
if (initialCampaign.fileId) {
setWizardUpload({
fileId: initialCampaign.fileId,
contactCount: initialCampaign.contacts ?? 0,
name: initialCampaign.prospectFileName || '',
});
} else {
setWizardUpload(null);
}
const seq = initialCampaign.sequence;
const hasLocalSequence = Array.isArray(seq) && seq.length > 0;
if (hasLocalSequence) {
setSequenceSteps(seq);
} else {
setSequenceSteps(createDefaultSequenceSteps());
}
// Local dashboard state can be missing the sequence entirely — e.g. a campaign
// reconciled in from the backend list on a fresh session never had local `sequence`
// populated. Fall back to the backend's saved sequence_plan_json so reopening a
// campaign never silently resets it to the default Gmail-only template. Email pacing
// settings live only on the backend record (never in local dashboard state), so this
// fetch also runs when a local sequence is already present, purely to restore those.
if (initialCampaign.fileId) {
const fid = initialCampaign.fileId;
const campaignId = initialCampaign.id;
(async () => {
try {
const res = await apiFetch(`/api/campaign-files/${encodeURIComponent(fid)}`);
if (!res.ok) return;
const detail = await res.json();
if (hydratedCampaignIdRef.current !== campaignId) return;
if (!hasLocalSequence && Array.isArray(detail.sequence_plan) && detail.sequence_plan.length > 0) {
setSequenceSteps(detail.sequence_plan);
setParallelTracks(!!detail.parallel_tracks);
}
if (detail.email_timezone !== undefined) {
setEmailSendingSettings({
timezone: detail.email_timezone || defaultEmailSendingSettings().timezone,
businessHoursStart:
detail.email_business_hours_start ?? 8,
businessHoursEnd: detail.email_business_hours_end ?? 18,
businessDays: (detail.email_business_days || '1,2,3,4,5')
.split(',')
.map((d) => parseInt(d, 10))
.filter((d) => !Number.isNaN(d)),
intervalSeconds: detail.email_send_interval_seconds ?? 60,
});
}
if (!hasLocalSequence && detail.prompts && Object.keys(detail.prompts).length) {
setPrompts((prev) => (Object.keys(prev).length ? prev : detail.prompts));
}
if (detail.linkedin_prompts && Object.keys(detail.linkedin_prompts).length) {
setLinkedinPrompts((prev) =>
Object.keys(prev).length ? prev : detail.linkedin_prompts
);
}
if (Array.isArray(detail.products) && detail.products.length) {
setSelectedProducts((prev) =>
prev.length
? prev
: detail.products.map((name, i) => ({ id: `backend-${i}-${name}`, name }))
);
}
} catch (e) {
console.error(e);
}
})();
}
setParallelTracks(!!initialCampaign.parallelTracks);
setSelectedProducts(
Array.isArray(initialCampaign.selectedProducts) ? initialCampaign.selectedProducts : []
);
setEstimatedContacts(initialCampaign.contacts || 0);
setGenComplete(!!initialCampaign.generationComplete);
setGenRunning(false);
setGenProgress(initialCampaign.generationProgressPercent ?? 0);
const ws =
typeof initialCampaign.wizardStep === 'number'
? Math.min(4, Math.max(1, initialCampaign.wizardStep))
: 1;
setStep(ws);
setPrompts(
initialCampaign.wizardPrompts && typeof initialCampaign.wizardPrompts === 'object'
? { ...initialCampaign.wizardPrompts }
: {}
);
setLinkedinPrompts(
initialCampaign.wizardLinkedinPrompts &&
typeof initialCampaign.wizardLinkedinPrompts === 'object'
? { ...initialCampaign.wizardLinkedinPrompts }
: {}
);
}, [open, initialCampaign?.id, wizardUpload?.fileId]);
useEffect(() => {
if (!open || !wizardUpload?.fileId || !initialCampaign?.id) return;
let cancelled = false;
(async () => {
try {
const [stRes, seqRes] = await Promise.all([
apiFetch(`/api/generation-status?file_id=${encodeURIComponent(wizardUpload.fileId)}`),
apiFetch(`/api/sequences?file_id=${encodeURIComponent(wizardUpload.fileId)}`),
]);
if (cancelled) return;
if (seqRes.ok) {
const data = await seqRes.json();
const list = data.sequences || [];
setGenSequences(list);
setGenContacts(sequenceListToContacts(list));
}
if (stRes.ok) {
const st = await stRes.json();
if (!cancelled) {
setGenComplete(!!st.is_complete);
setGenProgress(progressFromGenerationStatus(st));
}
}
} catch (e) {
if (!cancelled) console.error(e);
}
})();
return () => {
cancelled = true;
};
}, [open, wizardUpload?.fileId, initialCampaign?.id]);
useEffect(() => {
if (!open) hydratedCampaignIdRef.current = null;
}, [open]);
useEffect(() => {
if (!prospectFile) {
setEstimatedContacts(0);
return;
}
let cancelled = false;
estimateCsvRows(prospectFile).then((n) => {
if (!cancelled) setEstimatedContacts(n);
});
return () => {
cancelled = true;
};
}, [prospectFile]);
useEffect(() => {
if (!open) {
setLinkedinDefaults(null);
setMailboxDefaults(null);
return;
}
let cancelled = false;
Promise.allSettled([
apiFetch('/api/unipile/linkedin/campaign-defaults').then((r) => r.json()),
apiFetch('/api/unipile/mailbox/campaign-defaults').then((r) => r.json()),
]).then(([li, mb]) => {
if (cancelled) return;
setLinkedinDefaults(
li.status === 'fulfilled'
? li.value
: {
accounts: [],
linkedin_profile_display_name: '',
default_unipile_account_ref_id: null,
}
);
setMailboxDefaults(
mb.status === 'fulfilled'
? mb.value
: {
accounts: [],
mailbox_profile_display_name: '',
default_mailbox_unipile_account_ref_id: null,
}
);
});
return () => {
cancelled = true;
};
}, [open]);
useEffect(() => {
if (!linkedinDefaults) return;
setSequenceSteps((prev) =>
prev.map((s) => {
if (s.type !== 'action' || s.channel !== 'linkedin') return s;
const ref =
s.unipile_account_ref_id ?? linkedinDefaults.default_unipile_account_ref_id ?? null;
const sender =
(s.sender_profile_name || '').trim() ||
(linkedinDefaults.linkedin_profile_display_name || '').trim();
return {
...s,
unipile_account_ref_id: ref,
sender_profile_name: sender,
};
})
);
}, [linkedinDefaults]);
useEffect(() => {
if (!mailboxDefaults) return;
const accounts = mailboxDefaults.accounts || [];
const fallbackId = accounts[0]?.id != null ? Number(accounts[0].id) : null;
setSequenceSteps((prev) =>
prev.map((s) => {
if (s.type !== 'action' || s.channel !== 'gmail') return s;
const ref =
s.unipile_account_ref_id ??
(mailboxDefaults.default_mailbox_unipile_account_ref_id != null
? Number(mailboxDefaults.default_mailbox_unipile_account_ref_id)
: null) ??
fallbackId;
return { ...s, unipile_account_ref_id: ref };
})
);
}, [mailboxDefaults]);
useEffect(() => {
if (step !== 3 || selectedProducts.length === 0) return;
const appendix = buildCampaignSequenceAppendix(sequenceSteps, mailboxDefaults, parallelTracks);
setPrompts((prev) => {
const next = { ...prev };
let changed = false;
selectedProducts.forEach((p) => {
let cur = (next[p.name] || '').trim();
if (!cur) {
cur = DEFAULT_TEMPLATES[p.name] || '';
}
if (!cur || cur.includes(WIZARD_SEQUENCE_TAG)) return;
next[p.name] = `${cur}\n\n${appendix}`;
changed = true;
});
return changed ? next : prev;
});
if (!sequenceHasLinkedin) return;
setLinkedinPrompts((prev) => {
const next = { ...prev };
let changed = false;
selectedProducts.forEach((p) => {
let cur = (next[p.name] || '').trim();
if (!cur) {
cur =
LINKEDIN_DEFAULT_TEMPLATES[p.name] ||
`🔒 LINKEDIN SYSTEM PROMPT\n\nYou write LinkedIn connection notes and DMs for ${p.name}. Message 1: peer-style connection only (~280 chars), no pitch; later messages add detail. Sign DMs with {{sender_name}}.\nMatch the Message count in the generation request exactly.`;
}
if (!cur || cur.includes(WIZARD_SEQUENCE_TAG)) return;
next[p.name] = `${cur}\n\n${appendix}`;
changed = true;
});
return changed ? next : prev;
});
}, [step, sequenceSteps, selectedProducts, sequenceHasLinkedin, mailboxDefaults, parallelTracks]);
const savePromptsToServer = useCallback(async () => {
promptEditorRef.current?.commitCampaignPrompts?.();
const res = await apiFetch('/api/save-prompts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
file_id: wizardUpload.fileId,
prompts,
products: selectedProducts.map((p) => p.name),
linkedin_prompts: sequenceHasLinkedin ? linkedinPrompts : {},
sequence_plan: parallelTracks
? { parallel_tracks: true, steps: sequenceSteps }
: sequenceSteps,
campaign_name: campaignName.trim() || 'Untitled campaign',
email_timezone: emailSendingSettings.timezone || null,
email_business_hours_start: emailSendingSettings.businessHoursStart,
email_business_hours_end: emailSendingSettings.businessHoursEnd,
email_business_days: (emailSendingSettings.businessDays || []).join(','),
email_send_interval_seconds: emailSendingSettings.intervalSeconds,
}),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.detail || res.statusText);
}
}, [
wizardUpload?.fileId,
prompts,
selectedProducts,
linkedinPrompts,
sequenceHasLinkedin,
sequenceSteps,
parallelTracks,
campaignName,
emailSendingSettings,
]);
const stopGeneration = useCallback(async () => {
if (!wizardUpload?.fileId) return;
setGenStopping(true);
setGenStopTakingLong(false);
clearTimeout(genStopTimerRef.current);
// The backend only notices the cancel flag at its next per-contact checkpoint, so a
// contact mid-generation can take a while to wind down. Relabel the button rather than
// leaving a bare spinner that looks stuck once this stretches past a few seconds.
genStopTimerRef.current = setTimeout(() => setGenStopTakingLong(true), 8000);
const postCancel = () =>
apiFetch(`/api/campaign-files/${encodeURIComponent(wizardUpload.fileId)}/generation/cancel`, {
method: 'POST',
});
try {
let res = await postCancel();
if (!res.ok) {
// One retry for a transient failure before giving up — a silently-failed cancel
// request would otherwise leave the flag unset and generation running forever
// while the button stays stuck on "Stopping…".
res = await postCancel();
}
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.detail || `Cancel request failed (${res.status})`);
}
} catch (e) {
console.error(e);
alert(e.message || 'Could not stop generation — please try again.');
setGenStopping(false);
setGenStopTakingLong(false);
clearTimeout(genStopTimerRef.current);
}
}, [wizardUpload?.fileId]);
const startGenerationRun = useCallback(
({ reset, preview = false }) => {
if (!wizardUpload?.fileId) return;
genRunOptionsRef.current = { reset: !!reset, preview: !!preview };
setGenPreviewOnly(!!preview);
setGenRunId((r) => r + 1);
setGenRunning(true);
if (reset) {
setGenSequences([]);
setGenContacts([]);
setGenProgress(0);
setGenComplete(false);
setGenPhase('email');
}
},
[wizardUpload?.fileId]
);
useEffect(() => {
if (!genRunning || !wizardUpload?.fileId) return;
const isNewRun = prevGenRunIdRef.current !== genRunId;
if (isNewRun) {
prevGenRunIdRef.current = genRunId;
setGenFailedContacts([]);
}
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
const runOpts = genRunOptionsRef.current;
const url = buildGenerateSequencesUrl(wizardUpload.fileId, {
reset: runOpts.reset,
preview: runOpts.preview,
contactLimit: runOpts.preview ? 1 : null,
});
const es = new EventSource(url, { withCredentials: true });
eventSourceRef.current = es;
es.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === 'sequence') {
const seq = data.sequence;
setGenSequences((prev) => {
const sig = `${seq.id}-${seq.emailNumber}-${seq.channel || 'email'}-${seq.stepOrder ?? ''}`;
if (
prev.some(
(s) =>
`${s.id}-${s.emailNumber}-${s.channel || 'email'}-${s.stepOrder ?? ''}` === sig
)
) {
return prev;
}
return [...prev, seq];
});
setGenContacts((prev) => mergeSequenceIntoContacts(prev, seq));
} else if (data.type === 'progress' && typeof data.progress === 'number') {
setGenProgress(data.progress);
} else if (data.type === 'phase' && data.phase === 'linkedin') {
setGenPhase('linkedin');
} else if (data.type === 'contact_error') {
setGenFailedContacts((prev) => [
...prev,
{
email: data.email || '',
name: data.name || '',
channel: data.channel || '',
error: data.error || '',
},
]);
} else if (data.type === 'cancelled') {
setGenRunning(false);
setGenPreviewOnly(false);
setGenStopping(false);
setGenStopTakingLong(false);
clearTimeout(genStopTimerRef.current);
es.close();
if (eventSourceRef.current === es) eventSourceRef.current = null;
} else if (data.type === 'preview_complete') {
setGenRunning(false);
setGenPreviewOnly(false);
es.close();
if (eventSourceRef.current === es) eventSourceRef.current = null;
} else if (data.type === 'complete') {
setGenRunning(false);
setGenComplete(true);
setGenPreviewOnly(false);
setGenStopping(false);
setGenStopTakingLong(false);
clearTimeout(genStopTimerRef.current);
es.close();
if (eventSourceRef.current === es) eventSourceRef.current = null;
if (!openRef.current) {
reset();
}
} else if (data.type === 'error') {
console.error(data.error);
alert(`Generation error: ${data.error}`);
setGenRunning(false);
setGenPreviewOnly(false);
setGenStopping(false);
setGenStopTakingLong(false);
clearTimeout(genStopTimerRef.current);
es.close();
if (eventSourceRef.current === es) eventSourceRef.current = null;
if (!openRef.current) {
reset();
}
}
} catch (e) {
console.error(e);
}
};
es.onerror = () => {
es.close();
if (eventSourceRef.current === es) eventSourceRef.current = null;
};
return () => {
es.close();
if (eventSourceRef.current === es) eventSourceRef.current = null;
};
}, [genRunning, wizardUpload?.fileId, genRunId, reset]);
const pickFile = async (file) => {
if (!file) return;
if (!file.name.toLowerCase().endsWith('.csv')) {
alert('Please upload a .csv file (Apollo export), same as Email / AI Generator.');
return;
}
setProspectFile(file);
setWizardUpload(null);
setUploadingCsv(true);
try {
const formData = new FormData();
formData.append('file', file);
const response = await apiFetch('/api/upload-csv', {
method: 'POST',
body: formData,
});
if (!response.ok) {
throw new Error('Upload failed');
}
const data = await response.json();
setWizardUpload({
fileId: data.file_id,
contactCount: data.contact_count,
name: file.name,
});
} catch (err) {
console.error(err);
alert('Could not upload CSV. Please try again.');
setProspectFile(null);
setWizardUpload(null);
} finally {
setUploadingCsv(false);
}
};
const canContinueStep1 =
campaignName.trim().length > 0 && !!wizardUpload?.fileId && !uploadingCsv;
const canGenerate = useMemo(() => {
if (selectedProducts.length === 0 || !wizardUpload?.fileId) return false;
const emailOk = !sequenceHasGmail || selectedProducts.every((p) => (prompts[p.name] || '').trim());
if (!emailOk) return false;
if (!sequenceHasLinkedin) return true;
return selectedProducts.every((p) => (linkedinPrompts[p.name] || '').trim());
}, [selectedProducts, prompts, linkedinPrompts, wizardUpload?.fileId, sequenceHasLinkedin, sequenceHasGmail]);
const triggerPreviewGeneration = useCallback(async () => {
if (!canGenerate || !wizardUpload?.fileId || genRunning) return;
if (previewTriggeredRef.current && genContacts.length > 0) return;
previewTriggeredRef.current = true;
try {
await savePromptsToServer();
startGenerationRun({
reset: genSequences.length === 0,
preview: true,
});
} catch (e) {
console.error(e);
previewTriggeredRef.current = false;
}
}, [
canGenerate,
wizardUpload?.fileId,
genRunning,
genContacts.length,
genSequences.length,
savePromptsToServer,
startGenerationRun,
]);
useEffect(() => {
if (step === 4 && canGenerate && wizardUpload?.fileId && genContacts.length === 0 && !genRunning) {
void triggerPreviewGeneration();
}
}, [step, canGenerate, wizardUpload?.fileId, genContacts.length, genRunning, triggerPreviewGeneration]);
const handleContinue = () => {
if (step === 1 && !canContinueStep1) return;
if (step < 4) setStep((s) => s + 1);
};
const handleBack = () => {
if (step > 1) setStep((s) => s - 1);
};
const requestClose = () => {
const committed = promptEditorRef.current?.commitCampaignPrompts?.() ?? null;
const mergedPrompts =
committed && typeof committed === 'object' && committed.prompts
? committed.prompts
: prompts;
const mergedLinkedin =
committed && typeof committed === 'object' && committed.linkedinPrompts
? committed.linkedinPrompts
: linkedinPrompts;
const payload = {
id: initialCampaign?.id,
name: campaignName.trim() || 'Untitled campaign',
contacts: wizardUpload?.contactCount ?? estimatedContacts ?? 0,
prospectFileName: wizardUpload?.name || prospectFile?.name || '',
fileId: wizardUpload?.fileId || null,
sequence: sequenceSteps,
parallelTracks,
selectedProducts,
generationComplete: genComplete,
generationProgressPercent: Math.min(100, Math.round(genProgress || 0)),
wizardStep: step,
wizardPrompts: mergedPrompts,
wizardLinkedinPrompts: mergedLinkedin,
};
if (onDraftPersist && (payload.fileId || payload.id)) {
onDraftPersist(payload);
}
onOpenChange(false);
};
const handleGenerate = async () => {
if (!canGenerate) {
alert('Select products and fill all prompt templates before generating.');
return;
}
try {
await savePromptsToServer();
} catch (e) {
console.error(e);
alert('Failed to save prompts. Please try again.');
return;
}
previewTriggeredRef.current = true;
startGenerationRun({ reset: true, preview: false });
};
const handleLaunch = async () => {
if (!onComplete) {
requestClose();
return;
}
try {
if (canGenerate && wizardUpload?.fileId) {
await savePromptsToServer();
}
} catch (e) {
console.error(e);
alert('Failed to save campaign settings. Please try again.');
return;
}
const hasPartial = genSequences.length > 0 && !genComplete;
const needsBackgroundGen = !genComplete;
onComplete({
campaignId: initialCampaign?.id ?? null,
name: campaignName.trim(),
contacts: wizardUpload?.contactCount || estimatedContacts || 0,
prospectFileName: wizardUpload?.name || prospectFile?.name || '',
fileId: wizardUpload?.fileId || null,
sequence: sequenceSteps,
parallelTracks,
selectedProducts,
generationComplete: genComplete,
generationProgressPercent: Math.min(100, Math.round(genProgress || 0)),
startBackgroundGeneration: needsBackgroundGen,
resumeGeneration: hasPartial,
});
onOpenChange(false);
};
useEffect(() => {
if (!open) return;
const onKey = (e) => {
if (e.key === 'Escape') requestClose();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [open, requestClose]);
if (!open) return null;
const modal = (
<div
className="fixed inset-0 z-[100] flex items-center justify-center p-4 sm:p-6"
role="dialog"
aria-modal="true"
aria-labelledby="create-campaign-title"
>
<button
type="button"
className="absolute inset-0 bg-slate-900/40 backdrop-blur-[2px]"
aria-label="Close"
onClick={() => requestClose()}
/>
<div
className={cn(
'relative z-[101] flex max-h-[min(92vh,900px)] w-full flex-col overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-2xl shadow-violet-200/30',
step === 2 || step === 3 ? 'max-w-4xl' : 'max-w-3xl'
)}
>
<div className="flex items-center justify-between border-b border-slate-100 px-5 py-4">
<h2 id="create-campaign-title" className="text-lg font-semibold text-slate-900">
Create Campaign
</h2>
<button
type="button"
onClick={() => requestClose()}
className="rounded-lg p-2 text-slate-400 transition hover:bg-slate-100 hover:text-slate-700"
>
<X className="h-5 w-5" />
</button>
</div>
<div className="border-b border-slate-100 bg-slate-50/80 px-4 py-4 sm:px-6">
<div className="flex flex-wrap items-start justify-between gap-4">
{STEPS.map((s) => {
const active = step === s.id;
const done = step > s.id;
return (
<div key={s.id} className="flex min-w-[120px] flex-1 flex-col items-center gap-2">
<div
className={cn(
'flex h-10 w-10 items-center justify-center rounded-full text-sm font-semibold transition',
active &&
'bg-violet-600 text-white ring-4 ring-violet-100',
done && !active && 'bg-violet-100 text-violet-800',
!active && !done && 'border-2 border-slate-200 bg-white text-slate-400'
)}
>
{s.id}
</div>
<span
className={cn(
'text-center text-xs font-medium sm:text-sm',
active ? 'text-violet-700' : 'text-slate-500'
)}
>
{s.label}
</span>
</div>
);
})}
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-6 sm:px-8">
{step === 1 ? (
<div className="space-y-6">
<div>
<span className="inline-flex rounded-md bg-slate-100 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-slate-500">
Step 1
</span>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-800">
Campaign Name
</label>
<Input
placeholder="e.g., Q4 Enterprise Tech Outreach"
value={campaignName}
onChange={(e) => setCampaignName(e.target.value)}
className="max-w-lg"
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-800">
Prospect List (Apollo CSV)
</label>
<div
className={cn(
'relative flex min-h-[200px] cursor-pointer flex-col items-center justify-center rounded-xl border-2 border-dashed px-4 py-8 transition',
dragOver
? 'border-violet-400 bg-violet-50/50'
: 'border-slate-200 bg-slate-50/50 hover:border-violet-300'
)}
onDragOver={(e) => {
e.preventDefault();
setDragOver(true);
}}
onDragLeave={() => setDragOver(false)}
onDrop={(e) => {
e.preventDefault();
setDragOver(false);
const f = e.dataTransfer.files?.[0];
pickFile(f);
}}
onClick={() => !uploadingCsv && document.getElementById('wizard-csv-input')?.click()}
>
<input
id="wizard-csv-input"
type="file"
accept=".csv"
className="hidden"
onChange={(e) => pickFile(e.target.files?.[0])}
/>
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-violet-100 text-violet-600">
<Upload className="h-6 w-6" />
</div>
<p className="mt-3 text-center text-sm font-semibold text-slate-800">
Drag and drop your CSV here
</p>
<Button
type="button"
variant="outline"
className="mt-4"
disabled={uploadingCsv}
onClick={(e) => {
e.stopPropagation();
document.getElementById('wizard-csv-input')?.click();
}}
>
Browse Files
</Button>
{uploadingCsv ? (
<p className="mt-3 text-xs font-medium text-violet-700">Uploading…</p>
) : wizardUpload ? (
<p className="mt-3 text-xs font-medium text-violet-700">
{wizardUpload.name}
{wizardUpload.contactCount
? ` · ${Number(wizardUpload.contactCount).toLocaleString()} contacts`
: estimatedContacts > 0
? ` · ~${estimatedContacts.toLocaleString()} rows`
: ''}
</p>
) : prospectFile ? (
<p className="mt-3 text-xs text-amber-700">Upload failed — try another file.</p>
) : null}
</div>
</div>
</div>
) : step === 2 ? (
<CampaignSequenceBuilder
value={sequenceSteps}
onChange={setSequenceSteps}
parallelTracks={parallelTracks}
onParallelTracksChange={setParallelTracks}
linkedinDefaults={linkedinDefaults}
mailboxDefaults={mailboxDefaults}
emailSendingSettings={emailSendingSettings}
onEmailSendingSettingsChange={setEmailSendingSettings}
/>
) : step === 3 ? (
<div className="space-y-8">
<ProductSelector
selectedProducts={selectedProducts}
onProductsChange={setSelectedProducts}
/>
{selectedProducts.length > 0 ? (
<>
{!genRunning && (!genComplete || genFailedContacts.length > 0) ? (
<div className="rounded-xl border-2 border-violet-300 bg-gradient-to-br from-violet-50 via-white to-purple-50 p-5 shadow-sm shadow-violet-100/80">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="text-base font-semibold text-slate-900">
{genFailedContacts.length > 0
? 'Retry failed contacts'
: 'Generate personalized content'}
</p>
<p className="mt-1 max-w-xl text-sm text-slate-600">
{genFailedContacts.length > 0 ? (
<>
Some contacts failed to generate. Click{' '}
<strong className="font-semibold text-violet-800">
Retry generation
</strong>{' '}
to regenerate content for all contacts.
</>
) : (
<>
Click <strong className="font-semibold text-violet-800">Generate</strong>{' '}
to create emails and LinkedIn copy for all contacts. You can also skip
this and continue — we&apos;ll auto-preview the first contact on the
next step.
</>
)}
</p>
</div>
<Button
type="button"
size="lg"
disabled={!canGenerate}
className="h-12 shrink-0 bg-gradient-to-r from-violet-600 to-purple-600 px-8 text-base font-semibold shadow-md shadow-violet-200 hover:from-violet-700 hover:to-purple-700"
onClick={handleGenerate}
>
<Sparkles className="mr-2 h-5 w-5" />
{genFailedContacts.length > 0 ? 'Retry generation' : 'Generate all contacts'}
</Button>
</div>
</div>
) : null}
<PromptEditor
ref={promptEditorRef}
selectedProducts={selectedProducts}
prompts={prompts}
onPromptsChange={setPrompts}
variant="campaign"
includeLinkedinInCampaign={sequenceHasLinkedin}
linkedinPrompts={linkedinPrompts}
onLinkedinPromptsChange={setLinkedinPrompts}
/>
{genRunning ? (
<div className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-violet-200 bg-violet-50/80 px-4 py-3 text-sm text-violet-900">
<span className="flex items-center gap-2">
<Loader2 className="h-4 w-4 shrink-0 animate-spin" />
Generating in the background — you can continue to Review &amp; Launch anytime.
</span>
<Button
type="button"
size="sm"
variant="outline"
className="shrink-0 border-violet-300 bg-white text-violet-800 hover:bg-violet-100"
disabled={genStopping}
onClick={stopGeneration}
>
{genStopping ? (
<>
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
{genStopTakingLong ? 'Finishing current contact…' : 'Stopping…'}
</>
) : (
'Stop generation'
)}
</Button>
</div>
) : genComplete && genFailedContacts.length === 0 ? (
<div className="flex items-center gap-2 rounded-lg border border-green-200 bg-green-50 px-4 py-3 text-sm text-green-800">
<CheckCircle2 className="h-4 w-4 shrink-0" />
All contact content generated. Continue to review and launch.
</div>
) : null}
{genFailedContacts.length > 0 ? (
<div className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900">
<p className="font-medium">
{genFailedContacts.length} contact{genFailedContacts.length === 1 ? '' : 's'} failed
to generate — retry generation to fill in the rest.
</p>
<ul className="mt-1.5 max-h-24 space-y-0.5 overflow-y-auto text-xs text-amber-800">
{genFailedContacts.slice(0, 10).map((f, i) => (
<li key={`${f.email || f.name}-${i}`}>
{f.email || f.name || 'Unknown contact'}
{f.channel ? ` (${f.channel})` : ''}
{f.error ? `: ${f.error}` : ''}
</li>
))}
</ul>
</div>
) : null}
<WizardSequencePreview
contacts={genContacts}
sequences={genSequences}
isGenerating={genRunning}
generationComplete={genComplete}
generationStarted={genRunId > 0 || genSequences.length > 0}
progress={genProgress}
contactCount={wizardUpload?.contactCount}
selectedProducts={selectedProducts}
sequenceHasLinkedin={sequenceHasLinkedin}
genPhase={genPhase}
hasFailures={genFailedContacts.length > 0}
/>
</>
) : (
<p className="text-sm text-slate-500">Select at least one product to edit prompts.</p>
)}
</div>
) : (
<div className="space-y-6">
<div className="rounded-xl border border-slate-200 bg-white p-4 shadow-sm">
<p className="text-sm font-medium text-slate-800">Sequence</p>
<ReviewSequenceTimeline steps={sequenceSteps} parallelTracks={parallelTracks} />
</div>
<div>
<p className="mb-2 text-sm font-medium text-slate-800">Sample preview (first contact)</p>
{genRunning && !genContacts[0] ? (
<div className="rounded-xl border border-violet-200 bg-violet-50/50 px-4 py-10 text-center">
<Loader2 className="mx-auto h-8 w-8 animate-spin text-violet-600" />
<p className="mt-3 text-sm font-medium text-slate-700">
Generating preview for your first contact…
</p>
</div>
) : genContacts[0] ? (
<SequenceCard contact={genContacts[0]} index={0} />
) : (
<div className="rounded-xl border border-dashed border-slate-200 bg-slate-50/80 px-4 py-10 text-center text-sm text-slate-600">
Preview will appear here shortly. You can still launch — remaining contacts
generate in the background on the dashboard.
</div>
)}
</div>
{(genRunning || (!genComplete && genSequences.length > 0)) ? (
<div className="rounded-xl border border-violet-200 bg-white p-4 shadow-sm">
<div className="mb-2 flex items-center justify-between text-sm">
<span className="font-medium text-slate-800">
{genComplete
? 'Content generation complete'
: genPreviewOnly
? 'Generating preview…'
: 'Generating all contacts…'}
</span>
<span className="font-semibold text-violet-700">
{Math.min(100, Math.round(genProgress || 0))}%
</span>
</div>
<Progress value={genProgress} className="h-2" />
{!genComplete ? (
<p className="mt-2 text-xs text-slate-500">
Launch now — generation continues on the dashboard. Sending starts automatically
when every contact is ready.
</p>
) : null}
</div>
) : null}
{!genRunning && genFailedContacts.length > 0 ? (
<div className="rounded-xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900">
<p className="font-medium">
{genFailedContacts.length} contact{genFailedContacts.length === 1 ? '' : 's'} failed to
generate content and will not have a sequence unless you go back and retry generation.
</p>
</div>
) : null}
<div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-slate-100 bg-slate-50/80 px-4 py-3 text-sm text-slate-700">
<div>
<span className="font-semibold text-slate-900">{campaignName || 'Campaign'}</span>
<span className="mx-2 text-slate-300">·</span>
<span>
{(wizardUpload?.contactCount ?? estimatedContacts ?? 0).toLocaleString()}{' '}
prospects
</span>
{(wizardUpload?.name || prospectFile?.name) && (
<>
<span className="mx-2 text-slate-300">·</span>
<span className="text-slate-600">
{wizardUpload?.name || prospectFile?.name}
</span>
</>
)}
</div>
{genRunning || (genSequences.length > 0 && !genComplete) ? (
<span className="text-xs font-medium text-violet-700">
Content generation {genComplete ? 'complete' : 'in progress'}
</span>
) : null}
</div>
</div>
)}
</div>
<div className="flex flex-wrap items-center justify-end gap-2 border-t border-slate-100 bg-white px-5 py-4 sm:px-8">
{step === 4 ? (
<Button
type="button"
className="bg-violet-600 text-white hover:bg-violet-700"
onClick={() => void handleLaunch()}
>
{genComplete ? 'Launch campaign' : 'Launch & finish generating'}
</Button>
) : null}
{step > 1 ? (
<Button type="button" variant="outline" onClick={handleBack}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Button>
) : null}
{step < 4 ? (
<Button
type="button"
disabled={step === 1 && !canContinueStep1}
className="bg-violet-600 text-white hover:bg-violet-700 disabled:opacity-40"
onClick={handleContinue}
>
{step === 1
? 'Continue to Sequence'
: step === 3
? 'Continue to Review (auto-preview first contact)'
: `Continue to ${STEPS[step]?.label ?? 'next'}`}
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
) : null}
</div>
</div>
</div>
);
return typeof document !== 'undefined' ? createPortal(modal, document.body) : null;
}