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 (
{generationStarted ? (
{generationComplete && hasFailures ? (
) : generationComplete ? (
) : (
)}

{generationComplete ? hasFailures ? 'Generation finished with errors' : 'Generation complete!' : `Generating ${phaseLabel}…`}

{contacts.length} contacts · {sequences.length} generated rows {contactCount ? ` · ~${contactCount} contacts in file` : ''}

{showProgress ? : null}
) : null} {sequences.length > 0 && (
setSearchQuery(e.target.value)} className="pl-10" />
)}
{displayedContacts.map((contact, index) => ( ))} {hasMore && (
)}
{!generationStarted && contacts.length === 0 && sequences.length === 0 && (
Generated messages will appear here after you click Generate. You can continue to the next step while generation runs in the background.
)}
); } function ReviewSequenceTimeline({ steps, parallelTracks }) { if (!steps?.length) { return

No sequence steps.

; } return (
{parallelTracks ? (

Parallel tracks: Gmail and LinkedIn advance on separate timers — waits only apply within the same channel.

) : null}
{steps.map((s, i) => ( {i > 0 ? : null} {s.type === 'wait' ? ( Wait {formatWaitStep(s, { short: true })} ) : ( {s.action === 'linkedin_connect' ? ( ) : s.channel === 'linkedin' ? ( ) : ( )} {s.title || (s.channel === 'gmail' ? 'Email' : 'LinkedIn')} )} ))}
); } 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 = (
{STEPS.map((s) => { const active = step === s.id; const done = step > s.id; return (
{s.id}
{s.label}
); })}
{step === 1 ? (
Step 1
setCampaignName(e.target.value)} className="max-w-lg" />
{ 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()} > pickFile(e.target.files?.[0])} />

Drag and drop your CSV here

{uploadingCsv ? (

Uploading…

) : wizardUpload ? (

{wizardUpload.name} {wizardUpload.contactCount ? ` · ${Number(wizardUpload.contactCount).toLocaleString()} contacts` : estimatedContacts > 0 ? ` · ~${estimatedContacts.toLocaleString()} rows` : ''}

) : prospectFile ? (

Upload failed — try another file.

) : null}
) : step === 2 ? ( ) : step === 3 ? (
{selectedProducts.length > 0 ? ( <> {!genRunning && (!genComplete || genFailedContacts.length > 0) ? (

{genFailedContacts.length > 0 ? 'Retry failed contacts' : 'Generate personalized content'}

{genFailedContacts.length > 0 ? ( <> Some contacts failed to generate. Click{' '} Retry generation {' '} to regenerate content for all contacts. ) : ( <> Click Generate{' '} to create emails and LinkedIn copy for all contacts. You can also skip this and continue — we'll auto-preview the first contact on the next step. )}

) : null} {genRunning ? (
Generating in the background — you can continue to Review & Launch anytime.
) : genComplete && genFailedContacts.length === 0 ? (
All contact content generated. Continue to review and launch.
) : null} {genFailedContacts.length > 0 ? (

{genFailedContacts.length} contact{genFailedContacts.length === 1 ? '' : 's'} failed to generate — retry generation to fill in the rest.

    {genFailedContacts.slice(0, 10).map((f, i) => (
  • {f.email || f.name || 'Unknown contact'} {f.channel ? ` (${f.channel})` : ''} {f.error ? `: ${f.error}` : ''}
  • ))}
) : null} 0 || genSequences.length > 0} progress={genProgress} contactCount={wizardUpload?.contactCount} selectedProducts={selectedProducts} sequenceHasLinkedin={sequenceHasLinkedin} genPhase={genPhase} hasFailures={genFailedContacts.length > 0} /> ) : (

Select at least one product to edit prompts.

)}
) : (

Sequence

Sample preview (first contact)

{genRunning && !genContacts[0] ? (

Generating preview for your first contact…

) : genContacts[0] ? ( ) : (
Preview will appear here shortly. You can still launch — remaining contacts generate in the background on the dashboard.
)}
{(genRunning || (!genComplete && genSequences.length > 0)) ? (
{genComplete ? 'Content generation complete' : genPreviewOnly ? 'Generating preview…' : 'Generating all contacts…'} {Math.min(100, Math.round(genProgress || 0))}%
{!genComplete ? (

Launch now — generation continues on the dashboard. Sending starts automatically when every contact is ready.

) : null}
) : null} {!genRunning && genFailedContacts.length > 0 ? (

{genFailedContacts.length} contact{genFailedContacts.length === 1 ? '' : 's'} failed to generate content and will not have a sequence unless you go back and retry generation.

) : null}
{campaignName || 'Campaign'} · {(wizardUpload?.contactCount ?? estimatedContacts ?? 0).toLocaleString()}{' '} prospects {(wizardUpload?.name || prospectFile?.name) && ( <> · {wizardUpload?.name || prospectFile?.name} )}
{genRunning || (genSequences.length > 0 && !genComplete) ? ( Content generation {genComplete ? 'complete' : 'in progress'} ) : null}
)}
{step === 4 ? ( ) : null} {step > 1 ? ( ) : null} {step < 4 ? ( ) : null}
); return typeof document !== 'undefined' ? createPortal(modal, document.body) : null; }