| 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]); |
|
|
| |
| 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; |
| } |
|
|