| | import React, { useState, useEffect, useRef } from 'react'; |
| | import { Download, Mail, Loader2, CheckCircle2, Search, Filter } 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 { motion, AnimatePresence } from 'framer-motion'; |
| | import SequenceCard from './SequenceCard'; |
| |
|
| | function applySequenceToContacts(prev, sequence, contactCount, setProgress) { |
| | const existingContact = prev.find(c => |
| | c.firstName === sequence.firstName && |
| | c.lastName === sequence.lastName && |
| | c.email === sequence.email |
| | ); |
| | let updatedContacts; |
| | if (existingContact) { |
| | existingContact.emails.push({ |
| | emailNumber: sequence.emailNumber || existingContact.emails.length + 1, |
| | subject: sequence.subject, |
| | emailContent: sequence.emailContent |
| | }); |
| | updatedContacts = [...prev]; |
| | } else { |
| | updatedContacts = [...prev, { |
| | id: sequence.id, |
| | firstName: sequence.firstName, |
| | lastName: sequence.lastName, |
| | email: sequence.email, |
| | company: sequence.company, |
| | title: sequence.title, |
| | product: sequence.product, |
| | emails: [{ |
| | emailNumber: sequence.emailNumber || 1, |
| | subject: sequence.subject, |
| | emailContent: sequence.emailContent |
| | }] |
| | }]; |
| | } |
| | const progressValue = contactCount > 0 |
| | ? Math.min(100, Math.max(0, (updatedContacts.length / contactCount) * 100)) |
| | : 0; |
| | setProgress(progressValue); |
| | return updatedContacts; |
| | } |
| |
|
| | export default function SequenceViewer({ isGenerating, generationRunId, contactCount, selectedProducts, uploadedFile, prompts, onComplete }) { |
| | const [sequences, setSequences] = useState([]); |
| | const [contacts, setContacts] = useState([]); |
| | const [progress, setProgress] = useState(0); |
| | const [searchQuery, setSearchQuery] = useState(''); |
| | const [filterProduct, setFilterProduct] = useState('all'); |
| | const [isComplete, setIsComplete] = useState(false); |
| | const [displayedCount, setDisplayedCount] = useState(50); |
| | const [reconnectKey, setReconnectKey] = useState(0); |
| | const prevRunIdRef = useRef(null); |
| |
|
| | useEffect(() => { |
| | if (!isGenerating || !uploadedFile?.fileId) return; |
| |
|
| | const isNewRun = prevRunIdRef.current !== generationRunId; |
| | if (isNewRun) { |
| | prevRunIdRef.current = generationRunId; |
| | setSequences([]); |
| | setContacts([]); |
| | setProgress(0); |
| | setIsComplete(false); |
| | } |
| |
|
| | const reset = isNewRun ? 1 : 0; |
| | const url = `/api/generate-sequences?file_id=${encodeURIComponent(uploadedFile.fileId)}&reset=${reset}`; |
| | const eventSource = new EventSource(url, { withCredentials: false }); |
| |
|
| | eventSource.onmessage = (event) => { |
| | try { |
| | const data = JSON.parse(event.data); |
| | if (data.type === 'sequence') { |
| | const seq = data.sequence; |
| | setSequences(prev => { |
| | if (prev.some(s => s.id === seq.id && s.emailNumber === seq.emailNumber)) return prev; |
| | return [...prev, seq]; |
| | }); |
| | setContacts(prev => { |
| | const existing = prev.find(c => c.email === seq.email); |
| | if (existing?.emails.some(e => e.emailNumber === seq.emailNumber)) return prev; |
| | return applySequenceToContacts(prev, seq, contactCount, setProgress); |
| | }); |
| | } else if (data.type === 'progress') { |
| | setProgress(data.progress); |
| | } else if (data.type === 'complete') { |
| | setIsComplete(true); |
| | onComplete?.(); |
| | eventSource.close(); |
| | } else if (data.type === 'error') { |
| | console.error('Generation error:', data.error); |
| | alert('Error generating sequences: ' + data.error); |
| | eventSource.close(); |
| | } |
| | } catch (err) { |
| | console.error('Error parsing SSE data:', err); |
| | } |
| | }; |
| |
|
| | eventSource.onerror = () => { |
| | eventSource.close(); |
| | if (!isComplete) setReconnectKey(k => k + 1); |
| | }; |
| |
|
| | return () => eventSource.close(); |
| | }, [isGenerating, uploadedFile?.fileId, generationRunId, contactCount, reconnectKey, onComplete, isComplete]); |
| |
|
| | useEffect(() => { |
| | if (!isGenerating || !uploadedFile?.fileId || reconnectKey === 0) return; |
| | let cancelled = false; |
| | (async () => { |
| | try { |
| | const [statusRes, seqRes] = await Promise.all([ |
| | fetch(`/api/generation-status?file_id=${encodeURIComponent(uploadedFile.fileId)}`), |
| | fetch(`/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) { |
| | setIsComplete(true); |
| | onComplete?.(); |
| | } |
| | if (list?.length > 0) { |
| | const byContact = new Map(); |
| | list.forEach(seq => { |
| | const key = seq.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: [] |
| | }); |
| | } |
| | byContact.get(key).emails.push({ |
| | emailNumber: seq.emailNumber, |
| | subject: seq.subject, |
| | emailContent: seq.emailContent |
| | }); |
| | }); |
| | const arr = [...byContact.values()]; |
| | arr.sort((a, b) => (a.id || 0) - (b.id || 0)); |
| | setSequences(list); |
| | setContacts(arr); |
| | const p = status.total_contacts > 0 ? Math.min(100, (arr.length / status.total_contacts) * 100) : 0; |
| | setProgress(p); |
| | } |
| | } |
| | } catch (e) { |
| | if (!cancelled) console.error('Reconnect fetch error:', e); |
| | } |
| | })(); |
| | return () => { cancelled = true; }; |
| | }, [reconnectKey, isGenerating, uploadedFile?.fileId, contactCount, onComplete]); |
| |
|
| | 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 handleDownload = async () => { |
| | try { |
| | const response = await fetch(`/api/download-sequences?file_id=${uploadedFile.fileId}`); |
| | if (response.ok) { |
| | const blob = await response.blob(); |
| | const url = URL.createObjectURL(blob); |
| | const a = document.createElement('a'); |
| | a.href = url; |
| | a.download = 'email_sequences_fixed.csv'; |
| | a.click(); |
| | URL.revokeObjectURL(url); |
| | } else { |
| | alert('Failed to download CSV. Please try again.'); |
| | } |
| | } catch (error) { |
| | console.error('Download error:', error); |
| | alert('Error downloading CSV. Please try again.'); |
| | } |
| | }; |
| |
|
| | 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; |
| | }); |
| |
|
| | |
| | useEffect(() => { |
| | setDisplayedCount(50); |
| | }, [searchQuery, filterProduct]); |
| |
|
| | |
| | const displayedContacts = filteredContacts.slice(0, displayedCount); |
| | const hasMore = filteredContacts.length > displayedCount; |
| |
|
| | const loadMore = () => { |
| | setDisplayedCount(prev => Math.min(prev + 50, filteredContacts.length)); |
| | }; |
| |
|
| | return ( |
| | <div className="w-full"> |
| | {/* Progress Header */} |
| | <div className="rounded-2xl border border-slate-200 bg-white p-6 mb-6"> |
| | <div className="flex items-center justify-between mb-4"> |
| | <div className="flex items-center gap-3"> |
| | {isComplete ? ( |
| | <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 text-violet-600 animate-spin" /> |
| | </div> |
| | )} |
| | <div> |
| | <h3 className="font-semibold text-slate-800"> |
| | {isComplete ? 'Generation Complete!' : 'Generating Email Sequences...'} |
| | </h3> |
| | <p className="text-sm text-slate-500"> |
| | {contacts.length} of {contactCount} contacts, {sequences.length} total emails generated |
| | </p> |
| | </div> |
| | </div> |
| | {isComplete && ( |
| | <Button |
| | onClick={handleDownload} |
| | className="bg-green-600 hover:bg-green-700" |
| | > |
| | <Download className="h-4 w-4 mr-2" /> |
| | Download CSV for Outreaches |
| | </Button> |
| | )} |
| | </div> |
| | <Progress value={progress} className="h-2" /> |
| | </div> |
| | |
| | {/* Filters */} |
| | {sequences.length > 0 && ( |
| | <motion.div |
| | initial={{ opacity: 0, y: -10 }} |
| | animate={{ opacity: 1, y: 0 }} |
| | className="flex flex-col sm:flex-row gap-3 mb-6" |
| | > |
| | <div className="relative flex-1"> |
| | <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 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="h-4 w-4 mr-2 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> |
| | </motion.div> |
| | )} |
| | |
| | {/* Sequence List - Optimized for high volume with pagination */} |
| | <div className="space-y-3 max-h-[600px] overflow-y-auto pr-2 custom-scrollbar"> |
| | <AnimatePresence> |
| | {displayedContacts.map((contact, index) => ( |
| | <SequenceCard key={contact.id || `${contact.firstName}-${contact.lastName}-${contact.email}`} contact={contact} index={index} /> |
| | ))} |
| | </AnimatePresence> |
| | {hasMore && ( |
| | <div className="text-center py-4"> |
| | <Button |
| | variant="outline" |
| | onClick={loadMore} |
| | className="mx-auto" |
| | > |
| | Load More ({filteredContacts.length - displayedCount} remaining) |
| | </Button> |
| | </div> |
| | )} |
| | {filteredContacts.length > 0 && ( |
| | <div className="text-center py-2 text-sm text-slate-500"> |
| | Showing {displayedContacts.length} of {filteredContacts.length} contacts |
| | </div> |
| | )} |
| | </div> |
| | |
| | {/* Empty State */} |
| | {!isGenerating && contacts.length === 0 && ( |
| | <div className="text-center py-16"> |
| | <div className="mx-auto w-16 h-16 rounded-2xl bg-slate-100 flex items-center justify-center mb-4"> |
| | <Mail className="h-8 w-8 text-slate-300" /> |
| | </div> |
| | <h3 className="text-lg font-semibold text-slate-400 mb-2">No sequences yet</h3> |
| | <p className="text-sm text-slate-400">Click "Generate Sequences" to start</p> |
| | </div> |
| | )} |
| | </div> |
| | ); |
| | } |
| |
|