Download frontend/src/components/campaigns/CreateCampaignWizard.jsx from Seth0330/qualifier: direct link, hf CLI and curl.
- Browser
- Download file 73.4 kB
-
https://huggingface.co/spaces/Seth0330/qualifier/resolve/main/frontend/src/components/campaigns/CreateCampaignWizard.jsx
- Command line
-
hf download hf://spaces/Seth0330/qualifier/frontend/src/components/campaigns/CreateCampaignWizard.jsx
-
curl -L -o CreateCampaignWizard.jsx https://huggingface.co/spaces/Seth0330/qualifier/resolve/main/frontend/src/components/campaigns/CreateCampaignWizard.jsx
73.4 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 sequenceHasGmail = useMemo( | |
| () => sequenceSteps.some((s) => s.type === 'action' && s.channel === 'gmail'), | |
| [sequenceSteps] | |
| ); | |
| const reset = useCallback(() => { | |
| if (eventSourceRef.current) { | |
| eventSourceRef.current.close(); | |
| eventSourceRef.current = null; | |
| } | |
| setStep(1); | |
| setCampaignName(''); | |
| setProspectFile(null); | |
| setWizardUpload(null); | |
| setUploadingCsv(false); | |
| setEstimatedContacts(0); | |
| setDragOver(false); | |
| setSequenceSteps(createDefaultSequenceSteps()); | |
| setParallelTracks(false); | |
| setEmailSendingSettings(defaultEmailSendingSettings()); | |
| setSelectedProducts([]); | |
| setPrompts({}); | |
| setLinkedinPrompts({}); | |
| setGenSequences([]); | |
| setGenContacts([]); | |
| setGenProgress(0); | |
| setGenRunning(false); | |
| setGenComplete(false); | |
| setGenPhase('email'); | |
| setGenRunId(0); | |
| setGenPreviewOnly(false); | |
| setGenFailedContacts([]); | |
| setGenStopping(false); | |
| setGenStopTakingLong(false); | |
| clearTimeout(genStopTimerRef.current); | |
| prevGenRunIdRef.current = null; | |
| previewTriggeredRef.current = false; | |
| }, []); | |
| useEffect(() => { | |
| if (!open) { | |
| if (genRunning) return; | |
| reset(); | |
| } | |
| }, [open, genRunning, reset]); | |
| /** Fresh "Create" session — clear any in-component state (including orphan streams). */ | |
| useEffect(() => { | |
| if (open && !initialCampaign?.id) { | |
| reset(); | |
| } | |
| }, [open, initialCampaign?.id, reset]); | |
| // Debounced backend auto-save of campaign identity — fires on name edits and every step | |
| // transition, not only when the wizard is explicitly closed, so an abrupt tab close never | |
| // strands already-generated server-side content with no way back to it. | |
| useEffect(() => { | |
| if (!open || !wizardUpload?.fileId) return; | |
| const fileId = wizardUpload.fileId; | |
| const name = campaignName.trim() || 'Untitled campaign'; | |
| const timer = setTimeout(() => { | |
| apiFetch(`/api/campaign-files/${encodeURIComponent(fileId)}/meta`, { | |
| method: 'PATCH', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ file_id: fileId, campaign_name: name, status: 'draft' }), | |
| }).catch((e) => console.error(e)); | |
| }, 800); | |
| return () => clearTimeout(timer); | |
| }, [open, wizardUpload?.fileId, campaignName, step]); | |
| useEffect(() => { | |
| if (!open || !initialCampaign?.id) return; | |
| if (hydratedCampaignIdRef.current === initialCampaign.id) return; | |
| hydratedCampaignIdRef.current = initialCampaign.id; | |
| const incomingFile = initialCampaign.fileId || null; | |
| const currentFile = wizardUpload?.fileId ?? null; | |
| if (eventSourceRef.current) { | |
| const keepStream = | |
| !!incomingFile && !!currentFile && incomingFile === currentFile; | |
| if (!keepStream) { | |
| eventSourceRef.current.close(); | |
| eventSourceRef.current = null; | |
| } | |
| } | |
| setGenSequences([]); | |
| setGenContacts([]); | |
| setGenRunId(0); | |
| prevGenRunIdRef.current = null; | |
| setCampaignName(initialCampaign.name || ''); | |
| setProspectFile(null); | |
| if (initialCampaign.fileId) { | |
| setWizardUpload({ | |
| fileId: initialCampaign.fileId, | |
| contactCount: initialCampaign.contacts ?? 0, | |
| name: initialCampaign.prospectFileName || '', | |
| }); | |
| } else { | |
| setWizardUpload(null); | |
| } | |
| const seq = initialCampaign.sequence; | |
| const hasLocalSequence = Array.isArray(seq) && seq.length > 0; | |
| if (hasLocalSequence) { | |
| setSequenceSteps(seq); | |
| } else { | |
| setSequenceSteps(createDefaultSequenceSteps()); | |
| } | |
| // Local dashboard state can be missing the sequence entirely — e.g. a campaign | |
| // reconciled in from the backend list on a fresh session never had local `sequence` | |
| // populated. Fall back to the backend's saved sequence_plan_json so reopening a | |
| // campaign never silently resets it to the default Gmail-only template. Email pacing | |
| // settings live only on the backend record (never in local dashboard state), so this | |
| // fetch also runs when a local sequence is already present, purely to restore those. | |
| if (initialCampaign.fileId) { | |
| const fid = initialCampaign.fileId; | |
| const campaignId = initialCampaign.id; | |
| (async () => { | |
| try { | |
| const res = await apiFetch(`/api/campaign-files/${encodeURIComponent(fid)}`); | |
| if (!res.ok) return; | |
| const detail = await res.json(); | |
| if (hydratedCampaignIdRef.current !== campaignId) return; | |
| if (!hasLocalSequence && Array.isArray(detail.sequence_plan) && detail.sequence_plan.length > 0) { | |
| setSequenceSteps(detail.sequence_plan); | |
| setParallelTracks(!!detail.parallel_tracks); | |
| } | |
| if (detail.email_timezone !== undefined) { | |
| setEmailSendingSettings({ | |
| timezone: detail.email_timezone || defaultEmailSendingSettings().timezone, | |
| businessHoursStart: | |
| detail.email_business_hours_start ?? 8, | |
| businessHoursEnd: detail.email_business_hours_end ?? 18, | |
| businessDays: (detail.email_business_days || '1,2,3,4,5') | |
| .split(',') | |
| .map((d) => parseInt(d, 10)) | |
| .filter((d) => !Number.isNaN(d)), | |
| intervalSeconds: detail.email_send_interval_seconds ?? 60, | |
| }); | |
| } | |
| if (!hasLocalSequence && detail.prompts && Object.keys(detail.prompts).length) { | |
| setPrompts((prev) => (Object.keys(prev).length ? prev : detail.prompts)); | |
| } | |
| if (detail.linkedin_prompts && Object.keys(detail.linkedin_prompts).length) { | |
| setLinkedinPrompts((prev) => | |
| Object.keys(prev).length ? prev : detail.linkedin_prompts | |
| ); | |
| } | |
| if (Array.isArray(detail.products) && detail.products.length) { | |
| setSelectedProducts((prev) => | |
| prev.length | |
| ? prev | |
| : detail.products.map((name, i) => ({ id: `backend-${i}-${name}`, name })) | |
| ); | |
| } | |
| } catch (e) { | |
| console.error(e); | |
| } | |
| })(); | |
| } | |
| setParallelTracks(!!initialCampaign.parallelTracks); | |
| setSelectedProducts( | |
| Array.isArray(initialCampaign.selectedProducts) ? initialCampaign.selectedProducts : [] | |
| ); | |
| setEstimatedContacts(initialCampaign.contacts || 0); | |
| setGenComplete(!!initialCampaign.generationComplete); | |
| setGenRunning(false); | |
| setGenProgress(initialCampaign.generationProgressPercent ?? 0); | |
| const ws = | |
| typeof initialCampaign.wizardStep === 'number' | |
| ? Math.min(4, Math.max(1, initialCampaign.wizardStep)) | |
| : 1; | |
| setStep(ws); | |
| setPrompts( | |
| initialCampaign.wizardPrompts && typeof initialCampaign.wizardPrompts === 'object' | |
| ? { ...initialCampaign.wizardPrompts } | |
| : {} | |
| ); | |
| setLinkedinPrompts( | |
| initialCampaign.wizardLinkedinPrompts && | |
| typeof initialCampaign.wizardLinkedinPrompts === 'object' | |
| ? { ...initialCampaign.wizardLinkedinPrompts } | |
| : {} | |
| ); | |
| }, [open, initialCampaign?.id, wizardUpload?.fileId]); | |
| useEffect(() => { | |
| if (!open || !wizardUpload?.fileId || !initialCampaign?.id) return; | |
| let cancelled = false; | |
| (async () => { | |
| try { | |
| const [stRes, seqRes] = await Promise.all([ | |
| apiFetch(`/api/generation-status?file_id=${encodeURIComponent(wizardUpload.fileId)}`), | |
| apiFetch(`/api/sequences?file_id=${encodeURIComponent(wizardUpload.fileId)}`), | |
| ]); | |
| if (cancelled) return; | |
| if (seqRes.ok) { | |
| const data = await seqRes.json(); | |
| const list = data.sequences || []; | |
| setGenSequences(list); | |
| setGenContacts(sequenceListToContacts(list)); | |
| } | |
| if (stRes.ok) { | |
| const st = await stRes.json(); | |
| if (!cancelled) { | |
| setGenComplete(!!st.is_complete); | |
| setGenProgress(progressFromGenerationStatus(st)); | |
| } | |
| } | |
| } catch (e) { | |
| if (!cancelled) console.error(e); | |
| } | |
| })(); | |
| return () => { | |
| cancelled = true; | |
| }; | |
| }, [open, wizardUpload?.fileId, initialCampaign?.id]); | |
| useEffect(() => { | |
| if (!open) hydratedCampaignIdRef.current = null; | |
| }, [open]); | |
| useEffect(() => { | |
| if (!prospectFile) { | |
| setEstimatedContacts(0); | |
| return; | |
| } | |
| let cancelled = false; | |
| estimateCsvRows(prospectFile).then((n) => { | |
| if (!cancelled) setEstimatedContacts(n); | |
| }); | |
| return () => { | |
| cancelled = true; | |
| }; | |
| }, [prospectFile]); | |
| useEffect(() => { | |
| if (!open) { | |
| setLinkedinDefaults(null); | |
| setMailboxDefaults(null); | |
| return; | |
| } | |
| let cancelled = false; | |
| Promise.allSettled([ | |
| apiFetch('/api/unipile/linkedin/campaign-defaults').then((r) => r.json()), | |
| apiFetch('/api/unipile/mailbox/campaign-defaults').then((r) => r.json()), | |
| ]).then(([li, mb]) => { | |
| if (cancelled) return; | |
| setLinkedinDefaults( | |
| li.status === 'fulfilled' | |
| ? li.value | |
| : { | |
| accounts: [], | |
| linkedin_profile_display_name: '', | |
| default_unipile_account_ref_id: null, | |
| } | |
| ); | |
| setMailboxDefaults( | |
| mb.status === 'fulfilled' | |
| ? mb.value | |
| : { | |
| accounts: [], | |
| mailbox_profile_display_name: '', | |
| default_mailbox_unipile_account_ref_id: null, | |
| } | |
| ); | |
| }); | |
| return () => { | |
| cancelled = true; | |
| }; | |
| }, [open]); | |
| useEffect(() => { | |
| if (!linkedinDefaults) return; | |
| setSequenceSteps((prev) => | |
| prev.map((s) => { | |
| if (s.type !== 'action' || s.channel !== 'linkedin') return s; | |
| const ref = | |
| s.unipile_account_ref_id ?? linkedinDefaults.default_unipile_account_ref_id ?? null; | |
| const sender = | |
| (s.sender_profile_name || '').trim() || | |
| (linkedinDefaults.linkedin_profile_display_name || '').trim(); | |
| return { | |
| ...s, | |
| unipile_account_ref_id: ref, | |
| sender_profile_name: sender, | |
| }; | |
| }) | |
| ); | |
| }, [linkedinDefaults]); | |
| useEffect(() => { | |
| if (!mailboxDefaults) return; | |
| const accounts = mailboxDefaults.accounts || []; | |
| const fallbackId = accounts[0]?.id != null ? Number(accounts[0].id) : null; | |
| setSequenceSteps((prev) => | |
| prev.map((s) => { | |
| if (s.type !== 'action' || s.channel !== 'gmail') return s; | |
| const ref = | |
| s.unipile_account_ref_id ?? | |
| (mailboxDefaults.default_mailbox_unipile_account_ref_id != null | |
| ? Number(mailboxDefaults.default_mailbox_unipile_account_ref_id) | |
| : null) ?? | |
| fallbackId; | |
| return { ...s, unipile_account_ref_id: ref }; | |
| }) | |
| ); | |
| }, [mailboxDefaults]); | |
| useEffect(() => { | |
| if (step !== 3 || selectedProducts.length === 0) return; | |
| const appendix = buildCampaignSequenceAppendix(sequenceSteps, mailboxDefaults, parallelTracks); | |
| setPrompts((prev) => { | |
| const next = { ...prev }; | |
| let changed = false; | |
| selectedProducts.forEach((p) => { | |
| let cur = (next[p.name] || '').trim(); | |
| if (!cur) { | |
| cur = DEFAULT_TEMPLATES[p.name] || ''; | |
| } | |
| if (!cur || cur.includes(WIZARD_SEQUENCE_TAG)) return; | |
| next[p.name] = `${cur}\n\n${appendix}`; | |
| changed = true; | |
| }); | |
| return changed ? next : prev; | |
| }); | |
| if (!sequenceHasLinkedin) return; | |
| setLinkedinPrompts((prev) => { | |
| const next = { ...prev }; | |
| let changed = false; | |
| selectedProducts.forEach((p) => { | |
| let cur = (next[p.name] || '').trim(); | |
| if (!cur) { | |
| cur = | |
| LINKEDIN_DEFAULT_TEMPLATES[p.name] || | |
| `🔒 LINKEDIN SYSTEM PROMPT\n\nYou write LinkedIn connection notes and DMs for ${p.name}. Message 1: peer-style connection only (~280 chars), no pitch; later messages add detail. Sign DMs with {{sender_name}}.\nMatch the Message count in the generation request exactly.`; | |
| } | |
| if (!cur || cur.includes(WIZARD_SEQUENCE_TAG)) return; | |
| next[p.name] = `${cur}\n\n${appendix}`; | |
| changed = true; | |
| }); | |
| return changed ? next : prev; | |
| }); | |
| }, [step, sequenceSteps, selectedProducts, sequenceHasLinkedin, mailboxDefaults, parallelTracks]); | |
| const savePromptsToServer = useCallback(async () => { | |
| promptEditorRef.current?.commitCampaignPrompts?.(); | |
| const res = await apiFetch('/api/save-prompts', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ | |
| file_id: wizardUpload.fileId, | |
| prompts, | |
| products: selectedProducts.map((p) => p.name), | |
| linkedin_prompts: sequenceHasLinkedin ? linkedinPrompts : {}, | |
| sequence_plan: parallelTracks | |
| ? { parallel_tracks: true, steps: sequenceSteps } | |
| : sequenceSteps, | |
| campaign_name: campaignName.trim() || 'Untitled campaign', | |
| email_timezone: emailSendingSettings.timezone || null, | |
| email_business_hours_start: emailSendingSettings.businessHoursStart, | |
| email_business_hours_end: emailSendingSettings.businessHoursEnd, | |
| email_business_days: (emailSendingSettings.businessDays || []).join(','), | |
| email_send_interval_seconds: emailSendingSettings.intervalSeconds, | |
| }), | |
| }); | |
| if (!res.ok) { | |
| const err = await res.json().catch(() => ({})); | |
| throw new Error(err.detail || res.statusText); | |
| } | |
| }, [ | |
| wizardUpload?.fileId, | |
| prompts, | |
| selectedProducts, | |
| linkedinPrompts, | |
| sequenceHasLinkedin, | |
| sequenceSteps, | |
| parallelTracks, | |
| campaignName, | |
| emailSendingSettings, | |
| ]); | |
| const stopGeneration = useCallback(async () => { | |
| if (!wizardUpload?.fileId) return; | |
| setGenStopping(true); | |
| setGenStopTakingLong(false); | |
| clearTimeout(genStopTimerRef.current); | |
| // The backend only notices the cancel flag at its next per-contact checkpoint, so a | |
| // contact mid-generation can take a while to wind down. Relabel the button rather than | |
| // leaving a bare spinner that looks stuck once this stretches past a few seconds. | |
| genStopTimerRef.current = setTimeout(() => setGenStopTakingLong(true), 8000); | |
| const postCancel = () => | |
| apiFetch(`/api/campaign-files/${encodeURIComponent(wizardUpload.fileId)}/generation/cancel`, { | |
| method: 'POST', | |
| }); | |
| try { | |
| let res = await postCancel(); | |
| if (!res.ok) { | |
| // One retry for a transient failure before giving up — a silently-failed cancel | |
| // request would otherwise leave the flag unset and generation running forever | |
| // while the button stays stuck on "Stopping…". | |
| res = await postCancel(); | |
| } | |
| if (!res.ok) { | |
| const err = await res.json().catch(() => ({})); | |
| throw new Error(err.detail || `Cancel request failed (${res.status})`); | |
| } | |
| } catch (e) { | |
| console.error(e); | |
| alert(e.message || 'Could not stop generation — please try again.'); | |
| setGenStopping(false); | |
| setGenStopTakingLong(false); | |
| clearTimeout(genStopTimerRef.current); | |
| } | |
| }, [wizardUpload?.fileId]); | |
| const startGenerationRun = useCallback( | |
| ({ reset, preview = false }) => { | |
| if (!wizardUpload?.fileId) return; | |
| genRunOptionsRef.current = { reset: !!reset, preview: !!preview }; | |
| setGenPreviewOnly(!!preview); | |
| setGenRunId((r) => r + 1); | |
| setGenRunning(true); | |
| if (reset) { | |
| setGenSequences([]); | |
| setGenContacts([]); | |
| setGenProgress(0); | |
| setGenComplete(false); | |
| setGenPhase('email'); | |
| } | |
| }, | |
| [wizardUpload?.fileId] | |
| ); | |
| useEffect(() => { | |
| if (!genRunning || !wizardUpload?.fileId) return; | |
| const isNewRun = prevGenRunIdRef.current !== genRunId; | |
| if (isNewRun) { | |
| prevGenRunIdRef.current = genRunId; | |
| setGenFailedContacts([]); | |
| } | |
| if (eventSourceRef.current) { | |
| eventSourceRef.current.close(); | |
| eventSourceRef.current = null; | |
| } | |
| const runOpts = genRunOptionsRef.current; | |
| const url = buildGenerateSequencesUrl(wizardUpload.fileId, { | |
| reset: runOpts.reset, | |
| preview: runOpts.preview, | |
| contactLimit: runOpts.preview ? 1 : null, | |
| }); | |
| const es = new EventSource(url, { withCredentials: true }); | |
| eventSourceRef.current = es; | |
| es.onmessage = (event) => { | |
| try { | |
| const data = JSON.parse(event.data); | |
| if (data.type === 'sequence') { | |
| const seq = data.sequence; | |
| setGenSequences((prev) => { | |
| const sig = `${seq.id}-${seq.emailNumber}-${seq.channel || 'email'}-${seq.stepOrder ?? ''}`; | |
| if ( | |
| prev.some( | |
| (s) => | |
| `${s.id}-${s.emailNumber}-${s.channel || 'email'}-${s.stepOrder ?? ''}` === sig | |
| ) | |
| ) { | |
| return prev; | |
| } | |
| return [...prev, seq]; | |
| }); | |
| setGenContacts((prev) => mergeSequenceIntoContacts(prev, seq)); | |
| } else if (data.type === 'progress' && typeof data.progress === 'number') { | |
| setGenProgress(data.progress); | |
| } else if (data.type === 'phase' && data.phase === 'linkedin') { | |
| setGenPhase('linkedin'); | |
| } else if (data.type === 'contact_error') { | |
| setGenFailedContacts((prev) => [ | |
| ...prev, | |
| { | |
| email: data.email || '', | |
| name: data.name || '', | |
| channel: data.channel || '', | |
| error: data.error || '', | |
| }, | |
| ]); | |
| } else if (data.type === 'cancelled') { | |
| setGenRunning(false); | |
| setGenPreviewOnly(false); | |
| setGenStopping(false); | |
| setGenStopTakingLong(false); | |
| clearTimeout(genStopTimerRef.current); | |
| es.close(); | |
| if (eventSourceRef.current === es) eventSourceRef.current = null; | |
| } else if (data.type === 'preview_complete') { | |
| setGenRunning(false); | |
| setGenPreviewOnly(false); | |
| es.close(); | |
| if (eventSourceRef.current === es) eventSourceRef.current = null; | |
| } else if (data.type === 'complete') { | |
| setGenRunning(false); | |
| setGenComplete(true); | |
| setGenPreviewOnly(false); | |
| setGenStopping(false); | |
| setGenStopTakingLong(false); | |
| clearTimeout(genStopTimerRef.current); | |
| es.close(); | |
| if (eventSourceRef.current === es) eventSourceRef.current = null; | |
| if (!openRef.current) { | |
| reset(); | |
| } | |
| } else if (data.type === 'error') { | |
| console.error(data.error); | |
| alert(`Generation error: ${data.error}`); | |
| setGenRunning(false); | |
| setGenPreviewOnly(false); | |
| setGenStopping(false); | |
| setGenStopTakingLong(false); | |
| clearTimeout(genStopTimerRef.current); | |
| es.close(); | |
| if (eventSourceRef.current === es) eventSourceRef.current = null; | |
| if (!openRef.current) { | |
| reset(); | |
| } | |
| } | |
| } catch (e) { | |
| console.error(e); | |
| } | |
| }; | |
| es.onerror = () => { | |
| es.close(); | |
| if (eventSourceRef.current === es) eventSourceRef.current = null; | |
| }; | |
| return () => { | |
| es.close(); | |
| if (eventSourceRef.current === es) eventSourceRef.current = null; | |
| }; | |
| }, [genRunning, wizardUpload?.fileId, genRunId, reset]); | |
| const pickFile = async (file) => { | |
| if (!file) return; | |
| if (!file.name.toLowerCase().endsWith('.csv')) { | |
| alert('Please upload a .csv file (Apollo export), same as Email / AI Generator.'); | |
| return; | |
| } | |
| setProspectFile(file); | |
| setWizardUpload(null); | |
| setUploadingCsv(true); | |
| try { | |
| const formData = new FormData(); | |
| formData.append('file', file); | |
| const response = await apiFetch('/api/upload-csv', { | |
| method: 'POST', | |
| body: formData, | |
| }); | |
| if (!response.ok) { | |
| throw new Error('Upload failed'); | |
| } | |
| const data = await response.json(); | |
| setWizardUpload({ | |
| fileId: data.file_id, | |
| contactCount: data.contact_count, | |
| name: file.name, | |
| }); | |
| } catch (err) { | |
| console.error(err); | |
| alert('Could not upload CSV. Please try again.'); | |
| setProspectFile(null); | |
| setWizardUpload(null); | |
| } finally { | |
| setUploadingCsv(false); | |
| } | |
| }; | |
| const canContinueStep1 = | |
| campaignName.trim().length > 0 && !!wizardUpload?.fileId && !uploadingCsv; | |
| const canGenerate = useMemo(() => { | |
| if (selectedProducts.length === 0 || !wizardUpload?.fileId) return false; | |
| const emailOk = !sequenceHasGmail || selectedProducts.every((p) => (prompts[p.name] || '').trim()); | |
| if (!emailOk) return false; | |
| if (!sequenceHasLinkedin) return true; | |
| return selectedProducts.every((p) => (linkedinPrompts[p.name] || '').trim()); | |
| }, [selectedProducts, prompts, linkedinPrompts, wizardUpload?.fileId, sequenceHasLinkedin, sequenceHasGmail]); | |
| const triggerPreviewGeneration = useCallback(async () => { | |
| if (!canGenerate || !wizardUpload?.fileId || genRunning) return; | |
| if (previewTriggeredRef.current && genContacts.length > 0) return; | |
| previewTriggeredRef.current = true; | |
| try { | |
| await savePromptsToServer(); | |
| startGenerationRun({ | |
| reset: genSequences.length === 0, | |
| preview: true, | |
| }); | |
| } catch (e) { | |
| console.error(e); | |
| previewTriggeredRef.current = false; | |
| } | |
| }, [ | |
| canGenerate, | |
| wizardUpload?.fileId, | |
| genRunning, | |
| genContacts.length, | |
| genSequences.length, | |
| savePromptsToServer, | |
| startGenerationRun, | |
| ]); | |
| useEffect(() => { | |
| if (step === 4 && canGenerate && wizardUpload?.fileId && genContacts.length === 0 && !genRunning) { | |
| void triggerPreviewGeneration(); | |
| } | |
| }, [step, canGenerate, wizardUpload?.fileId, genContacts.length, genRunning, triggerPreviewGeneration]); | |
| const handleContinue = () => { | |
| if (step === 1 && !canContinueStep1) return; | |
| if (step < 4) setStep((s) => s + 1); | |
| }; | |
| const handleBack = () => { | |
| if (step > 1) setStep((s) => s - 1); | |
| }; | |
| const requestClose = () => { | |
| const committed = promptEditorRef.current?.commitCampaignPrompts?.() ?? null; | |
| const mergedPrompts = | |
| committed && typeof committed === 'object' && committed.prompts | |
| ? committed.prompts | |
| : prompts; | |
| const mergedLinkedin = | |
| committed && typeof committed === 'object' && committed.linkedinPrompts | |
| ? committed.linkedinPrompts | |
| : linkedinPrompts; | |
| const payload = { | |
| id: initialCampaign?.id, | |
| name: campaignName.trim() || 'Untitled campaign', | |
| contacts: wizardUpload?.contactCount ?? estimatedContacts ?? 0, | |
| prospectFileName: wizardUpload?.name || prospectFile?.name || '', | |
| fileId: wizardUpload?.fileId || null, | |
| sequence: sequenceSteps, | |
| parallelTracks, | |
| selectedProducts, | |
| generationComplete: genComplete, | |
| generationProgressPercent: Math.min(100, Math.round(genProgress || 0)), | |
| wizardStep: step, | |
| wizardPrompts: mergedPrompts, | |
| wizardLinkedinPrompts: mergedLinkedin, | |
| }; | |
| if (onDraftPersist && (payload.fileId || payload.id)) { | |
| onDraftPersist(payload); | |
| } | |
| onOpenChange(false); | |
| }; | |
| const handleGenerate = async () => { | |
| if (!canGenerate) { | |
| alert('Select products and fill all prompt templates before generating.'); | |
| return; | |
| } | |
| try { | |
| await savePromptsToServer(); | |
| } catch (e) { | |
| console.error(e); | |
| alert('Failed to save prompts. Please try again.'); | |
| return; | |
| } | |
| previewTriggeredRef.current = true; | |
| startGenerationRun({ reset: true, preview: false }); | |
| }; | |
| const handleLaunch = async () => { | |
| if (!onComplete) { | |
| requestClose(); | |
| return; | |
| } | |
| try { | |
| if (canGenerate && wizardUpload?.fileId) { | |
| await savePromptsToServer(); | |
| } | |
| } catch (e) { | |
| console.error(e); | |
| alert('Failed to save campaign settings. Please try again.'); | |
| return; | |
| } | |
| const hasPartial = genSequences.length > 0 && !genComplete; | |
| const needsBackgroundGen = !genComplete; | |
| onComplete({ | |
| campaignId: initialCampaign?.id ?? null, | |
| name: campaignName.trim(), | |
| contacts: wizardUpload?.contactCount || estimatedContacts || 0, | |
| prospectFileName: wizardUpload?.name || prospectFile?.name || '', | |
| fileId: wizardUpload?.fileId || null, | |
| sequence: sequenceSteps, | |
| parallelTracks, | |
| selectedProducts, | |
| generationComplete: genComplete, | |
| generationProgressPercent: Math.min(100, Math.round(genProgress || 0)), | |
| startBackgroundGeneration: needsBackgroundGen, | |
| resumeGeneration: hasPartial, | |
| }); | |
| onOpenChange(false); | |
| }; | |
| useEffect(() => { | |
| if (!open) return; | |
| const onKey = (e) => { | |
| if (e.key === 'Escape') requestClose(); | |
| }; | |
| window.addEventListener('keydown', onKey); | |
| return () => window.removeEventListener('keydown', onKey); | |
| }, [open, requestClose]); | |
| if (!open) return null; | |
| const modal = ( | |
| <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'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 & 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; | |
| } | |