Spaces:
Running
Running
| /** | |
| * UTM Analytics Pro - Main Application Logic | |
| */ | |
| // --- Shopify Configuration --- | |
| const SHOPIFY_CONFIG = { | |
| storeName: 'sua-loja', // Substitua pelo nome da sua loja (ex: minhaloja em minhaloja.myshopify.com) | |
| accessToken: 'seu_access_token_aqui', // Token de Acesso da API Admin do Shopify | |
| apiVersion: '2024-01' | |
| }; | |
| // --- Mock Data & API Simulation --- | |
| const UTMS = [ | |
| "facebook_feed_summer", | |
| "instagram_stories_launch", | |
| "google_search_brand", | |
| "tiktok_viral_video", | |
| "email_newsletter_oct", | |
| "influencer_beauty_guru", | |
| "youtube_pre_roll", | |
| "facebook_retargeting_abandoned" | |
| ]; | |
| const NAMES = ["Ana Silva", "Bruno Souza", "Carla Dias", "Daniel Rocha", "Eduarda Lima"]; | |
| // Generates random orders based on date range | |
| function generateMockOrders(startDate, endDate, count = 20) { | |
| const orders = []; | |
| const start = new Date(startDate).getTime(); | |
| const end = new Date(endDate).getTime(); | |
| for (let i = 0; i < count; i++) { | |
| const randomTime = start + Math.random() * (end - start); | |
| const isPaid = Math.random() > 0.3; // 70% paid rate | |
| const utm = UTMS[Math.floor(Math.random() * UTMS.length)]; | |
| orders.push({ | |
| id: `gid://shopify/Order/${Math.floor(Math.random() * 1000000000)}`, | |
| name: `#${Math.floor(1000 + Math.random() * 9000)}`, | |
| createdAt: new Date(randomTime).toISOString(), | |
| displayFinancialStatus: isPaid ? 'PAID' : 'PENDING', | |
| totalPriceSet: { | |
| shopMoney: { | |
| amount: (Math.random() * 500 + 50).toFixed(2) // Random price between 50 and 550 | |
| } | |
| }, | |
| customer: { | |
| email: Math.random() > 0.5 ? `cliente${i}@email.com` : null | |
| }, | |
| customAttributes: [ | |
| { key: 'utm_content', value: utm } | |
| ] | |
| }); | |
| } | |
| return orders.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)); | |
| } | |
| // --- Application State --- | |
| const app = { | |
| state: { | |
| dateRangeOption: 'hoje', | |
| customDateRange: null, | |
| loading: false, | |
| error: null, | |
| utmData: [], | |
| allOrders: [], | |
| snapshot: null, // { timestamp, orderIds } | |
| lastUpdate: null, | |
| sortColumn: 'totalVendas', | |
| sortDirection: 'desc' | |
| }, | |
| init() { | |
| this.cacheDOM(); | |
| this.bindEvents(); | |
| // Initial load isn't automatic to match the "No data" state, | |
| // but let's pre-load some empty states | |
| this.updateUI(); | |
| }, | |
| cacheDOM() { | |
| this.dom = { | |
| dateSelect: document.getElementById('date-range-select'), | |
| customDateContainer: document.getElementById('custom-date-container'), | |
| dateStart: document.getElementById('date-start'), | |
| dateEnd: document.getElementById('date-end'), | |
| refreshBtn: document.getElementById('refresh-btn'), | |
| exportBtn: document.getElementById('export-btn'), | |
| alertsArea: document.getElementById('alerts-area'), | |
| kpiSection: document.getElementById('kpi-section'), | |
| tableLoading: document.getElementById('table-loading'), | |
| tableEmpty: document.getElementById('table-empty'), | |
| dataTable: document.getElementById('data-table'), | |
| tableBody: document.getElementById('table-body'), | |
| tableFooter: document.getElementById('table-footer'), | |
| sortSelect: document.getElementById('sort-select'), | |
| // KPIs | |
| kpiTotalOrders: document.getElementById('kpi-total-orders'), | |
| kpiPaidSales: document.getElementById('kpi-paid-sales'), | |
| kpiConversion: document.getElementById('kpi-conversion-rate'), | |
| kpiUniqueUtms: document.getElementById('kpi-unique-utms'), | |
| }; | |
| }, | |
| bindEvents() { | |
| this.dom.dateSelect.addEventListener('change', (e) => { | |
| this.state.dateRangeOption = e.target.value; | |
| if (this.state.dateRangeOption === 'customizado') { | |
| this.dom.customDateContainer.classList.remove('hidden'); | |
| } else { | |
| this.dom.customDateContainer.classList.add('hidden'); | |
| } | |
| }); | |
| this.dom.refreshBtn.addEventListener('click', () => this.fetchOrders()); | |
| this.dom.exportBtn.addEventListener('click', () => this.exportCSV()); | |
| this.dom.sortSelect.addEventListener('change', (e) => { | |
| this.state.sortColumn = e.target.value; | |
| this.state.sortDirection = 'desc'; | |
| this.renderTable(); | |
| }); | |
| }, | |
| // --- Logic & Helpers --- | |
| getDateRange() { | |
| const now = new Date(); | |
| const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); | |
| let startDate, endDate = new Date(today.getTime() + 24 * 60 * 60 * 1000 - 1); | |
| switch (this.state.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; | |
| case 'customizado': | |
| if (this.dom.dateStart.value && this.dom.dateEnd.value) { | |
| return { | |
| startDate: new Date(this.dom.dateStart.value).toISOString(), | |
| endDate: new Date(this.dom.dateEnd.value).toISOString() | |
| }; | |
| } | |
| // Fallback if dates not picked yet | |
| startDate = today; | |
| break; | |
| default: | |
| startDate = today; | |
| } | |
| return { startDate: startDate.toISOString(), endDate: endDate.toISOString() }; | |
| }, | |
| calculateTimeDiff(timestamp) { | |
| const now = new Date(); | |
| const then = new Date(timestamp); | |
| const diffMinutes = Math.floor((now - then) / 60000); | |
| if (diffMinutes < 1) return "menos de 1 minuto"; | |
| const hours = Math.floor(diffMinutes / 60); | |
| const mins = diffMinutes % 60; | |
| if (hours === 0) return `${mins}m`; | |
| return `${hours}h e ${mins}m`; | |
| }, | |
| // --- Core Actions --- | |
| async fetchShopifyData(startDate, endDate) { | |
| const query = ` | |
| { | |
| orders(first: 250, query: "created_at:>='${startDate}' AND created_at:<='${endDate}'") { | |
| edges { | |
| node { | |
| id | |
| name | |
| createdAt | |
| displayFinancialStatus | |
| totalPriceSet { | |
| shopMoney { | |
| amount | |
| currencyCode | |
| } | |
| } | |
| customer { | |
| } | |
| customAttributes { | |
| key | |
| value | |
| } | |
| } | |
| } | |
| } | |
| }`; | |
| const response = await fetch(`https://${SHOPIFY_CONFIG.storeName}.myshopify.com/admin/api/${SHOPIFY_CONFIG.apiVersion}/graphql.json`, { | |
| method: 'POST', | |
| headers: { | |
| 'X-Shopify-Access-Token': SHOPIFY_CONFIG.accessToken, | |
| 'Content-Type': 'application/json' | |
| }, | |
| body: JSON.stringify({ query }) | |
| }); | |
| if (!response.ok) { | |
| const errorText = await response.text(); | |
| throw new Error(`Shopify API Error: ${response.status} - ${errorText}`); | |
| } | |
| const data = await response.json(); | |
| return data.data.orders.edges.map(edge => edge.node); | |
| }, | |
| async fetchOrders() { | |
| this.state.loading = true; | |
| this.state.error = null; | |
| this.updateUI(); | |
| try { | |
| const { startDate, endDate } = this.getDateRange(); | |
| let allOrders = []; | |
| // Tenta buscar dados reais do Shopify se as credenciais estiverem configuradas | |
| if (SHOPIFY_CONFIG.storeName !== 'sua-loja' && SHOPIFY_CONFIG.accessToken !== 'seu_access_token_aqui') { | |
| try { | |
| console.log("Conectando ao Shopify..."); | |
| allOrders = await this.fetchShopifyData(startDate.split('T')[0], endDate.split('T')[0]); | |
| } catch (apiError) { | |
| console.warn("Falha ao buscar dados da API Shopify, usando dados mockados:", apiError); | |
| this.state.error = "Erro na API Shopify: " + apiError.message + ". Exibindo dados simulados."; | |
| // Fallback para mock | |
| const orderCount = Math.floor(Math.random() * 30) + 10; | |
| allOrders = generateMockOrders(startDate, endDate, orderCount); | |
| } | |
| } else { | |
| // Dados Mockados | |
| await new Promise(r => setTimeout(r, 1500)); // Simulate Network Delay | |
| const orderCount = Math.floor(Math.random() * 30) + 10; | |
| allOrders = generateMockOrders(startDate, endDate, orderCount); | |
| } | |
| // 2. Compare with Snapshot for "New Orders" | |
| let newOrdersReport = null; | |
| if (this.state.snapshot) { | |
| const previousIds = new Set(this.state.snapshot.orderIds); | |
| const newOrders = allOrders.filter(o => !previousIds.has(o.id)); | |
| if (newOrders.length > 0) { | |
| const totalVal = newOrders.reduce((sum, o) => sum + parseFloat(o.totalPriceSet.shopMoney.amount), 0); | |
| const paidVal = newOrders.filter(o => o.displayFinancialStatus === 'PAID') | |
| .reduce((sum, o) => sum + parseFloat(o.totalPriceSet.shopMoney.amount), 0); | |
| newOrdersReport = { | |
| count: newOrders.length, | |
| total: totalVal, | |
| paid: paidVal, | |
| timeDiff: this.calculateTimeDiff(this.state.snapshot.timestamp) | |
| }; | |
| } else { | |
| newOrdersReport = { count: 0, timeDiff: this.calculateTimeDiff(this.state.snapshot.timestamp) }; | |
| } | |
| } | |
| // 3. Update Snapshot | |
| this.state.snapshot = { | |
| timestamp: new Date().toISOString(), | |
| orderIds: allOrders.map(o => o.id) | |
| }; | |
| this.state.lastUpdate = new Date().toLocaleTimeString('pt-BR', {hour: '2-digit', minute:'2-digit'}); | |
| // 4. Process Data | |
| this.processOrders(allOrders); | |
| this.state.newOrdersReport = newOrdersReport; | |
| } catch (err) { | |
| console.error(err); | |
| this.state.error = "Erro ao carregar dados. Tente novamente."; | |
| console.error(err); | |
| } finally { | |
| this.state.loading = false; | |
| this.updateUI(); | |
| } | |
| }, | |
| processOrders(orders) { | |
| const utmGroups = new Map(); | |
| orders.forEach(order => { | |
| // Find UTM Content | |
| let utm = 'Sem UTM Content'; | |
| const attr = order.customAttributes.find(a => a.key === 'utm_content'); | |
| if (attr && attr.value) utm = attr.value.trim(); | |
| if (!utmGroups.has(utm)) utmGroups.set(utm, []); | |
| utmGroups.get(utm).push(order); | |
| }); | |
| const processed = []; | |
| utmGroups.forEach((group, utmName) => { | |
| const total = group.length; | |
| const paidCount = group.filter(o => o.displayFinancialStatus === 'PAID').length; | |
| const uniqueClients = new Set(group.map(o => o.customer?.email || o.id)).size; | |
| const totalSales = group.reduce((sum, o) => sum + parseFloat(o.totalPriceSet.shopMoney.amount), 0); | |
| const paidSales = group.filter(o => o.displayFinancialStatus === 'PAID') | |
| .reduce((sum, o) => sum + parseFloat(o.totalPriceSet.shopMoney.amount), 0); | |
| const rate = total > 0 ? (paidCount / total) * 100 : 0; | |
| processed.push({ | |
| utmContent: utmName, | |
| totalPedidos: total, | |
| pedidosPagos: paidCount, | |
| pedidosPendentes: total - paidCount, | |
| clientesUnicos: uniqueClients, | |
| totalVendas: totalSales, | |
| vendasPagas: paidSales, | |
| taxaPagamento: rate | |
| }); | |
| }); | |
| this.state.utmData = processed; | |
| }, | |
| handleSort(column) { | |
| if (this.state.sortColumn === column) { | |
| this.state.sortDirection = this.state.sortDirection === 'asc' ? 'desc' : 'asc'; | |
| } else { | |
| this.state.sortColumn = column; | |
| this.state.sortDirection = 'desc'; | |
| } | |
| this.renderTable(); | |
| }, | |
| getSortedData() { | |
| return [...this.state.utmData].sort((a, b) => { | |
| let valA = a[this.state.sortColumn]; | |
| let valB = b[this.state.sortColumn]; | |
| if (typeof valA === 'string') { | |
| valA = valA.toLowerCase(); | |
| valB = valB.toLowerCase(); | |
| } | |
| if (valA < valB) return this.state.sortDirection === 'asc' ? -1 : 1; | |
| if (valA > valB) return this.state.sortDirection === 'asc' ? 1 : -1; | |
| return 0; | |
| }); | |
| }, | |
| calculateTotals() { | |
| return this.state.utmData.reduce((acc, curr) => { | |
| acc.totalPedidos += curr.totalPedidos; | |
| acc.pedidosPagos += curr.pedidosPagos; | |
| acc.clientesUnicos += curr.clientesUnicos; | |
| acc.totalVendas += curr.totalVendas; | |
| acc.vendasPagas += curr.vendasPagas; | |
| return acc; | |
| }, { | |
| totalPedidos: 0, pedidosPagos: 0, clientesUnicos: 0, | |
| totalVendas: 0, vendasPagas: 0, utmContent: 'TOTAL GERAL' | |
| }); | |
| }, | |
| // --- UI Rendering --- | |
| updateUI() { | |
| // Loading State | |
| if (this.state.loading) { | |
| this.dom.tableLoading.classList.remove('hidden'); | |
| this.dom.dataTable.classList.add('hidden'); | |
| this.dom.tableEmpty.classList.add('hidden'); | |
| this.dom.kpiSection.classList.add('hidden'); | |
| this.dom.alertsArea.innerHTML = ''; | |
| this.dom.refreshBtn.disabled = true; | |
| this.dom.refreshBtn.classList.add('opacity-75'); | |
| return; | |
| } | |
| this.dom.refreshBtn.disabled = false; | |
| this.dom.refreshBtn.classList.remove('opacity-75'); | |
| this.dom.tableLoading.classList.add('hidden'); | |
| // Error State | |
| if (this.state.error) { | |
| this.dom.alertsArea.innerHTML = ` | |
| <div class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded relative"> | |
| <strong class="font-bold">Erro!</strong> | |
| <span class="block sm:inline">${this.state.error}</span> | |
| </div>`; | |
| return; | |
| } | |
| // Empty State (No data loaded yet) | |
| if (this.state.utmData.length === 0) { | |
| this.dom.tableEmpty.classList.remove('hidden'); | |
| this.dom.dataTable.classList.add('hidden'); | |
| this.dom.kpiSection.classList.add('hidden'); | |
| return; | |
| } | |
| // Has Data | |
| this.dom.tableEmpty.classList.add('hidden'); | |
| this.dom.dataTable.classList.remove('hidden'); | |
| this.dom.kpiSection.classList.remove('hidden'); | |
| this.dom.exportBtn.disabled = false; | |
| this.renderAlerts(); | |
| this.renderKPIs(); | |
| this.renderTable(); | |
| }, | |
| renderAlerts() { | |
| let html = ''; | |
| // New Orders Alert | |
| if (this.state.newOrdersReport && this.state.newOrdersReport.count > 0) { | |
| const r = this.state.newOrdersReport; | |
| html += ` | |
| <div class="bg-indigo-50 border border-indigo-200 rounded-lg p-4 flex flex-col md:flex-row md:items-center justify-between gap-4 animate-fade-in"> | |
| <div class="flex items-start gap-3"> | |
| <div class="bg-indigo-100 p-2 rounded-full text-indigo-600"> | |
| <i data-feather="bell" class="w-5 h-5"></i> | |
| </div> | |
| <div> | |
| <h3 class="font-bold text-indigo-900">Novas Vendas Detectadas!</h3> | |
| <p class="text-indigo-700 text-sm"> | |
| ${r.count} novos pedidos desde a última atualização (${r.timeDiff} atrás). | |
| Total de <span class="font-bold">R$ ${r.total.toLocaleString('pt-BR', {minimumFractionDigits: 2})}</span>. | |
| </p> | |
| </div> | |
| </div> | |
| <button onclick="app.resetSnapshot()" class="text-sm text-indigo-600 hover:text-indigo-800 underline">Resetar Ponto de Partida</button> | |
| </div> | |
| `; | |
| } else if (this.state.snapshot) { | |
| html += ` | |
| <div class="bg-gray-100 border border-gray-200 rounded-lg px-4 py-2 text-sm text-gray-600 flex justify-between items-center"> | |
| <span>Última atualização: <strong>${this.state.lastUpdate}</strong></span> | |
| <span>Ponto de comparação definido.</span> | |
| </div> | |
| `; | |
| } | |
| this.dom.alertsArea.innerHTML = html; | |
| feather.replace(); | |
| }, | |
| renderKPIs() { | |
| const totals = this.calculateTotals(); | |
| this.dom.kpiTotalOrders.textContent = totals.totalPedidos; | |
| this.dom.kpiPaidSales.textContent = `R$ ${totals.vendasPagas.toLocaleString('pt-BR', {minimumFractionDigits: 2})}`; | |
| this.dom.kpiUniqueUtms.textContent = this.state.utmData.length; | |
| const rate = totals.totalPedidos > 0 ? (totals.pedidosPagos / totals.totalPedidos) * 100 : 0; | |
| this.dom.kpiConversion.textContent = `${rate.toFixed(1)}%`; | |
| }, | |
| renderTable() { | |
| const data = this.getSortedData(); | |
| const totals = this.calculateTotals(); | |
| // Render Rows | |
| this.dom.tableBody.innerHTML = data.map((row, idx) => { | |
| const paidPct = row.totalPedidos > 0 ? (row.pedidosPagos / row.totalPedidos) * 100 : 0; | |
| // Determine Badge Color | |
| let badgeClass = 'badge-critical'; | |
| if (row.taxaPagamento >= 70) badgeClass = 'badge-success'; | |
| else if (row.taxaPagamento >= 50) badgeClass = 'badge-warning'; | |
| return ` | |
| <tr class="hover:bg-gray-50 animate-fade-in" style="animation-delay: ${idx * 50}ms"> | |
| <td class="p-4"> | |
| <div class="flex flex-col"> | |
| <span class="font-semibold text-gray-900 flex items-center gap-2"> | |
| <span class="${badgeClass}">${row.utmContent}</span> | |
| </span> | |
| </div> | |
| </td> | |
| <td class="p-4 text-right"> | |
| <div class="flex flex-col items-end gap-1"> | |
| <div class="w-32 bg-gray-200 rounded-full h-2.5 dark:bg-gray-200"> | |
| <div class="bg-primary h-2.5 rounded-full progress-bar-fill" style="width: ${paidPct}%"></div> | |
| </div> | |
| <span class="text-xs text-gray-500">${row.pedidosPagos}/${row.totalPedidos}</span> | |
| </div> | |
| </td> | |
| <td class="p-4 text-center">${row.clientesUnicos}</td> | |
| <td class="p-4 text-right font-medium">R$ ${row.totalVendas.toLocaleString('pt-BR', {minimumFractionDigits: 2})}</td> | |
| <td class="p-4 text-right font-medium text-emerald-600">R$ ${row.vendasPagas.toLocaleString('pt-BR', {minimumFractionDigits: 2})}</td> | |
| <td class="p-4 text-center"> | |
| <span class="${badgeClass} font-bold">${row.taxaPagamento.toFixed(1)}%</span> | |
| </td> | |
| </tr> | |
| `; | |
| }).join(''); | |
| // Render Footer | |
| const totalRate = totals.totalPedidos > 0 ? (totals.pedidosPagos / totals.totalPedidos) * 100 : 0; | |
| this.dom.tableFooter.innerHTML = ` | |
| <td class="p-4">${totals.utmContent}</td> | |
| <td class="p-4 text-right">${totals.pedidosPagos}/${totals.totalPedidos}</td> | |
| <td class="p-4 text-center">${totals.clientesUnicos}</td> | |
| <td class="p-4 text-right">R$ ${totals.totalVendas.toLocaleString('pt-BR', {minimumFractionDigits: 2})}</td> | |
| <td class="p-4 text-right text-emerald-600">R$ ${totals.vendasPagas.toLocaleString('pt-BR', {minimumFractionDigits: 2})}</td> | |
| <td class="p-4 text-center font-bold">${totalRate.toFixed(1)}%</td> | |
| `; | |
| }, | |
| resetSnapshot() { | |
| this.state.snapshot = null; | |
| this.state.newOrdersReport = null; | |
| this.renderAlerts(); | |
| }, | |
| exportCSV() { | |
| const data = this.getSortedData(); | |
| const totals = this.calculateTotals(); | |
| const rows = data.map(r => [ | |
| r.utmContent, | |
| r.totalPedidos, | |
| r.pedidosPagos, | |
| r.clientesUnicos, | |
| r.totalVendas.toFixed(2), | |
| r.vendasPagas.toFixed(2), | |
| r.taxaPagamento.toFixed(1) | |
| ]); | |
| // Add totals row | |
| rows.push([ | |
| totals.utmContent, | |
| totals.totalPedidos, | |
| totals.pedidosPagos, | |
| totals.clientesUnicos, | |
| totals.totalVendas.toFixed(2), | |
| totals.vendasPagas.toFixed(2), | |
| totals.taxaPagamento.toFixed(1) | |
| ]); | |
| const csvContent = [ | |
| ['UTM Content', 'Total Pedidos', 'Pedidos Pagos', 'Clientes Unicos', 'Total Vendas', 'Vendas Pagas', 'Taxa Pagamento'], | |
| ...rows | |
| ].map(e => e.join(",")).join("\n"); | |
| const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); | |
| const link = document.createElement("a"); | |
| const url = URL.createObjectURL(blob); | |
| const dateStr = new Date().toISOString().split('T')[0]; | |
| link.setAttribute("href", url); | |
| link.setAttribute("download", `relatorio-utm-${dateStr}.csv`); | |
| link.style.visibility = 'hidden'; | |
| document.body.appendChild(link); | |
| link.click(); | |
| document.body.removeChild(link); | |
| } | |
| }; | |
| // Start App | |
| document.addEventListener('DOMContentLoaded', () => { | |
| app.init(); | |
| }); |