EMAILOUT / frontend /src /components /campaigns /CreateCampaignWizard.jsx
Seth
update
d2b0e4f
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 (
<div className="w-full space-y-4">
{generationStarted ? (
<div className="mb-2 rounded-2xl border border-slate-200 bg-white p-6">
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-3">
{generationComplete ? (
<div className="rounded-xl bg-green-100 p-3">
<CheckCircle2 className="h-6 w-6 text-green-600" />
</div>
) : (
<div className="rounded-xl bg-violet-100 p-3">
<Loader2 className="h-6 w-6 animate-spin text-violet-600" />
</div>
)}
<div>
<h3 className="font-semibold text-slate-800">
{generationComplete ? 'Generation complete!' : `Generating ${phaseLabel}…`}
</h3>
<p className="text-sm text-slate-500">
{contacts.length} contacts · {sequences.length} generated rows
{contactCount ? ` · ~${contactCount} contacts in file` : ''}
</p>
</div>
</div>
</div>
{showProgress ? <Progress value={progress} className="h-2" /> : null}
</div>
) : null}
{sequences.length > 0 && (
<div className="flex flex-col gap-3 sm:flex-row">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<Input
placeholder="Search contacts…"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10"
/>
</div>
<Select value={filterProduct} onValueChange={setFilterProduct}>
<SelectTrigger className="w-full sm:w-48">
<Filter className="mr-2 h-4 w-4 text-slate-400" />
<SelectValue placeholder="Filter by product" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Products</SelectItem>
{selectedProducts.map((product) => (
<SelectItem key={product.id} value={product.name}>
{product.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div className="custom-scrollbar max-h-[min(420px,50vh)] space-y-3 overflow-y-auto pr-1">
{displayedContacts.map((contact, index) => (
<SequenceCard
key={contact.id || `${contact.firstName}-${contact.lastName}-${contact.email}`}
contact={contact}
index={index}
/>
))}
{hasMore && (
<div className="py-3 text-center">
<Button
type="button"
variant="outline"
onClick={() => setDisplayedCount((c) => Math.min(c + 50, filteredContacts.length))}
>
Load more ({filteredContacts.length - displayedCount} remaining)
</Button>
</div>
)}
</div>
{!generationStarted && contacts.length === 0 && sequences.length === 0 && (
<div className="rounded-xl border border-dashed border-slate-200 bg-slate-50/80 py-12 text-center text-sm text-slate-500">
Generated messages will appear here after you click Generate. You can continue to the next step
while generation runs in the background.
</div>
)}
</div>
);
}
function ReviewSequenceTimeline({ steps, parallelTracks }) {
if (!steps?.length) {
return <p className="text-sm text-slate-500">No sequence steps.</p>;
}
return (
<div>
{parallelTracks ? (
<p className="mb-3 text-xs text-slate-600">
Parallel tracks: Gmail and LinkedIn advance on separate timers — waits only apply within the same
channel.
</p>
) : null}
<div className="flex flex-wrap items-center gap-1 py-2">
{steps.map((s, i) => (
<React.Fragment key={s.id || i}>
{i > 0 ? <ChevronRight className="mx-0.5 h-4 w-4 shrink-0 text-slate-300" /> : null}
{s.type === 'wait' ? (
<span className="inline-flex items-center gap-1 rounded-lg border border-dashed border-blue-200 bg-sky-50 px-2.5 py-1.5 text-xs font-medium text-slate-700">
<Clock className="h-3.5 w-3.5 text-blue-600" />
Wait {s.days ?? 1}d
</span>
) : (
<span
className={cn(
'inline-flex max-w-[200px] items-center gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs font-medium',
s.channel === 'linkedin'
? 'border-sky-200 bg-sky-50 text-sky-900'
: 'border-violet-200 bg-violet-50 text-violet-900'
)}
>
{s.action === 'linkedin_connect' ? (
<UserPlus className="h-3.5 w-3.5 shrink-0" />
) : s.channel === 'linkedin' ? (
<MessageCircle className="h-3.5 w-3.5 shrink-0" />
) : (
<Mail className="h-3.5 w-3.5 shrink-0" />
)}
<span className="truncate">{s.title || (s.channel === 'gmail' ? 'Email' : 'LinkedIn')}</span>
</span>
)}
</React.Fragment>
))}
</div>
</div>
);
}
export default function CreateCampaignWizard({
open,
onOpenChange,
onComplete,
onDraftPersist,
initialCampaign = null,
}) {
const [step, setStep] = useState(1);
const [campaignName, setCampaignName] = useState('');
const [prospectFile, setProspectFile] = useState(null);
const [wizardUpload, setWizardUpload] = useState(null);
const [uploadingCsv, setUploadingCsv] = useState(false);
const [dragOver, setDragOver] = useState(false);
const [estimatedContacts, setEstimatedContacts] = useState(0);
const [sequenceSteps, setSequenceSteps] = useState(() => createDefaultSequenceSteps());
const [parallelTracks, setParallelTracks] = useState(false);
const [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 = (
<div
className="fixed inset-0 z-[100] flex items-center justify-center p-4 sm:p-6"
role="dialog"
aria-modal="true"
aria-labelledby="create-campaign-title"
>
<button
type="button"
className="absolute inset-0 bg-slate-900/40 backdrop-blur-[2px]"
aria-label="Close"
onClick={() => requestClose()}
/>
<div
className={cn(
'relative z-[101] flex max-h-[min(92vh,900px)] w-full flex-col overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-2xl shadow-violet-200/30',
step === 2 || step === 3 ? 'max-w-4xl' : 'max-w-3xl'
)}
>
<div className="flex items-center justify-between border-b border-slate-100 px-5 py-4">
<h2 id="create-campaign-title" className="text-lg font-semibold text-slate-900">
Create Campaign
</h2>
<button
type="button"
onClick={() => requestClose()}
className="rounded-lg p-2 text-slate-400 transition hover:bg-slate-100 hover:text-slate-700"
>
<X className="h-5 w-5" />
</button>
</div>
<div className="border-b border-slate-100 bg-slate-50/80 px-4 py-4 sm:px-6">
<div className="flex flex-wrap items-start justify-between gap-4">
{STEPS.map((s) => {
const active = step === s.id;
const done = step > s.id;
return (
<div key={s.id} className="flex min-w-[120px] flex-1 flex-col items-center gap-2">
<div
className={cn(
'flex h-10 w-10 items-center justify-center rounded-full text-sm font-semibold transition',
active &&
'bg-violet-600 text-white ring-4 ring-violet-100',
done && !active && 'bg-violet-100 text-violet-800',
!active && !done && 'border-2 border-slate-200 bg-white text-slate-400'
)}
>
{s.id}
</div>
<span
className={cn(
'text-center text-xs font-medium sm:text-sm',
active ? 'text-violet-700' : 'text-slate-500'
)}
>
{s.label}
</span>
</div>
);
})}
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-6 sm:px-8">
{step === 1 ? (
<div className="space-y-6">
<div>
<span className="inline-flex rounded-md bg-slate-100 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-slate-500">
Step 1
</span>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-800">
Campaign Name
</label>
<Input
placeholder="e.g., Q4 Enterprise Tech Outreach"
value={campaignName}
onChange={(e) => setCampaignName(e.target.value)}
className="max-w-lg"
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-800">
Prospect List (Apollo CSV)
</label>
<div
className={cn(
'relative flex min-h-[200px] cursor-pointer flex-col items-center justify-center rounded-xl border-2 border-dashed px-4 py-8 transition',
dragOver
? 'border-violet-400 bg-violet-50/50'
: 'border-slate-200 bg-slate-50/50 hover:border-violet-300'
)}
onDragOver={(e) => {
e.preventDefault();
setDragOver(true);
}}
onDragLeave={() => setDragOver(false)}
onDrop={(e) => {
e.preventDefault();
setDragOver(false);
const f = e.dataTransfer.files?.[0];
pickFile(f);
}}
onClick={() => !uploadingCsv && document.getElementById('wizard-csv-input')?.click()}
>
<input
id="wizard-csv-input"
type="file"
accept=".csv"
className="hidden"
onChange={(e) => pickFile(e.target.files?.[0])}
/>
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-violet-100 text-violet-600">
<Upload className="h-6 w-6" />
</div>
<p className="mt-3 text-center text-sm font-semibold text-slate-800">
Drag and drop your CSV here
</p>
<Button
type="button"
variant="outline"
className="mt-4"
disabled={uploadingCsv}
onClick={(e) => {
e.stopPropagation();
document.getElementById('wizard-csv-input')?.click();
}}
>
Browse Files
</Button>
{uploadingCsv ? (
<p className="mt-3 text-xs font-medium text-violet-700">Uploading…</p>
) : wizardUpload ? (
<p className="mt-3 text-xs font-medium text-violet-700">
{wizardUpload.name}
{wizardUpload.contactCount
? ` · ${Number(wizardUpload.contactCount).toLocaleString()} contacts`
: estimatedContacts > 0
? ` · ~${estimatedContacts.toLocaleString()} rows`
: ''}
</p>
) : prospectFile ? (
<p className="mt-3 text-xs text-amber-700">Upload failed — try another file.</p>
) : null}
</div>
</div>
</div>
) : step === 2 ? (
<CampaignSequenceBuilder
value={sequenceSteps}
onChange={setSequenceSteps}
parallelTracks={parallelTracks}
onParallelTracksChange={setParallelTracks}
linkedinDefaults={linkedinDefaults}
mailboxDefaults={mailboxDefaults}
/>
) : step === 3 ? (
<div className="space-y-8">
<ProductSelector
selectedProducts={selectedProducts}
onProductsChange={setSelectedProducts}
/>
{selectedProducts.length > 0 ? (
<>
<PromptEditor
ref={promptEditorRef}
selectedProducts={selectedProducts}
prompts={prompts}
onPromptsChange={setPrompts}
variant="campaign"
includeLinkedinInCampaign={sequenceHasLinkedin}
linkedinPrompts={linkedinPrompts}
onLinkedinPromptsChange={setLinkedinPrompts}
/>
<div className="flex flex-wrap items-center gap-3">
<Button
type="button"
disabled={!canGenerate || genRunning}
className="bg-gradient-to-r from-violet-600 to-purple-600 hover:from-violet-700 hover:to-purple-700"
onClick={handleGenerate}
>
<Sparkles className="mr-2 h-4 w-4" />
{genRunning ? 'Generating…' : 'Generate'}
</Button>
{genRunning ? (
<span className="text-sm text-slate-600">
Running in the background — you can go to the next step anytime.
</span>
) : null}
</div>
<WizardSequencePreview
contacts={genContacts}
sequences={genSequences}
isGenerating={genRunning}
generationComplete={genComplete}
generationStarted={genRunId > 0 || genSequences.length > 0}
progress={genProgress}
contactCount={wizardUpload?.contactCount}
selectedProducts={selectedProducts}
sequenceHasLinkedin={sequenceHasLinkedin}
genPhase={genPhase}
/>
</>
) : (
<p className="text-sm text-slate-500">Select at least one product to edit prompts.</p>
)}
</div>
) : (
<div className="space-y-6">
<div className="rounded-xl border border-slate-200 bg-white p-4 shadow-sm">
<p className="text-sm font-medium text-slate-800">Sequence</p>
<ReviewSequenceTimeline steps={sequenceSteps} parallelTracks={parallelTracks} />
</div>
<div>
<p className="mb-2 text-sm font-medium text-slate-800">Sample preview</p>
{genContacts[0] ? (
<SequenceCard contact={genContacts[0]} index={0} />
) : (
<div className="rounded-xl border border-dashed border-slate-200 bg-slate-50/80 px-4 py-10 text-center text-sm text-slate-600">
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.
</div>
)}
</div>
<div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-slate-100 bg-slate-50/80 px-4 py-3 text-sm text-slate-700">
<div>
<span className="font-semibold text-slate-900">{campaignName || 'Campaign'}</span>
<span className="mx-2 text-slate-300">·</span>
<span>
{(wizardUpload?.contactCount ?? estimatedContacts ?? 0).toLocaleString()}{' '}
prospects
</span>
{(wizardUpload?.name || prospectFile?.name) && (
<>
<span className="mx-2 text-slate-300">·</span>
<span className="text-slate-600">
{wizardUpload?.name || prospectFile?.name}
</span>
</>
)}
</div>
{genRunning || (genSequences.length > 0 && !genComplete) ? (
<span className="text-xs font-medium text-violet-700">
Content generation {genComplete ? 'complete' : 'in progress'}
</span>
) : null}
</div>
</div>
)}
</div>
<div className="flex flex-wrap items-center justify-end gap-2 border-t border-slate-100 bg-white px-5 py-4 sm:px-8">
{step === 4 ? (
<Button
type="button"
className="bg-violet-600 text-white hover:bg-violet-700"
onClick={handleLaunch}
>
Launch campaign
</Button>
) : null}
{step > 1 ? (
<Button type="button" variant="outline" onClick={handleBack}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Button>
) : null}
{step < 4 ? (
<Button
type="button"
disabled={step === 1 && !canContinueStep1}
className="bg-violet-600 text-white hover:bg-violet-700 disabled:opacity-40"
onClick={handleContinue}
>
{step === 1
? 'Continue to Sequence'
: step === 3
? 'Continue to Review & Launch'
: `Continue to ${STEPS[step]?.label ?? 'next'}`}
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
) : null}
</div>
</div>
</div>
);
return typeof document !== 'undefined' ? createPortal(modal, document.body) : null;
}