leadgenpro / src /App.jsx
Raí Santos
oi
6b71f02
import React, { useState, lazy, Suspense } from 'react';
import Papa from 'papaparse';
import FileUpload from './components/FileUpload';
import { Layers, AlertCircle, X } from 'lucide-react';
// Lazy load Dashboard since it's not needed on initial load
const Dashboard = lazy(() => import('./components/Dashboard'));
function App() {
const [view, setView] = useState('upload'); // 'upload' | 'dashboard'
const [rawData, setRawData] = useState([]);
const [processedData, setProcessedData] = useState({});
const [stats, setStats] = useState({ total: 0, noWebsite: 0, niches: 0 });
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [showExportModal, setShowExportModal] = useState(false);
const [selectedNiches, setSelectedNiches] = useState([]);
const handleFileUpload = (files) => {
// Reset states
setError(null);
setLoading(true);
// Validate files
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB
for (const file of files) {
if (!file.name.endsWith('.csv')) {
setError('Por favor, selecione apenas arquivos CSV.');
setLoading(false);
return;
}
if (file.size > MAX_FILE_SIZE) {
setError(`O arquivo ${file.name} é muito grande. Tamanho máximo: 50MB.`);
setLoading(false);
return;
}
}
const parsePromises = files.map(file => {
return new Promise((resolve, reject) => {
Papa.parse(file, {
header: true,
skipEmptyLines: true,
// REMOVED worker: true - causes issues in production/Hugging Face
complete: (results) => {
if (results.errors && results.errors.length > 0) {
console.warn('CSV parsing warnings:', results.errors);
}
resolve(results.data);
},
error: (error) => {
console.error("Error parsing CSV:", error);
reject(error);
}
});
});
});
Promise.all(parsePromises)
.then(resultsArray => {
// Flatten array of arrays
const allData = resultsArray.flat();
if (allData.length === 0) {
setError('Nenhum dado encontrado nos arquivos CSV.');
setLoading(false);
return;
}
processData(allData);
setLoading(false);
})
.catch(error => {
console.error("Error processing files:", error);
setError(`Erro ao processar arquivos: ${error.message || 'Erro desconhecido'}`);
setLoading(false);
});
};
const processData = (data) => {
setRawData(data);
// Filter: Keep only those WITHOUT a website AND WITH a phone number
const filteredData = data.filter(item => {
const hasNoWebsite = !item.website || item.website.trim() === '';
const hasPhone = item.phone && item.phone.trim() !== '';
return hasNoWebsite && hasPhone;
}).map((item, index) => ({
...item,
// Generate a unique ID based on content or fallback to index if needed
id: `${item.title || 'unknown'}-${item.phone || 'no-phone'}-${index}`
}));
// Group by Niche (categoryName)
const grouped = {};
filteredData.forEach(item => {
// Normalize category name
const niche = item.categoryName ? item.categoryName.trim() : 'Outros';
if (!grouped[niche]) {
grouped[niche] = [];
}
grouped[niche].push(item);
});
setProcessedData(grouped);
setStats({
total: data.length,
noWebsite: filteredData.length,
niches: Object.keys(grouped).length
});
setView('dashboard');
};
const openExportModal = () => {
// Initialize with all niches selected
setSelectedNiches(Object.keys(processedData));
setShowExportModal(true);
};
const toggleNicheSelection = (niche) => {
setSelectedNiches(prev => {
if (prev.includes(niche)) {
return prev.filter(n => n !== niche);
} else {
return [...prev, niche];
}
});
};
const toggleSelectAll = () => {
if (selectedNiches.length === Object.keys(processedData).length) {
setSelectedNiches([]);
} else {
setSelectedNiches(Object.keys(processedData));
}
};
const handleExport = () => {
if (selectedNiches.length === 0) {
setError('Por favor, selecione pelo menos um nicho para exportar.');
return;
}
// Flatten the grouped data back to a list for export
// Only include selected niches
const exportList = [];
selectedNiches.forEach(niche => {
const items = processedData[niche] || [];
items.forEach(item => {
exportList.push({
Name: item.title,
Phone: item.phone,
Address: `${item.street || ''} ${item.city || ''} ${item.state || ''}`.trim(),
Niche: niche
});
});
});
const csv = Papa.unparse(exportList);
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
// Generate filename with selected niches info
const filename = selectedNiches.length === Object.keys(processedData).length
? 'leads_todos_nichos.csv'
: selectedNiches.length === 1
? `leads_${selectedNiches[0].toLowerCase().replace(/[^a-z0-9]/g, '_')}.csv`
: `leads_${selectedNiches.length}_nichos.csv`;
link.setAttribute('download', filename);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
setShowExportModal(false);
};
return (
<div style={{ minHeight: '100vh', paddingBottom: '4rem' }}>
{/* Header */}
<header style={{
padding: '1.5rem 0',
borderBottom: '1px solid var(--border-color)',
background: 'rgba(10, 10, 12, 0.8)',
backdropFilter: 'blur(10px)',
position: 'sticky',
top: 0,
zIndex: 100,
marginBottom: '3rem'
}}>
<div className="container" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0 2rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<div style={{
background: 'linear-gradient(135deg, var(--accent-primary), #6246ea)',
padding: '0.5rem',
borderRadius: '8px',
display: 'flex'
}}>
<Layers color="white" size={24} />
</div>
<h1 style={{ fontSize: '1.25rem', fontWeight: 700, letterSpacing: '-0.5px' }}>
Lead<span style={{ color: 'var(--accent-primary)' }}>Gen</span> Pro
</h1>
</div>
{view === 'dashboard' && (
<button
className="btn-secondary"
onClick={() => setView('upload')}
style={{ fontSize: '0.875rem' }}
>
Novo Upload
</button>
)}
</div>
</header>
<main className="container">
{/* Error Banner */}
{error && (
<div style={{
background: 'rgba(239, 68, 68, 0.1)',
border: '1px solid rgba(239, 68, 68, 0.3)',
borderRadius: 'var(--radius-md)',
padding: '1rem',
marginBottom: '2rem',
display: 'flex',
alignItems: 'center',
gap: '0.75rem',
maxWidth: '800px',
margin: '0 auto 2rem auto'
}}>
<AlertCircle color="#ef4444" size={20} />
<span style={{ flex: 1, color: '#fca5a5' }}>{error}</span>
<button
onClick={() => setError(null)}
style={{
background: 'transparent',
padding: '0.25rem',
color: '#fca5a5',
cursor: 'pointer'
}}
>
<X size={18} />
</button>
</div>
)}
{view === 'upload' ? (
<div style={{ maxWidth: '800px', margin: '0 auto' }}>
<div style={{ textAlign: 'center', marginBottom: '3rem' }}>
<h2 style={{ fontSize: '2.5rem', fontWeight: 800, marginBottom: '1rem', lineHeight: 1.2 }}>
Transforme dados brutos em <br />
<span style={{
background: 'linear-gradient(to right, var(--accent-primary), var(--accent-secondary))',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent'
}}>Oportunidades de Venda</span>
</h2>
<p style={{ color: 'var(--text-secondary)', fontSize: '1.1rem', maxWidth: '600px', margin: '0 auto' }}>
Filtre automaticamente negócios sem website, organize por nicho e exporte para prospecção em massa.
</p>
</div>
<FileUpload onFileUpload={handleFileUpload} loading={loading} />
</div>
) : (
<Suspense fallback={
<div style={{ textAlign: 'center', padding: '4rem' }}>
<div style={{
display: 'inline-block',
width: '40px',
height: '40px',
border: '4px solid var(--border-color)',
borderTop: '4px solid var(--accent-primary)',
borderRadius: '50%',
animation: 'spin 1s linear infinite'
}}></div>
<p style={{ marginTop: '1rem', color: 'var(--text-secondary)' }}>Carregando dashboard...</p>
</div>
}>
<Dashboard
stats={stats}
groupedData={processedData}
onExport={openExportModal}
/>
</Suspense>
)}
</main>
{/* Export Modal */}
{showExportModal && (
<div style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'rgba(0, 0, 0, 0.7)',
backdropFilter: 'blur(8px)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000,
animation: 'fadeIn 0.2s ease-out'
}}
onClick={() => setShowExportModal(false)}
>
<div
className="glass-panel"
style={{
maxWidth: '600px',
width: '90%',
maxHeight: '80vh',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
animation: 'slideUp 0.3s ease-out'
}}
onClick={(e) => e.stopPropagation()}
>
{/* Modal Header */}
<div style={{
padding: '1.5rem',
borderBottom: '1px solid var(--border-color)',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
}}>
<div>
<h2 style={{ fontSize: '1.5rem', fontWeight: 700, marginBottom: '0.25rem' }}>
Exportar Leads
</h2>
<p style={{ color: 'var(--text-secondary)', fontSize: '0.875rem' }}>
Selecione os nichos que deseja exportar
</p>
</div>
<button
onClick={() => setShowExportModal(false)}
style={{
background: 'transparent',
color: 'var(--text-secondary)',
padding: '0.5rem',
cursor: 'pointer',
borderRadius: '8px',
transition: 'all 0.2s'
}}
onMouseEnter={(e) => e.target.style.background = 'var(--surface-hover)'}
onMouseLeave={(e) => e.target.style.background = 'transparent'}
>
<X size={24} />
</button>
</div>
{/* Modal Body */}
<div style={{
padding: '1.5rem',
overflowY: 'auto',
flex: 1
}}>
{/* Select All Toggle */}
<div style={{
background: 'var(--surface-color)',
padding: '1rem',
borderRadius: 'var(--radius-md)',
marginBottom: '1rem',
border: '2px solid var(--accent-primary)',
cursor: 'pointer',
transition: 'all 0.2s'
}}
onClick={toggleSelectAll}
>
<label style={{
display: 'flex',
alignItems: 'center',
gap: '0.75rem',
cursor: 'pointer',
userSelect: 'none'
}}>
<div style={{
width: '24px',
height: '24px',
borderRadius: '6px',
border: `2px solid ${selectedNiches.length === Object.keys(processedData).length ? 'var(--accent-primary)' : 'var(--border-color)'}`,
background: selectedNiches.length === Object.keys(processedData).length ? 'var(--accent-primary)' : 'transparent',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'all 0.2s'
}}>
{selectedNiches.length === Object.keys(processedData).length && (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path d="M11.6666 3.5L5.24992 9.91667L2.33325 7" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)}
</div>
<div style={{ flex: 1 }}>
<span style={{ fontWeight: 600, fontSize: '1rem' }}>
Selecionar Todos
</span>
<p style={{ color: 'var(--text-secondary)', fontSize: '0.875rem', margin: 0 }}>
{Object.values(processedData).reduce((sum, items) => sum + items.length, 0)} leads no total
</p>
</div>
</label>
</div>
{/* Niches List */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
{Object.entries(processedData).sort(([a], [b]) => a.localeCompare(b)).map(([niche, items]) => {
const isSelected = selectedNiches.includes(niche);
return (
<div
key={niche}
style={{
background: isSelected ? 'rgba(127, 90, 240, 0.1)' : 'var(--surface-color)',
padding: '1rem',
borderRadius: 'var(--radius-md)',
border: `1px solid ${isSelected ? 'var(--accent-primary)' : 'var(--border-color)'}`,
cursor: 'pointer',
transition: 'all 0.2s'
}}
onClick={() => toggleNicheSelection(niche)}
onMouseEnter={(e) => {
if (!isSelected) e.currentTarget.style.background = 'var(--surface-hover)';
}}
onMouseLeave={(e) => {
if (!isSelected) e.currentTarget.style.background = 'var(--surface-color)';
}}
>
<label style={{
display: 'flex',
alignItems: 'center',
gap: '0.75rem',
cursor: 'pointer',
userSelect: 'none'
}}>
<div style={{
width: '20px',
height: '20px',
borderRadius: '4px',
border: `2px solid ${isSelected ? 'var(--accent-primary)' : 'var(--border-color)'}`,
background: isSelected ? 'var(--accent-primary)' : 'transparent',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'all 0.2s',
flexShrink: 0
}}>
{isSelected && (
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
<path d="M10 3L4.5 8.5L2 6" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)}
</div>
<div style={{ flex: 1 }}>
<span style={{ fontWeight: 500 }}>{niche}</span>
<span style={{ color: 'var(--text-secondary)', fontSize: '0.875rem', marginLeft: '0.5rem' }}>
({items.length} {items.length === 1 ? 'lead' : 'leads'})
</span>
</div>
</label>
</div>
);
})}
</div>
</div>
{/* Modal Footer */}
<div style={{
padding: '1.5rem',
borderTop: '1px solid var(--border-color)',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: '1rem'
}}>
<div style={{ color: 'var(--text-secondary)', fontSize: '0.875rem' }}>
{selectedNiches.length} {selectedNiches.length === 1 ? 'nicho selecionado' : 'nichos selecionados'}
{selectedNiches.length > 0 && (
<span style={{ marginLeft: '0.5rem' }}>
({selectedNiches.reduce((sum, niche) => sum + (processedData[niche]?.length || 0), 0)} leads)
</span>
)}
</div>
<div style={{ display: 'flex', gap: '0.75rem' }}>
<button
className="btn-secondary"
onClick={() => setShowExportModal(false)}
style={{ fontSize: '0.875rem' }}
>
Cancelar
</button>
<button
className="btn-primary"
onClick={handleExport}
disabled={selectedNiches.length === 0}
style={{
fontSize: '0.875rem',
opacity: selectedNiches.length === 0 ? 0.5 : 1,
cursor: selectedNiches.length === 0 ? 'not-allowed' : 'pointer'
}}
>
Exportar CSV
</button>
</div>
</div>
</div>
</div>
)}
</div>
);
}
export default App;