Seth
update
952e292
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;
});
// Reset pagination when search/filter changes
useEffect(() => {
setDisplayedCount(50);
}, [searchQuery, filterProduct]);
// Pagination: only show first N contacts to avoid browser performance issues
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>
);
}