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