Spaces:
Running
Running
| import { useState, useEffect } from 'react'; | |
| import { | |
| BarChart3, | |
| RefreshCw, | |
| Download, | |
| Settings, | |
| Sun, | |
| Moon, | |
| TrendingUp, | |
| DollarSign, | |
| Users, | |
| ShoppingCart, | |
| AlertCircle, | |
| CheckCircle2, | |
| XCircle, | |
| ArrowUpDown, | |
| ArrowUp, | |
| ArrowDown, | |
| Clock, | |
| Trophy | |
| } from 'lucide-react'; | |
| // --- Types & Interfaces --- | |
| interface CustomAttribute { | |
| key: string; | |
| value: string; | |
| } | |
| interface Order { | |
| id: string; | |
| name: string; | |
| createdAt: string; | |
| displayFinancialStatus: string; | |
| totalPriceSet: { | |
| shopMoney: { | |
| amount: string; | |
| }; | |
| }; | |
| customer: { | |
| email: string | null; | |
| } | null; | |
| customAttributes: CustomAttribute[]; | |
| } | |
| interface UTMData { | |
| utmContent: string; | |
| totalPedidos: number; | |
| pedidosPagos: number; | |
| pedidosPendentes: number; | |
| clientesUnicos: number; | |
| totalVendas: number; | |
| vendasPagas: number; | |
| taxaPagamento: number; | |
| } | |
| interface OrderSnapshot { | |
| timestamp: string; | |
| orderIds: string[]; | |
| } | |
| interface NewOrdersByCampaign { | |
| utmContent: string; | |
| newOrders: number; | |
| totalValue: number; | |
| paidOrders: number; | |
| paidValue: number; | |
| } | |
| interface NewOrdersReport { | |
| newOrderCount: number; | |
| newOrdersTotal: number; | |
| newOrdersPaid: number; | |
| newOrdersPaidCount: number; | |
| newOrderNumbers: string[]; | |
| timeDifference: string; | |
| campaignBreakdown: NewOrdersByCampaign[]; | |
| } | |
| type SortColumn = 'utmContent' | 'lastParameter' | 'totalPedidos' | 'pedidosPagos' | 'pedidosPendentes' | 'clientesUnicos' | 'totalVendas' | 'vendasPagas' | 'taxaPagamento'; | |
| type SortDirection = 'asc' | 'desc'; | |
| // --- Helper Functions --- | |
| const formatCurrency = (value: number): string => { | |
| return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value); | |
| }; | |
| const formatPercentage = (value: number): string => { | |
| return `${value.toFixed(1)}%`; | |
| }; | |
| const extractLastParameter = (utmContent: string): string => { | |
| if (utmContent === 'Sem UTM Content') return 'N/A'; | |
| const matches = utmContent.match(/\[([^\]]+)\]/g); | |
| if (!matches || matches.length === 0) return utmContent.substring(0, 20) + (utmContent.length > 20 ? '...' : ''); | |
| const lastMatch = matches[matches.length - 1]; | |
| return lastMatch.replace(/[\[\]]/g, '').trim(); | |
| }; | |
| // --- Main Component --- | |
| export default function AnalyticsDashboard() { | |
| // -- State -- | |
| const [theme, setTheme] = useState<'dark' | 'light'>('dark'); | |
| const [configOpen, setConfigOpen] = useState(false); | |
| // Shopify Config | |
| const [storeName, setStoreName] = useState(''); | |
| const [accessToken, setAccessToken] = useState(''); | |
| const [isConfigured, setIsConfigured] = useState(false); | |
| // App State | |
| const [dateRangeOption, setDateRangeOption] = useState<string>('hoje'); | |
| const [loading, setLoading] = useState<boolean>(false); | |
| const [error, setError] = useState<string | null>(null); | |
| const [utmData, setUtmData] = useState<UTMData[]>([]); | |
| const [totalOrders, setTotalOrders] = useState<number>(0); | |
| const [lastUpdate, setLastUpdate] = useState<string>(''); | |
| const [sortColumn, setSortColumn] = useState<SortColumn>('totalVendas'); | |
| const [sortDirection, setSortDirection] = useState<SortDirection>('desc'); | |
| const [snapshot, setSnapshot] = useState<OrderSnapshot | null>(null); | |
| const [newOrdersReport, setNewOrdersReport] = useState<NewOrdersReport | null>(null); | |
| const [isFirstLoad, setIsFirstLoad] = useState<boolean>(true); | |
| const [hasLoadedData, setHasLoadedData] = useState<boolean>(false); | |
| const [dayChanged, setDayChanged] = useState<boolean>(false); | |
| // -- Effects -- | |
| useEffect(() => { | |
| const savedStore = localStorage.getItem('shopify_store'); | |
| const savedToken = localStorage.getItem('shopify_token'); | |
| const savedTheme = localStorage.getItem('theme') as 'dark' | 'light' | null; | |
| if (savedStore) setStoreName(savedStore); | |
| if (savedToken) setAccessToken(savedToken); | |
| if (savedStore && savedToken) setIsConfigured(true); | |
| if (savedTheme) { | |
| setTheme(savedTheme); | |
| document.documentElement.classList.toggle('dark', savedTheme === 'dark'); | |
| } | |
| }, []); | |
| const toggleTheme = () => { | |
| const newTheme = theme === 'dark' ? 'light' : 'dark'; | |
| setTheme(newTheme); | |
| localStorage.setItem('theme', newTheme); | |
| document.documentElement.classList.toggle('dark', newTheme === 'dark'); | |
| }; | |
| // -- Logic -- | |
| const getDateRange = (): { startDate: string; endDate: string } => { | |
| const now = new Date(); | |
| const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); | |
| let startDate: Date; | |
| let endDate: Date = new Date(today.getTime() + 24 * 60 * 60 * 1000 - 1); | |
| switch (dateRangeOption) { | |
| case 'hoje': startDate = today; break; | |
| case 'ontem': | |
| startDate = new Date(today.getTime() - 24 * 60 * 60 * 1000); | |
| endDate = new Date(today.getTime() - 1); | |
| break; | |
| case 'ultimos7': startDate = new Date(today.getTime() - 7 * 24 * 60 * 60 * 1000); break; | |
| case 'ultimos30': startDate = new Date(today.getTime() - 30 * 24 * 60 * 60 * 1000); break; | |
| default: startDate = today; | |
| } | |
| return { | |
| startDate: startDate.toISOString(), | |
| endDate: endDate.toISOString(), | |
| }; | |
| }; | |
| const normalizeStoreName = (name: string): string => { | |
| return name.replace(/^https?:\/\//, '').replace(/\.myshopify\.com.*$/, '').split('/')[0]; | |
| }; | |
| const fetchOrders = async () => { | |
| if (!storeName || !accessToken) { | |
| setError('Por favor, configure as credenciais da Shopify primeiro.'); | |
| setConfigOpen(true); | |
| return; | |
| } | |
| setLoading(true); | |
| setError(null); | |
| const normalizedStore = normalizeStoreName(storeName); | |
| const { startDate, endDate } = getDateRange(); | |
| const allOrders: Order[] = []; | |
| let hasNextPage = true; | |
| let cursor: string | null = null; | |
| const ordersQuery = `query GetOrders($first: Int!, $after: String, $query: String!) { | |
| orders(first: $first, after: $after, query: $query, sortKey: CREATED_AT) { | |
| edges { | |
| node { | |
| id | |
| name | |
| createdAt | |
| displayFinancialStatus | |
| totalPriceSet { shopMoney { amount } } | |
| customer { email } | |
| customAttributes { key value } | |
| } | |
| } | |
| pageInfo { hasNextPage endCursor } | |
| } | |
| }`; | |
| try { | |
| while (hasNextPage) { | |
| const response = await fetch(`https://${normalizedStore}.myshopify.com/admin/api/2023-10/graphql.json`, { | |
| method: 'POST', | |
| headers: { | |
| 'X-Shopify-Access-Token': accessToken, | |
| 'Content-Type': 'application/json', | |
| }, | |
| body: JSON.stringify({ | |
| query: ordersQuery, | |
| variables: { | |
| first: 250, | |
| after: cursor, | |
| query: `created_at:>='${startDate}' created_at:<='${endDate}'`, | |
| }, | |
| }), | |
| }); | |
| const data = await response.json(); | |
| if (data.errors) { | |
| throw new Error(data.errors.map((e: any) => e.message).join(', ')); | |
| } | |
| if (data?.data?.orders?.edges) { | |
| const orders = data.data.orders.edges.map((edge: any) => edge.node); | |
| allOrders.push(...orders); | |
| hasNextPage = data.data.orders.pageInfo.hasNextPage; | |
| cursor = data.data.orders.pageInfo.endCursor; | |
| } else { | |
| hasNextPage = false; | |
| } | |
| } | |
| // Process Data | |
| const report = analyzeNewOrders(allOrders, snapshot); | |
| setNewOrdersReport(report); | |
| setSnapshot(createSnapshot(allOrders)); | |
| setIsFirstLoad(false); | |
| setHasLoadedData(true); | |
| setDayChanged(false); | |
| processOrders(allOrders); | |
| setTotalOrders(allOrders.length); | |
| const now = new Date(); | |
| setLastUpdate(now.toLocaleString('pt-BR')); | |
| } catch (err: any) { | |
| setError(err.message || 'Erro ao carregar pedidos.'); | |
| } finally { | |
| setLoading(false); | |
| } | |
| }; | |
| const createSnapshot = (orders: Order[]): OrderSnapshot => ({ | |
| timestamp: new Date().toISOString(), | |
| orderIds: orders.map(o => o.id), | |
| }); | |
| const analyzeNewOrders = (currentOrders: Order[], previousSnapshot: OrderSnapshot | null) => { | |
| if (!previousSnapshot) return null; | |
| const previousOrderIds = new Set(previousSnapshot.orderIds); | |
| const newOrders = currentOrders.filter(order => !previousOrderIds.has(order.id)); | |
| if (newOrders.length === 0) { | |
| return { | |
| newOrderCount: 0, | |
| newOrdersTotal: 0, | |
| newOrdersPaid: 0, | |
| newOrdersPaidCount: 0, | |
| newOrderNumbers: [], | |
| timeDifference: calculateTimeDifference(previousSnapshot.timestamp), | |
| campaignBreakdown: [], | |
| }; | |
| } | |
| const newOrdersTotal = newOrders.reduce((sum, o) => sum + parseFloat(o.totalPriceSet.shopMoney.amount || '0'), 0); | |
| const paidOrders = newOrders.filter(o => o.displayFinancialStatus === 'PAID'); | |
| const newOrdersPaid = paidOrders.reduce((sum, o) => sum + parseFloat(o.totalPriceSet.shopMoney.amount || '0'), 0); | |
| // Group by campaign | |
| const campaignGroups = new Map<string, Order[]>(); | |
| newOrders.forEach(order => { | |
| let utmContent = 'Sem UTM Content'; | |
| const attr = order.customAttributes?.find(a => a.key === 'utm_content'); | |
| if (attr?.value) utmContent = attr.value.trim(); | |
| if (!campaignGroups.has(utmContent)) campaignGroups.set(utmContent, []); | |
| campaignGroups.get(utmContent)!.push(order); | |
| }); | |
| const campaignBreakdown: NewOrdersByCampaign[] = []; | |
| campaignGroups.forEach((orders, utmContent) => { | |
| const totalValue = orders.reduce((sum, o) => sum + parseFloat(o.totalPriceSet.shopMoney.amount || '0'), 0); | |
| const paid = orders.filter(o => o.displayFinancialStatus === 'PAID'); | |
| const paidValue = paid.reduce((sum, o) => sum + parseFloat(o.totalPriceSet.shopMoney.amount || '0'), 0); | |
| campaignBreakdown.push({ utmContent, newOrders: orders.length, totalValue, paidOrders: paid.length, paidValue }); | |
| }); | |
| return { | |
| newOrderCount: newOrders.length, | |
| newOrdersTotal, | |
| newOrdersPaid, | |
| newOrdersPaidCount: paidOrders.length, | |
| newOrderNumbers: newOrders.map(o => o.name), | |
| timeDifference: calculateTimeDifference(previousSnapshot.timestamp), | |
| campaignBreakdown, | |
| }; | |
| }; | |
| const calculateTimeDifference = (timestamp: string): string => { | |
| const diffMs = new Date().getTime() - new Date(timestamp).getTime(); | |
| const diffMins = Math.floor(diffMs / 60000); | |
| if (diffMins < 1) return 'menos de 1 minuto'; | |
| const hours = Math.floor(diffMins / 60); | |
| const mins = diffMins % 60; | |
| return hours === 0 ? `${mins}m` : `${hours}h e ${mins}m`; | |
| }; | |
| const processOrders = (orders: Order[]) => { | |
| const utmGroups = new Map<string, Order[]>(); | |
| orders.forEach(order => { | |
| let utmContent = 'Sem UTM Content'; | |
| const attr = order.customAttributes?.find(a => a.key === 'utm_content'); | |
| if (attr?.value) utmContent = attr.value.trim(); | |
| if (!utmGroups.has(utmContent)) utmGroups.set(utmContent, []); | |
| utmGroups.get(utmContent)!.push(order); | |
| }); | |
| const processed: UTMData[] = []; | |
| utmGroups.forEach((group, utmContent) => { | |
| const totalPedidos = group.length; | |
| const pedidosPagos = group.filter(o => o.displayFinancialStatus === 'PAID').length; | |
| const uniqueEmails = new Set(group.map(o => o.customer?.email || `no-email-${o.id}`)); | |
| const totalVendas = group.reduce((sum, o) => sum + parseFloat(o.totalPriceSet.shopMoney.amount || '0'), 0); | |
| const vendasPagas = group.filter(o => o.displayFinancialStatus === 'PAID').reduce((sum, o) => sum + parseFloat(o.totalPriceSet.shopMoney.amount || '0'), 0); | |
| processed.push({ | |
| utmContent, | |
| totalPedidos, | |
| pedidosPagos, | |
| pedidosPendentes: totalPedidos - pedidosPagos, | |
| clientesUnicos: uniqueEmails.size, | |
| totalVendas, | |
| vendasPagas, | |
| taxaPagamento: totalPedidos > 0 ? (pedidosPagos / totalPedidos) * 100 : 0, | |
| }); | |
| }); | |
| setUtmData(processed); | |
| }; | |
| const saveConfig = () => { | |
| localStorage.setItem('shopify_store', storeName); | |
| localStorage.setItem('shopify_token', accessToken); | |
| setIsConfigured(!!storeName && !!accessToken); | |
| setConfigOpen(false); | |
| }; | |
| const resetSnapshot = () => { | |
| setSnapshot(null); | |
| setNewOrdersReport(null); | |
| setIsFirstLoad(true); | |
| setHasLoadedData(false); | |
| setUtmData([]); | |
| setTotalOrders(0); | |
| }; | |
| // -- Render Helpers -- | |
| const getSortedData = () => { | |
| return [...utmData].sort((a, b) => { | |
| let valA: any, valB: any; | |
| if (sortColumn === 'lastParameter') { | |
| valA = extractLastParameter(a.utmContent).toLowerCase(); | |
| valB = extractLastParameter(b.utmContent).toLowerCase(); | |
| } else if (sortColumn === 'utmContent') { | |
| valA = a.utmContent.toLowerCase(); | |
| valB = b.utmContent.toLowerCase(); | |
| } else { | |
| valA = a[sortColumn]; | |
| valB = b[sortColumn]; | |
| } | |
| if (valA < valB) return sortDirection === 'asc' ? -1 : 1; | |
| if (valA > valB) return sortDirection === 'asc' ? 1 : -1; | |
| return 0; | |
| }); | |
| }; | |
| const calculateTotals = (): UTMData => { | |
| const totals: UTMData = { | |
| utmContent: 'TOTAL GERAL', | |
| totalPedidos: 0, pedidosPagos: 0, pedidosPendentes: 0, | |
| clientesUnicos: totalOrders, totalVendas: 0, vendasPagas: 0, taxaPagamento: 0 | |
| }; | |
| utmData.forEach(row => { | |
| totals.totalPedidos += row.totalPedidos; | |
| totals.pedidosPagos += row.pedidosPagos; | |
| totals.pedidosPendentes += row.pedidosPendentes; | |
| totals.totalVendas += row.totalVendas; | |
| totals.vendasPagas += row.vendasPagas; | |
| }); | |
| totals.taxaPagamento = totals.totalPedidos > 0 ? (totals.pedidosPagos / totals.totalPedidos) * 100 : 0; | |
| return totals; | |
| }; | |
| const generateCSV = () => { | |
| const headers = ['UTM Content', 'Total Pedidos', 'Pedidos Pagos', 'Pendentes', 'Clientes Únicos', 'Total Vendas', 'Vendas Pagas', 'Taxa (%)']; | |
| const rows = getSortedData().map(r => [ | |
| r.utmContent, r.totalPedidos, r.pedidosPagos, r.pedidosPendentes, | |
| r.clientesUnicos, r.totalVendas.toFixed(2), r.vendasPagas.toFixed(2), r.taxaPagamento.toFixed(1) | |
| ]); | |
| const totals = calculateTotals(); | |
| rows.push([totals.utmContent, ...Object.values(totals).slice(1).map(v => typeof v === 'number' ? v.toFixed(v % 1 === 0 ? 0 : 2) : v)]); | |
| const csvContent = [headers, ...rows].map(r => r.join(',')).join('\n'); | |
| const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); | |
| const link = document.createElement('a'); | |
| link.href = URL.createObjectURL(blob); | |
| link.download = `relatorio-utm-${new Date().toISOString().split('T')[0]}.csv`; | |
| link.click(); | |
| }; | |
| const sortedData = getSortedData(); | |
| const totals = calculateTotals(); | |
| const top3Performers = [...utmData] | |
| .filter(r => r.utmContent !== 'Sem UTM Content') | |
| .sort((a, b) => b.taxaPagamento - a.taxaPagamento) | |
| .slice(0, 3); | |
| return ( | |
| <div className={`min-h-screen transition-colors duration-200 ${theme === 'dark' ? 'bg-slate-950 text-slate-100' : 'bg-gray-50 text-slate-900'}`}> | |
| {/* Header */} | |
| <header className={`border-b ${theme === 'dark' ? 'border-slate-800 bg-slate-900/50 backdrop-blur' : 'border-gray-200 bg-white/80 backdrop-blur'} sticky top-0 z-50`}> | |
| <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-16 flex items-center justify-between"> | |
| <div className="flex items-center gap-3"> | |
| <div className="p-2 bg-indigo-600 rounded-lg"> | |
| <BarChart3 className="w-5 h-5 text-white" /> | |
| </div> | |
| <h1 className="text-xl font-bold tracking-tight">UTM Analytics Pro</h1> | |
| </div> | |
| <div className="flex items-center gap-4"> | |
| <a href="https://huggingface.co/spaces/akhaliq/anycoder" target="_blank" rel="noopener noreferrer" className="text-xs font-medium text-indigo-500 hover:text-indigo-400 transition-colors"> | |
| Built with anycoder | |
| </a> | |
| <button onClick={() => setConfigOpen(!configOpen)} className={`p-2 rounded-lg transition-colors ${theme === 'dark' ? 'hover:bg-slate-800 text-slate-400' : 'hover:bg-gray-100 text-gray-500'}`}> | |
| <Settings className="w-5 h-5" /> | |
| </button> | |
| <button onClick={toggleTheme} className={`p-2 rounded-lg transition-colors ${theme === 'dark' ? 'hover:bg-slate-800 text-yellow-400' : 'hover:bg-gray-100 text-slate-600'}`}> | |
| {theme === 'dark' ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />} | |
| </button> | |
| </div> | |
| </div> | |
| </header> | |
| <main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-8"> | |
| {/* Config Panel */} | |
| {configOpen && ( | |
| <div className={`rounded-xl border ${theme === 'dark' ? 'bg-slate-900 border-slate-800' : 'bg-white border-gray-200'} p-6 shadow-sm animate-in fade-in slide-in-from-top-4 duration-300`}> | |
| <h2 className="text-lg font-semibold mb-4 flex items-center gap-2"> | |
| <Settings className="w-5 h-5 text-indigo-500" /> | |
| Configuração da Shopify | |
| </h2> | |
| <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> | |
| <div> | |
| <label className="block text-sm font-medium mb-1.5">Nome da Loja (subdomínio)</label> | |
| <input | |
| type="text" | |
| placeholder="ex: minha-loja" | |
| value={storeName} | |
| onChange={(e) => setStoreName(e.target.value)} | |
| className={`w-full px-4 py-2 rounded-lg border focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none transition-all ${theme === 'dark' ? 'bg-slate-950 border-slate-700 text-white placeholder-slate-500' : 'bg-gray-50 border-gray-300 text-gray-900'}`} | |
| /> | |
| </div> | |
| <div> | |
| <label className="block text-sm font-medium mb-1.5">Token de Acesso (Admin API)</label> | |
| <input | |
| type="password" | |
| placeholder="shpat_xxxxx..." | |
| value={accessToken} | |
| onChange={(e) => setAccessToken(e.target.value)} | |
| className={`w-full px-4 py-2 rounded-lg border focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none transition-all ${theme === 'dark' ? 'bg-slate-950 border-slate-700 text-white placeholder-slate-500' : 'bg-gray-50 border-gray-300 text-gray-900'}`} | |
| /> | |
| </div> | |
| </div> | |
| <div className="mt-4 flex justify-end"> | |
| <button onClick={saveConfig} className="px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white font-medium rounded-lg transition-colors"> | |
| Salvar Configurações | |
| </button> | |
| </div> | |
| </div> | |
| )} | |
| {!isConfigured && !configOpen && ( | |
| <div className={`rounded-xl border border-dashed p-8 text-center ${theme === 'dark' ? 'border-slate-700 bg-slate-900/30' : 'border-gray-300 bg-gray-50'}`}> | |
| <AlertCircle className="w-12 h-12 text-indigo-500 mx-auto mb-3" /> | |
| <h3 className="text-lg font-medium mb-2">Configure sua integração</h3> | |
| <p className={`text-sm mb-4 ${theme === 'dark' ? 'text-slate-400' : 'text-gray-500'}`}>Insira suas credenciais da Shopify para começar a analisar os pedidos.</p> | |
| <button onClick={() => setConfigOpen(true)} className="px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white font-medium rounded-lg transition-colors"> | |
| Configurar Agora | |
| </button> | |
| </div> | |
| )} | |
| {isConfigured && ( | |
| <> | |
| {/* Controls */} | |
| <div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center justify-between"> | |
| <div className="flex flex-wrap gap-2"> | |
| {['hoje', 'ontem', 'ultimos7', 'ultimos30'].map((opt) => ( | |
| <button | |
| key={opt} | |
| onClick={() => setDateRangeOption(opt)} | |
| className={`px-4 py-2 rounded-lg text-sm font-medium transition-all capitalize ${ | |
| dateRangeOption === opt | |
| ? 'bg-indigo-600 text-white shadow-lg shadow-indigo-500/20' | |
| : `${theme === 'dark' ? 'bg-slate-900 text-slate-400 hover:bg-slate-800 hover:text-slate-200 border border-slate-800' : 'bg-white text-gray-600 hover:bg-gray-100 border border-gray-200'}` | |
| }`} | |
| > | |
| {opt === 'hoje' ? 'Hoje' : opt === 'ontem' ? 'Ontem' : opt === 'ultimos7' ? '7 Dias' : '30 Dias'} | |
| </button> | |
| ))} | |
| </div> | |
| <div className="flex gap-2"> | |
| <button | |
| onClick={fetchOrders} | |
| disabled={loading} | |
| className="flex items-center gap-2 px-4 py-2 bg-indigo-600 hover:bg-indigo-700 disabled:opacity-70 disabled:cursor-not-allowed text-white font-medium rounded-lg transition-colors shadow-lg shadow-indigo-500/20" | |
| > | |
| <RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} /> | |
| Atualizar | |
| </button> | |
| <button | |
| onClick={generateCSV} | |
| disabled={utmData.length === 0} | |
| className={`flex items-center gap-2 px-4 py-2 font-medium rounded-lg transition-colors border ${utmData.length === 0 ? 'opacity-50 cursor-not-allowed' : 'hover:bg-opacity-80'} ${theme === 'dark' ? 'bg-slate-800 border-slate-700 text-white' : 'bg-white border-gray-200 text-gray-700'}`} | |
| > | |
| <Download className="w-4 h-4" /> | |
| Exportar | |
| </button> | |
| </div> | |
| </div> | |
| {error && ( | |
| <div className="p-4 bg-red-500/10 border border-red-500/50 rounded-lg text-red-500 flex items-start gap-3"> | |
| <XCircle className="w-5 h-5 mt-0.5 flex-shrink-0" /> | |
| <div> | |
| <strong>Erro ao carregar dados</strong> | |
| <p className="text-sm opacity-90 mt-1">{error}</p> | |
| </div> | |
| </div> | |
| )} | |
| {/* New Orders Alert */} | |
| {hasLoadedData && !isFirstLoad && newOrdersReport && newOrdersReport.newOrderCount > 0 && ( | |
| <div className={`p-6 rounded-xl border-l-4 shadow-lg bg-gradient-to-r ${theme === 'dark' ? 'from-slate-900 to-slate-900 border-indigo-500' : 'from-white to-blue-50 border-indigo-600'}`}> | |
| <div className="flex items-start justify-between mb-4"> | |
| <div> | |
| <h3 className="text-lg font-bold flex items-center gap-2"> | |
| <span className="bg-indigo-500 text-white p-1.5 rounded-lg"> | |
| <TrendingUp className="w-5 h-5" /> | |
| </span> | |
| Novas Vendas Detectadas | |
| </h3> | |
| <p className={`text-sm mt-1 ${theme === 'dark' ? 'text-slate-400' : 'text-gray-500'}`}> | |
| Desde a última atualização ({newOrdersReport.timeDifference}) | |
| </p> | |
| </div> | |
| <button onClick={resetSnapshot} className={`text-xs px-3 py-1.5 rounded-full border ${theme === 'dark' ? 'border-slate-700 hover:bg-slate-800' : 'border-gray-200 hover:bg-gray-100'}`}> | |
| Resetar Contador | |
| </button> | |
| </div> | |
| <div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6"> | |
| <div> | |
| <p className={`text-xs uppercase font-semibold tracking-wider ${theme === 'dark' ? 'text-slate-500' : 'text-gray-400'}`}>Pedidos</p> | |
| <p className="text-2xl font-bold text-white">{newOrdersReport.newOrderCount}</p> | |
| </div> | |
| <div> | |
| <p className={`text-xs uppercase font-semibold tracking-wider ${theme === 'dark' ? 'text-slate-500' : 'text-gray-400'}`}>Total Bruto</p> | |
| <p className="text-2xl font-bold text-emerald-400">{formatCurrency(newOrdersReport.newOrdersTotal)}</p> | |
| </div> | |
| <div> | |
| <p className={`text-xs uppercase font-semibold tracking-wider ${theme === 'dark' ? 'text-slate-500' : 'text-gray-400'}`}>Pagos</p> | |
| <p className="text-2xl font-bold text-emerald-400">{formatCurrency(newOrdersReport.newOrdersPaid)}</p> | |
| </div> | |
| <div> | |
| <p className={`text-xs uppercase font-semibold tracking-wider ${theme === 'dark' ? 'text-slate-500' : 'text-gray-400'}`}>Pedidos Pagos</p> | |
| <p className="text-2xl font-bold text-white">{newOrdersReport.newOrdersPaidCount}</p> | |
| </div> | |
| </div> | |
| {newOrdersReport.campaignBreakdown.length > 0 && ( | |
| <div> | |
| <h4 className={`text-sm font-semibold mb-3 ${theme === 'dark' ? 'text-slate-300' : 'text-gray-700'}`">Detalhe por Campanha</h4> | |
| <div className="space-y-2"> | |
| {newOrdersReport.campaignBreakdown.map((c) => ( | |
| <div key={c.utmContent} className={`flex items-center justify-between p-3 rounded-lg ${theme === 'dark' ? 'bg-slate-800/50' : 'bg-white border border-gray-100'}`}> | |
| <div className="flex items-center gap-3"> | |
| <span className={`px-2 py-1 rounded text-xs font-bold ${c.paidOrders === c.newOrders ? 'bg-emerald-500/20 text-emerald-400' : 'bg-amber-500/20 text-amber-400'}`}> | |
| {c.paidOrders}/{c.newOrders} | |
| </span> | |
| <span className="font-medium text-sm">{extractLastParameter(c.utmContent)}</span> | |
| </div> | |
| <span className="font-bold text-sm text-emerald-400">{formatCurrency(c.totalValue)}</span> | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| )} | |
| {/* KPI Cards */} | |
| {hasLoadedData && utmData.length > 0 && ( | |
| <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6"> | |
| {[ | |
| { label: 'Total Pedidos', value: totals.totalPedidos, icon: ShoppingCart, color: 'text-blue-500', bg: 'bg-blue-500/10' }, | |
| { label: 'Receita Total', value: formatCurrency(totals.totalVendas), icon: DollarSign, color: 'text-emerald-400', bg: 'bg-emerald-500/10' }, | |
| { label: 'Receita Paga', value: formatCurrency(totals.vendasPagas), icon: CheckCircle2, color: 'text-emerald-500', bg: 'bg-emerald-500/10' }, | |
| { label: 'Taxa Pagamento', value: formatPercentage(totals.taxaPagamento), icon: TrendingUp, color: 'text-indigo-400', bg: 'bg-indigo-500/10' }, | |
| ].map((kpi, idx) => ( | |
| <div key={idx} className={`p-6 rounded-xl border ${theme === 'dark' ? 'bg-slate-900 border-slate-800' : 'bg-white border-gray-200'} shadow-sm relative overflow-hidden group`}> | |
| <div className={`absolute top-0 right-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity`}> | |
| <kpi.icon className="w-16 h-16" /> | |
| </div> | |
| <div className={`p-3 rounded-lg ${kpi.bg} ${kpi.color} mb-4 w-fit`}> | |
| <kpi.icon className="w-6 h-6" /> | |
| </div> | |
| <p className={`text-sm font-medium ${theme === 'dark |