import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { apiFetch } from '@/lib/api'; import { mergeSequenceIntoContacts } from '@/lib/mergeSequenceIntoContacts'; import { createPortal } from 'react-dom'; import { X, Upload, ArrowRight, ArrowLeft, Sparkles, Loader2, CheckCircle2, Search, Filter, Mail, UserPlus, MessageCircle, Clock, ChevronRight, } 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'; 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 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(`• Wait ${s.days ?? 1} day(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, }) { 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 ? (
) : (
)}

{generationComplete ? '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 {s.days ?? 1}d ) : ( {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 [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 eventSourceRef = useRef(null); const promptEditorRef = useRef(null); const prevGenRunIdRef = useRef(null); 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 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); setSelectedProducts([]); setPrompts({}); setLinkedinPrompts({}); setGenSequences([]); setGenContacts([]); setGenProgress(0); setGenRunning(false); setGenComplete(false); setGenPhase('email'); setGenRunId(0); prevGenRunIdRef.current = null; }, []); 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]); 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; if (Array.isArray(seq) && seq.length > 0) { setSequenceSteps(seq); } else { setSequenceSteps(createDefaultSequenceSteps()); } 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]); useEffect(() => { if (!genRunning || !wizardUpload?.fileId) return; const isNewRun = prevGenRunIdRef.current !== genRunId; if (isNewRun) { prevGenRunIdRef.current = genRunId; setGenSequences([]); setGenContacts([]); setGenProgress(0); setGenComplete(false); setGenPhase('email'); } if (eventSourceRef.current) { eventSourceRef.current.close(); eventSourceRef.current = null; } const resetParam = isNewRun ? 1 : 0; const url = `/api/generate-sequences?file_id=${encodeURIComponent(wizardUpload.fileId)}&reset=${resetParam}`; const es = new EventSource(url); 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 === 'complete') { setGenRunning(false); setGenComplete(true); 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); 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]); 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(() => { const emailOk = selectedProducts.length > 0 && selectedProducts.every((p) => (prompts[p.name] || '').trim()); if (!emailOk || !wizardUpload?.fileId) return false; if (!sequenceHasLinkedin) return true; return selectedProducts.every((p) => (linkedinPrompts[p.name] || '').trim()); }, [selectedProducts, prompts, linkedinPrompts, wizardUpload?.fileId, sequenceHasLinkedin]); 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; } promptEditorRef.current?.commitCampaignPrompts?.(); try { 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, }), }); if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(err.detail || res.statusText); } } catch (e) { console.error(e); alert('Failed to save prompts. Please try again.'); return; } setGenRunId((r) => r + 1); setGenRunning(true); }; const handleLaunch = () => { if (!onComplete) { requestClose(); return; } 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)), }); 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 ? ( Running in the background — you can go to the next step anytime. ) : null}
0 || genSequences.length > 0} progress={genProgress} contactCount={wizardUpload?.contactCount} selectedProducts={selectedProducts} sequenceHasLinkedin={sequenceHasLinkedin} genPhase={genPhase} /> ) : (

Select at least one product to edit prompts.

)}
) : (

Sequence

Sample preview

{genContacts[0] ? ( ) : (
No generated copy for the first contact yet. You can still launch — content will keep filling in the background. Return to step 3 or open this campaign from the dashboard to watch progress.
)}
{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; }