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 && (
)}
{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 = (
);
return typeof document !== 'undefined' ? createPortal(modal, document.body) : null;
}