Spaces:
Sleeping
Sleeping
| 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; | |