EMAILOUT / frontend /src /components /campaigns /CreateCampaignWizard.jsx
Seth
Claude Sonnet 5
Skip email generation for no-email contacts, make Stop generation more robust
2d73657
Raw History Blame Contribute Delete
73.2 kB
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 ( <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 && hasFailures ? ( <div className="rounded-xl bg-amber-100 p-3"> <AlertTriangle className="h-6 w-6 text-amber-600" /> </div> ) : 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 ? hasFailures ? 'Generation finished with errors' : '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 {formatWaitStep(s, { short: true })} </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 [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 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); 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(() => { 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 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 = ( <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} emailSendingSettings={emailSendingSettings} onEmailSendingSettingsChange={setEmailSendingSettings} /> ) : step === 3 ? ( <div className="space-y-8"> <ProductSelector selectedProducts={selectedProducts} onProductsChange={setSelectedProducts} /> {selectedProducts.length > 0 ? ( <> {!genRunning && (!genComplete || genFailedContacts.length > 0) ? ( <div className="rounded-xl border-2 border-violet-300 bg-gradient-to-br from-violet-50 via-white to-purple-50 p-5 shadow-sm shadow-violet-100/80"> <div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"> <div> <p className="text-base font-semibold text-slate-900"> {genFailedContacts.length > 0 ? 'Retry failed contacts' : 'Generate personalized content'} </p> <p className="mt-1 max-w-xl text-sm text-slate-600"> {genFailedContacts.length > 0 ? ( <> Some contacts failed to generate. Click{' '} <strong className="font-semibold text-violet-800"> Retry generation </strong>{' '} to regenerate content for all contacts. </> ) : ( <> Click <strong className="font-semibold text-violet-800">Generate</strong>{' '} to create emails and LinkedIn copy for all contacts. You can also skip this and continue — we&apos;ll auto-preview the first contact on the next step. </> )} </p> </div> <Button type="button" size="lg" disabled={!canGenerate} className="h-12 shrink-0 bg-gradient-to-r from-violet-600 to-purple-600 px-8 text-base font-semibold shadow-md shadow-violet-200 hover:from-violet-700 hover:to-purple-700" onClick={handleGenerate} > <Sparkles className="mr-2 h-5 w-5" /> {genFailedContacts.length > 0 ? 'Retry generation' : 'Generate all contacts'} </Button> </div> </div> ) : null} <PromptEditor ref={promptEditorRef} selectedProducts={selectedProducts} prompts={prompts} onPromptsChange={setPrompts} variant="campaign" includeLinkedinInCampaign={sequenceHasLinkedin} linkedinPrompts={linkedinPrompts} onLinkedinPromptsChange={setLinkedinPrompts} /> {genRunning ? ( <div className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-violet-200 bg-violet-50/80 px-4 py-3 text-sm text-violet-900"> <span className="flex items-center gap-2"> <Loader2 className="h-4 w-4 shrink-0 animate-spin" /> Generating in the background — you can continue to Review &amp; Launch anytime. </span> <Button type="button" size="sm" variant="outline" className="shrink-0 border-violet-300 bg-white text-violet-800 hover:bg-violet-100" disabled={genStopping} onClick={stopGeneration} > {genStopping ? ( <> <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" /> {genStopTakingLong ? 'Finishing current contact…' : 'Stopping…'} </> ) : ( 'Stop generation' )} </Button> </div> ) : genComplete && genFailedContacts.length === 0 ? ( <div className="flex items-center gap-2 rounded-lg border border-green-200 bg-green-50 px-4 py-3 text-sm text-green-800"> <CheckCircle2 className="h-4 w-4 shrink-0" /> All contact content generated. Continue to review and launch. </div> ) : null} {genFailedContacts.length > 0 ? ( <div className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900"> <p className="font-medium"> {genFailedContacts.length} contact{genFailedContacts.length === 1 ? '' : 's'} failed to generate — retry generation to fill in the rest. </p> <ul className="mt-1.5 max-h-24 space-y-0.5 overflow-y-auto text-xs text-amber-800"> {genFailedContacts.slice(0, 10).map((f, i) => ( <li key={`${f.email || f.name}-${i}`}> {f.email || f.name || 'Unknown contact'} {f.channel ? ` (${f.channel})` : ''} {f.error ? `: ${f.error}` : ''} </li> ))} </ul> </div> ) : null} <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} hasFailures={genFailedContacts.length > 0} /> </> ) : ( <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 (first contact)</p> {genRunning && !genContacts[0] ? ( <div className="rounded-xl border border-violet-200 bg-violet-50/50 px-4 py-10 text-center"> <Loader2 className="mx-auto h-8 w-8 animate-spin text-violet-600" /> <p className="mt-3 text-sm font-medium text-slate-700"> Generating preview for your first contact… </p> </div> ) : 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"> Preview will appear here shortly. You can still launch — remaining contacts generate in the background on the dashboard. </div> )} </div> {(genRunning || (!genComplete && genSequences.length > 0)) ? ( <div className="rounded-xl border border-violet-200 bg-white p-4 shadow-sm"> <div className="mb-2 flex items-center justify-between text-sm"> <span className="font-medium text-slate-800"> {genComplete ? 'Content generation complete' : genPreviewOnly ? 'Generating preview…' : 'Generating all contacts…'} </span> <span className="font-semibold text-violet-700"> {Math.min(100, Math.round(genProgress || 0))}% </span> </div> <Progress value={genProgress} className="h-2" /> {!genComplete ? ( <p className="mt-2 text-xs text-slate-500"> Launch now — generation continues on the dashboard. Sending starts automatically when every contact is ready. </p> ) : null} </div> ) : null} {!genRunning && genFailedContacts.length > 0 ? ( <div className="rounded-xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900"> <p className="font-medium"> {genFailedContacts.length} contact{genFailedContacts.length === 1 ? '' : 's'} failed to generate content and will not have a sequence unless you go back and retry generation. </p> </div> ) : null} <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={() => void handleLaunch()} > {genComplete ? 'Launch campaign' : 'Launch & finish generating'} </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 (auto-preview first contact)' : `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; }
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;