| import React, { |
| createContext, |
| useCallback, |
| useContext, |
| useEffect, |
| useMemo, |
| useRef, |
| useState, |
| } from 'react'; |
| import { apiFetch } from '@/lib/api'; |
| import { mergeSequenceIntoContacts } from '@/lib/mergeSequenceIntoContacts'; |
|
|
| const GeneratorWorkflowContext = createContext(null); |
|
|
| export function GeneratorWorkflowProvider({ children }) { |
| const [step, setStep] = useState(1); |
| const [uploadedFile, setUploadedFile] = useState(null); |
| const [selectedProducts, setSelectedProducts] = useState([]); |
| const [prompts, setPrompts] = useState({}); |
| const [linkedinPrompts, setLinkedinPrompts] = useState({}); |
| const [includeLinkedin, setIncludeLinkedin] = useState(false); |
| const [isGenerating, setIsGenerating] = useState(false); |
| const [generationComplete, setGenerationComplete] = useState(false); |
| const [generationRunId, setGenerationRunId] = useState(0); |
| const [genPhase, setGenPhase] = useState('email'); |
| const [contacts, setContacts] = useState([]); |
| const [sequences, setSequences] = useState([]); |
| const [progress, setProgress] = useState(0); |
| const [streamComplete, setStreamComplete] = useState(false); |
| const [reconnectKey, setReconnectKey] = useState(0); |
|
|
| const prevRunIdRef = useRef(null); |
| const eventSourceRef = useRef(null); |
|
|
| const resetWorkflow = useCallback(() => { |
| if (eventSourceRef.current) { |
| eventSourceRef.current.close(); |
| eventSourceRef.current = null; |
| } |
| setStep(1); |
| setUploadedFile(null); |
| setSelectedProducts([]); |
| setPrompts({}); |
| setLinkedinPrompts({}); |
| setIncludeLinkedin(false); |
| setIsGenerating(false); |
| setGenerationComplete(false); |
| setGenerationRunId(0); |
| setGenPhase('email'); |
| setContacts([]); |
| setSequences([]); |
| setProgress(0); |
| setStreamComplete(false); |
| prevRunIdRef.current = null; |
| }, []); |
|
|
| const beginGeneration = useCallback((runIdIncrement = true) => { |
| setStreamComplete(false); |
| setGenerationComplete(false); |
| setContacts([]); |
| setSequences([]); |
| setProgress(0); |
| setGenPhase('email'); |
| setIsGenerating(true); |
| if (runIdIncrement) { |
| setGenerationRunId((r) => r + 1); |
| } |
| }, []); |
|
|
| const contactCount = uploadedFile?.contactCount || 0; |
|
|
| useEffect(() => { |
| if (!isGenerating || !uploadedFile?.fileId) return; |
|
|
| const isNewRun = prevRunIdRef.current !== generationRunId; |
| if (isNewRun) { |
| prevRunIdRef.current = generationRunId; |
| setContacts([]); |
| setSequences([]); |
| setProgress(0); |
| setStreamComplete(false); |
| } |
|
|
| const reset = isNewRun ? 1 : 0; |
| const url = `/api/generate-sequences?file_id=${encodeURIComponent(uploadedFile.fileId)}&reset=${reset}`; |
| const eventSource = new EventSource(url); |
| eventSourceRef.current = eventSource; |
|
|
| eventSource.onmessage = (event) => { |
| try { |
| const data = JSON.parse(event.data); |
| if (data.type === 'sequence') { |
| const seq = data.sequence; |
| setSequences((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]; |
| }); |
| setContacts((prev) => mergeSequenceIntoContacts(prev, seq)); |
| } else if (data.type === 'progress') { |
| if (typeof data.progress === 'number') setProgress(data.progress); |
| } else if (data.type === 'phase') { |
| if (data.phase === 'linkedin') setGenPhase('linkedin'); |
| } else if (data.type === 'complete') { |
| setStreamComplete(true); |
| setIsGenerating(false); |
| setGenerationComplete(true); |
| eventSource.close(); |
| eventSourceRef.current = null; |
| } else if (data.type === 'error') { |
| console.error('Generation error:', data.error); |
| alert('Error generating sequences: ' + data.error); |
| eventSource.close(); |
| eventSourceRef.current = null; |
| setIsGenerating(false); |
| } |
| } catch (err) { |
| console.error('Error parsing SSE data:', err); |
| } |
| }; |
|
|
| eventSource.onerror = () => { |
| eventSource.close(); |
| eventSourceRef.current = null; |
| if (!streamComplete) setReconnectKey((k) => k + 1); |
| }; |
|
|
| return () => { |
| eventSource.close(); |
| if (eventSourceRef.current === eventSource) eventSourceRef.current = null; |
| }; |
| }, [isGenerating, uploadedFile?.fileId, generationRunId]); |
|
|
| useEffect(() => { |
| if (!isGenerating || !uploadedFile?.fileId || reconnectKey === 0) return; |
| let cancelled = false; |
| (async () => { |
| try { |
| const [statusRes, seqRes] = await Promise.all([ |
| apiFetch(`/api/generation-status?file_id=${encodeURIComponent(uploadedFile.fileId)}`), |
| apiFetch(`/api/sequences?file_id=${encodeURIComponent(uploadedFile.fileId)}`), |
| ]); |
| if (cancelled) return; |
| if (statusRes.ok && seqRes.ok) { |
| const status = await statusRes.json(); |
| const { sequences: list } = await seqRes.json(); |
| if (status.is_complete) { |
| setStreamComplete(true); |
| setIsGenerating(false); |
| setGenerationComplete(true); |
| } |
| if (list?.length > 0) { |
| const byContact = new Map(); |
| list.forEach((seq) => { |
| const key = seq.email; |
| const ch = seq.channel || 'email'; |
| if (!byContact.has(key)) { |
| byContact.set(key, { |
| id: seq.id, |
| firstName: seq.firstName, |
| lastName: seq.lastName, |
| email: seq.email, |
| company: seq.company, |
| title: seq.title, |
| product: seq.product, |
| emails: [], |
| linkedin: [], |
| }); |
| } |
| const row = { |
| emailNumber: seq.emailNumber, |
| subject: seq.subject, |
| emailContent: seq.emailContent, |
| channel: ch, |
| stepOrder: seq.stepOrder, |
| }; |
| const dedupe = (e) => |
| e.stepOrder != null |
| ? `ord-${e.stepOrder}-${ch}` |
| : `${ch}-${e.emailNumber}`; |
| const rowKey = dedupe(row); |
| const c = byContact.get(key); |
| if (ch === 'linkedin') { |
| if (!c.linkedin.some((e) => dedupe(e) === rowKey)) { |
| c.linkedin.push(row); |
| } |
| } else { |
| if (!c.emails.some((e) => dedupe(e) === rowKey)) { |
| c.emails.push(row); |
| } |
| } |
| }); |
| const arr = [...byContact.values()]; |
| arr.sort((a, b) => (a.id || 0) - (b.id || 0)); |
| setSequences(list); |
| setContacts(arr); |
| if (status.total_contacts > 0) { |
| const exp = |
| status.campaign_sequence_actions && status.total_contacts > 0 |
| ? status.total_contacts * status.campaign_sequence_actions |
| : status.has_linkedin_prompts |
| ? status.total_contacts * 2 |
| : status.total_contacts; |
| setProgress( |
| Math.min(100, ((status.completed_count || 0) / exp) * 100) |
| ); |
| } |
| } |
| } |
| } catch (e) { |
| if (!cancelled) console.error('Reconnect fetch error:', e); |
| } |
| })(); |
| return () => { |
| cancelled = true; |
| }; |
| }, [reconnectKey, isGenerating, uploadedFile?.fileId, contactCount]); |
|
|
| useEffect(() => { |
| if (!isGenerating || !uploadedFile?.fileId) return; |
| const onVisible = () => { |
| if (document.visibilityState === 'visible') setReconnectKey((k) => k + 1); |
| }; |
| document.addEventListener('visibilitychange', onVisible); |
| return () => document.removeEventListener('visibilitychange', onVisible); |
| }, [isGenerating, uploadedFile?.fileId]); |
|
|
| const value = useMemo( |
| () => ({ |
| step, |
| setStep, |
| uploadedFile, |
| setUploadedFile, |
| selectedProducts, |
| setSelectedProducts, |
| prompts, |
| setPrompts, |
| linkedinPrompts, |
| setLinkedinPrompts, |
| includeLinkedin, |
| setIncludeLinkedin, |
| isGenerating, |
| generationComplete, |
| generationRunId, |
| beginGeneration, |
| setIsGenerating, |
| setGenerationComplete, |
| resetWorkflow, |
| contacts, |
| sequences, |
| progress, |
| genPhase, |
| streamComplete, |
| contactCount, |
| }), |
| [ |
| step, |
| uploadedFile, |
| selectedProducts, |
| prompts, |
| linkedinPrompts, |
| includeLinkedin, |
| isGenerating, |
| generationComplete, |
| generationRunId, |
| beginGeneration, |
| resetWorkflow, |
| contacts, |
| sequences, |
| progress, |
| genPhase, |
| streamComplete, |
| contactCount, |
| ] |
| ); |
|
|
| return ( |
| <GeneratorWorkflowContext.Provider value={value}>{children}</GeneratorWorkflowContext.Provider> |
| ); |
| } |
|
|
| export function useGeneratorWorkflow() { |
| const ctx = useContext(GeneratorWorkflowContext); |
| if (!ctx) { |
| throw new Error('useGeneratorWorkflow must be used within GeneratorWorkflowProvider'); |
| } |
| return ctx; |
| } |
|
|