Spaces:
Running
Running
| <html lang="pt-BR" data-theme="dark"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Relatório de Vendas UTM - Pro Analytics</title> | |
| <!-- Typography: Inter for clean, modern readability --> | |
| <link rel="preconnect" href="https://fonts.googleapis.com"> | |
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> | |
| <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet"> | |
| <!-- Icons: Remix Icon for consistent, crisp iconography --> | |
| <link href="https://cdn.jsdelivr.net/npm/remixicon@3.5.0/fonts/remixicon.css" rel="stylesheet"> | |
| <style> | |
| /* --- 1. DESIGN SYSTEM & VARIABLES --- */ | |
| :root { | |
| /* Palette: Dark Mode Default (Premium Zinc & Emerald) */ | |
| --bg-body: #09090b; /* Zinc 950 */ | |
| --bg-card: #18181b; /* Zinc 900 */ | |
| --bg-surface: #27272a; /* Zinc 800 */ | |
| --bg-input: #09090b; /* Zinc 950 */ | |
| --border-subtle: #27272a; | |
| --border-default: #3f3f46; | |
| --border-focus: #10b981; /* Emerald 500 */ | |
| --text-main: #f4f4f5; /* Zinc 100 */ | |
| --text-muted: #a1a1aa; /* Zinc 400 */ | |
| --text-faint: #71717a; /* Zinc 500 */ | |
| --primary: #10b981; /* Emerald 500 */ | |
| --primary-dim: rgba(16, 185, 129, 0.1); | |
| --primary-glow: rgba(16, 185, 129, 0.25); | |
| --accent: #8b5cf6; /* Violet */ | |
| --danger: #ef4444; | |
| --warning: #f59e0b; | |
| --info: #3b82f6; | |
| --shadow-card: 0 4px 6px -1px rgba(0, 0, 0, 0.4), 0 2px 4px -1px rgba(0, 0, 0, 0.2); | |
| --shadow-header: 0 1px 3px 0 rgba(0, 0, 0, 0.5); | |
| --radius-sm: 8px; | |
| --radius-md: 12px; | |
| --radius-lg: 16px; | |
| --font-main: 'Inter', sans-serif; | |
| --ease-out: cubic-bezier(0.16, 1, 0.3, 1); | |
| } | |
| [data-theme="light"] { | |
| --bg-body: #f4f4f5; /* Zinc 100 */ | |
| --bg-card: #ffffff; | |
| --bg-surface: #f4f4f5; /* Zinc 100 */ | |
| --bg-input: #ffffff; | |
| --border-subtle: #e4e4e7; | |
| --border-default: #d4d4d8; | |
| --border-focus: #059669; /* Emerald 600 */ | |
| --text-main: #18181b; /* Zinc 900 */ | |
| --text-muted: #52525b; /* Zinc 600 */ | |
| --text-faint: #a1a1aa; /* Zinc 400 */ | |
| --primary: #059669; /* Emerald 600 */ | |
| --primary-dim: rgba(5, 150, 105, 0.1); | |
| --primary-glow: rgba(5, 150, 105, 0.15); | |
| --shadow-card: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -1px rgba(0, 0, 0, 0.03); | |
| --shadow-header: 0 1px 3px 0 rgba(0, 0, 0, 0.1); | |
| } | |
| /* --- 2. RESET & BASE --- */ | |
| * { box-sizing: border-box; margin: 0; padding: 0; outline: none; } | |
| body { | |
| font-family: var(--font-main); | |
| background-color: var(--bg-body); | |
| color: var(--text-main); | |
| line-height: 1.6; | |
| -webkit-font-smoothing: antialiased; | |
| transition: background-color 0.3s ease, color 0.3s ease; | |
| padding-bottom: 40px; | |
| } | |
| a { text-decoration: none; color: inherit; transition: color 0.2s; } | |
| ul { list-style: none; } | |
| button { font-family: inherit; } | |
| /* Scrollbar Styling */ | |
| ::-webkit-scrollbar { width: 8px; height: 8px; } | |
| ::-webkit-scrollbar-track { background: var(--bg-body); } | |
| ::-webkit-scrollbar-thumb { background: var(--border-default); border-radius: 4px; } | |
| ::-webkit-scrollbar-thumb:hover { background: var(--text-muted); } | |
| /* --- 3. LAYOUT UTILITIES --- */ | |
| .container { | |
| max-width: 1280px; | |
| margin: 0 auto; | |
| padding: 0 24px; | |
| } | |
| .flex-between { display: flex; justify-content: space-between; align-items: center; } | |
| .flex-center { display: flex; justify-content: center; align-items: center; } | |
| .gap-2 { gap: 8px; } | |
| .gap-4 { gap: 16px; } | |
| .mt-4 { margin-top: 16px; } | |
| .mb-4 { margin-bottom: 16px; } | |
| /* --- 4. COMPONENTS --- */ | |
| /* Header */ | |
| header { | |
| position: sticky; | |
| top: 0; | |
| z-index: 100; | |
| background: rgba(9, 9, 11, 0.75); | |
| backdrop-filter: blur(16px); | |
| -webkit-backdrop-filter: blur(16px); | |
| border-bottom: 1px solid var(--border-subtle); | |
| padding: 16px 0; | |
| transition: all 0.3s ease; | |
| } | |
| [data-theme="light"] header { background: rgba(255, 255, 255, 0.85); } | |
| .brand { | |
| display: flex; | |
| align-items: center; | |
| gap: 12px; | |
| font-weight: 700; | |
| font-size: 1.125rem; | |
| letter-spacing: -0.02em; | |
| } | |
| .brand i { color: var(--primary); font-size: 1.25rem; } | |
| /* Buttons */ | |
| .btn { | |
| display: inline-flex; | |
| align-items: center; | |
| justify-content: center; | |
| gap: 8px; | |
| padding: 10px 18px; | |
| border-radius: var(--radius-sm); | |
| font-weight: 500; | |
| font-size: 0.875rem; | |
| cursor: pointer; | |
| transition: all 0.2s var(--ease-out); | |
| border: 1px solid transparent; | |
| white-space: nowrap; | |
| } | |
| .btn-primary { | |
| background-color: var(--primary); | |
| color: #fff; | |
| box-shadow: 0 0 0 1px var(--primary-glow), 0 4px 12px var(--primary-glow); | |
| } | |
| .btn-primary:hover:not(:disabled) { | |
| transform: translateY(-1px); | |
| box-shadow: 0 0 0 1px var(--primary-glow), 0 6px 16px var(--primary-glow); | |
| filter: brightness(1.1); | |
| } | |
| .btn-secondary { | |
| background-color: var(--bg-surface); | |
| border-color: var(--border-default); | |
| color: var(--text-main); | |
| } | |
| .btn-secondary:hover:not(:disabled) { | |
| border-color: var(--text-muted); | |
| background-color: var(--border-subtle); | |
| } | |
| .btn-ghost { | |
| background: transparent; | |
| color: var(--text-muted); | |
| } | |
| .btn-ghost:hover { color: var(--text-main); background-color: var(--bg-surface); } | |
| .btn-icon { padding: 8px; border-radius: 50%; aspect-ratio: 1; } | |
| .btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none ; box-shadow: none ; } | |
| /* Cards */ | |
| .card { | |
| background-color: var(--bg-card); | |
| border: 1px solid var(--border-subtle); | |
| border-radius: var(--radius-md); | |
| padding: 24px; | |
| box-shadow: var(--shadow-card); | |
| margin-bottom: 24px; | |
| position: relative; | |
| overflow: hidden; | |
| transition: transform 0.3s ease; | |
| } | |
| .card-header { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: flex-start; | |
| margin-bottom: 20px; | |
| } | |
| .card-title { | |
| font-size: 1rem; | |
| font-weight: 600; | |
| color: var(--text-main); | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| } | |
| /* Forms & Inputs */ | |
| .form-grid { | |
| display: grid; | |
| grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); | |
| gap: 20px; | |
| } | |
| .input-group { display: flex; flex-direction: column; gap: 8px; } | |
| label { | |
| font-size: 0.75rem; | |
| font-weight: 600; | |
| color: var(--text-muted); | |
| text-transform: uppercase; | |
| letter-spacing: 0.05em; | |
| } | |
| input, select { | |
| background-color: var(--bg-input); | |
| border: 1px solid var(--border-default); | |
| color: var(--text-main); | |
| padding: 10px 14px; | |
| border-radius: var(--radius-sm); | |
| font-size: 0.9rem; | |
| transition: all 0.2s; | |
| width: 100%; | |
| } | |
| input:focus, select:focus { | |
| border-color: var(--primary); | |
| box-shadow: 0 0 0 3px var(--primary-dim); | |
| } | |
| /* Metrics Grid */ | |
| .metrics-container { | |
| display: grid; | |
| grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); | |
| gap: 20px; | |
| margin-bottom: 24px; | |
| } | |
| .metric-card { | |
| background: linear-gradient(145deg, var(--bg-card), rgba(255,255,255,0.02)); | |
| border: 1px solid var(--border-subtle); | |
| border-radius: var(--radius-md); | |
| padding: 20px; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 6px; | |
| position: relative; | |
| } | |
| /* Decorative accent line */ | |
| .metric-card::after { | |
| content: ''; | |
| position: absolute; | |
| top: 0; left: 0; bottom: 0; | |
| width: 3px; | |
| background: var(--primary); | |
| opacity: 0.7; | |
| border-top-left-radius: var(--radius-md); | |
| border-bottom-left-radius: var(--radius-md); | |
| } | |
| .metric-label { font-size: 0.8rem; color: var(--text-muted); font-weight: 500; } | |
| .metric-value { font-size: 1.75rem; font-weight: 700; color: var(--text-main); letter-spacing: -0.03em; } | |
| .metric-sub { font-size: 0.75rem; color: var(--text-faint); display: flex; align-items: center; gap: 4px; } | |
| /* Table */ | |
| .table-container { | |
| width: 100%; | |
| overflow-x: auto; | |
| border-radius: var(--radius-sm); | |
| border: 1px solid var(--border-subtle); | |
| } | |
| table { | |
| width: 100%; | |
| border-collapse: collapse; | |
| font-size: 0.875rem; | |
| white-space: nowrap; | |
| } | |
| th { | |
| background-color: var(--bg-surface); | |
| color: var(--text-muted); | |
| font-weight: 600; | |
| text-transform: uppercase; | |
| font-size: 0.7rem; | |
| padding: 14px 16px; | |
| text-align: left; | |
| letter-spacing: 0.05em; | |
| border-bottom: 1px solid var(--border-subtle); | |
| cursor: pointer; | |
| user-select: none; | |
| transition: color 0.2s; | |
| } | |
| th:hover { color: var(--text-main); } | |
| td { | |
| padding: 14px 16px; | |
| border-bottom: 1px solid var(--border-subtle); | |
| color: var(--text-main); | |
| } | |
| tr:last-child td { border-bottom: none; } | |
| tr:hover td { background-color: rgba(255,255,255,0.02); } | |
| [data-theme="light"] tr:hover td { background-color: rgba(0,0,0,0.02); } | |
| .text-right { text-align: right; } | |
| .text-center { text-align: center; } | |
| /* Badges & Pills */ | |
| .badge { | |
| padding: 4px 8px; | |
| border-radius: 6px; | |
| font-size: 0.7rem; | |
| font-weight: 600; | |
| text-transform: uppercase; | |
| } | |
| .badge-success { background: rgba(16, 185, 129, 0.15); color: #34d399; border: 1px solid rgba(16, 185, 129, 0.2); } | |
| .badge-warning { background: rgba(245, 158, 11, 0.15); color: #fbbf24; border: 1px solid rgba(245, 158, 11, 0.2); } | |
| .badge-danger { background: rgba(239, 68, 68, 0.15); color: #f87171; border: 1px solid rgba(239, 68, 68, 0.2); } | |
| /* States (Loading, Empty, Alert) */ | |
| .state-container { | |
| text-align: center; | |
| padding: 60px 20px; | |
| color: var(--text-muted); | |
| } | |
| .state-icon { font-size: 3rem; margin-bottom: 16px; display: block; opacity: 0.5; } | |
| .alert { | |
| padding: 12px 16px; | |
| border-radius: var(--radius-sm); | |
| margin-bottom: 20px; | |
| display: flex; | |
| align-items: center; | |
| gap: 12px; | |
| font-size: 0.875rem; | |
| border: 1px solid transparent; | |
| } | |
| .alert-info { background: rgba(59, 130, 246, 0.1); border-color: rgba(59, 130, 246, 0.2); color: #60a5fa; } | |
| .alert-warning { background: rgba(245, 158, 11, 0.1); border-color: rgba(245, 158, 11, 0.2); color: #fbbf24; } | |
| .alert-error { background: rgba(239, 68, 68, 0.1); border-color: rgba(239, 68, 68, 0.2); color: #f87171; } | |
| /* Animations */ | |
| @keyframes spin { to { transform: rotate(360deg); } } | |
| .spinner { | |
| width: 24px; height: 24px; | |
| border: 2px solid var(--bg-surface); | |
| border-top-color: var(--primary); | |
| border-radius: 50%; | |
| animation: spin 0.8s linear infinite; | |
| } | |
| @keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } } | |
| .animate-fade-in { animation: fadeIn 0.4s var(--ease-out) forwards; } | |
| /* Specific: New Orders Delta */ | |
| .delta-card { | |
| border: 1px solid rgba(16, 185, 129, 0.3); | |
| background: radial-gradient(circle at top right, rgba(16, 185, 129, 0.05), transparent 40%); | |
| } | |
| .delta-list { display: flex; flex-direction: column; gap: 8px; } | |
| .delta-item { | |
| display: flex; justify-content: space-between; align-items: center; | |
| padding: 8px 12px; | |
| background: var(--bg-surface); | |
| border-radius: var(--radius-sm); | |
| font-size: 0.85rem; | |
| } | |
| /* Utilities */ | |
| .hidden { display: none ; } | |
| .link-external { font-size: 0.8rem; color: var(--text-faint); display: flex; align-items: center; gap: 4px; } | |
| .link-external:hover { color: var(--primary); } | |
| /* Responsive */ | |
| @media (max-width: 768px) { | |
| .header-content { flex-direction: column; gap: 16px; text-align: center; } | |
| .card-header { flex-direction: column; gap: 12px; align-items: flex-start; } | |
| .controls-row { flex-direction: column; } | |
| .controls-row > * { width: 100%; } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <!-- HEADER --> | |
| <header> | |
| <div class="container header-content flex-between"> | |
| <div class="brand"> | |
| <i class="ri-bar-chart-grouped-fill"></i> | |
| <span>UTM Analytics Pro</span> | |
| </div> | |
| <div class="header-actions flex-center gap-4"> | |
| <a href="https://huggingface.co/spaces/akhaliq/anycoder" target="_blank" class="link-external"> | |
| Built with anycoder <i class="ri-external-link-line"></i> | |
| </a> | |
| <div id="theme-toggle" class="btn btn-ghost btn-icon" title="Alternar Tema"> | |
| <i class="ri-sun-line"></i> | |
| </div> | |
| </div> | |
| </div> | |
| </header> | |
| <main class="container" style="padding-top: 32px;"> | |
| <!-- 1. CONFIGURATION (Collapsible) --> | |
| <section class="card" id="config-card"> | |
| <div class="card-header"> | |
| <div class="card-title"> | |
| <i class="ri-settings-3-line"></i> Configuração da API | |
| </div> | |
| <button id="toggle-config-btn" class="btn btn-ghost btn-icon"> | |
| <i class="ri-arrow-down-s-line"></i> | |
| </button> | |
| </div> | |
| <div id="config-content" class="hidden"> | |
| <div class="form-grid mb-4"> | |
| <div class="input-group"> | |
| <label for="store-name">Nome da Loja</label> | |
| <input type="text" id="store-name" placeholder="ex: minha-loja"> | |
| </div> | |
| <div class="input-group"> | |
| <label for="access-token">Access Token</label> | |
| <input type="password" id="access-token" placeholder="shpat_xxxxx..."> | |
| </div> | |
| <div class="input-group"> | |
| <label for="cors-proxy">CORS Proxy (Opcional)</label> | |
| <input type="text" id="cors-proxy" placeholder="https://corsproxy.io/?"> | |
| </div> | |
| </div> | |
| <div class="flex-between"> | |
| <div id="config-msg" class="alert alert-info hidden" style="margin-bottom: 0; padding: 8px 12px;"> | |
| <i class="ri-checkbox-circle-line"></i> <span>Salvo!</span> | |
| </div> | |
| <button id="save-config-btn" class="btn btn-primary"> | |
| <i class="ri-save-3-line"></i> Salvar Configurações | |
| </button> | |
| </div> | |
| </div> | |
| </section> | |
| <!-- 2. CONTROLS & FILTERS --> | |
| <section class="card"> | |
| <div class="form-grid" style="align-items: end;"> | |
| <div class="input-group"> | |
| <label for="date-range">Período de Análise</label> | |
| <select id="date-range"> | |
| <option value="hoje">Hoje</option> | |
| <option value="ontem">Ontem</option> | |
| <option value="ultimos7" selected>Últimos 7 dias</option> | |
| <option value="ultimos30">Últimos 30 dias</option> | |
| <option value="customizado">Customizado</option> | |
| </select> | |
| </div> | |
| <div class="input-group hidden" id="custom-dates"> | |
| <label>Intervalo</label> | |
| <div style="display: flex; gap: 8px;"> | |
| <input type="date" id="date-start"> | |
| <input type="date" id="date-end"> | |
| </div> | |
| </div> | |
| <div class="controls-row flex-between" style="width: 100%; justify-content: flex-end;"> | |
| <button id="btn-fetch" class="btn btn-primary" disabled> | |
| <i class="ri-refresh-line"></i> Atualizar Dados | |
| </button> | |
| <button id="btn-export" class="btn btn-secondary" disabled> | |
| <i class="ri-download-cloud-2-line"></i> CSV | |
| </button> | |
| </div> | |
| </div> | |
| </section> | |
| <!-- ALERTS & STATUS --> | |
| <div id="alert-area"></div> | |
| <!-- 3. NEW ORDERS DELTA (Visible only on update) --> | |
| <section id="delta-section" class="card delta-card hidden animate-fade-in"> | |
| <div class="card-header"> | |
| <div class="card-title" style="color: var(--primary);"> | |
| <i class="ri-flashlight-fill"></i> Novas Vendas Detectadas | |
| </div> | |
| <button id="reset-snapshot" class="btn btn-ghost" style="font-size: 0.75rem;">Resetar Base</button> | |
| </div> | |
| <div class="flex-between mb-4" style="font-size: 0.9rem; color: var(--text-muted);"> | |
| <span>Base de comparação: <span id="snapshot-time" style="color: var(--text-main);">--</span></span> | |
| <span>Total de novos pedidos: <strong id="new-orders-count">0</strong></span> | |
| </div> | |
| <div id="delta-content" class="delta-list"> | |
| <!-- JS Injected --> | |
| </div> | |
| </section> | |
| <!-- 4. EMPTY STATE (Initial) --> | |
| <div id="empty-state" class="state-container"> | |
| <i class="ri-database-2-line state-icon"></i> | |
| <h3 class="mb-4">Pronto para Analisar</h3> | |
| <p style="max-width: 400px; margin: 0 auto 24px;"> | |
| Configure suas credenciais da Shopify acima e clique em "Atualizar Dados" para gerar o relatório de UTM. | |
| </p> | |
| <button id="btn-initial-connect" class="btn btn-primary">Conectar e Buscar</button> | |
| </div> | |
| <!-- 5. LOADING STATE --> | |
| <div id="loading-state" class="state-container hidden"> | |
| <div class="spinner" style="margin: 0 auto 16px;"></div> | |
| <p>Processando pedidos e UTMs...</p> | |
| </div> | |
| <!-- 6. DASHBOARD (Metrics & Table) --> | |
| <div id="dashboard-content" class="hidden animate-fade-in"> | |
| <!-- Metrics Grid --> | |
| <section class="metrics-container"> | |
| <div class="metric-card"> | |
| <span class="metric-label">Total de Pedidos</span> | |
| <span class="metric-value" id="m-total-orders">0</span> | |
| <span class="metric-sub">UTMs Únicas: <span id="m-unique-utms">0</span></span> | |
| </div> | |
| <div class="metric-card"> | |
| <span class="metric-label">Taxa de Conversão</span> | |
| <span class="metric-value" id="m-conversion-rate">0%</span> | |
| <span class="badge badge-success" id="m-conversion-badge">Calculando</span> | |
| </div> | |
| <div class="metric-card"> | |
| <span class="metric-label">Faturamento Bruto</span> | |
| <span class="metric-value" id="m-total-revenue">R$ 0,00</span> | |
| <span class="metric-sub">Todos os status</span> | |
| </div> | |
| <div class="metric-card"> | |
| <span class="metric-label">Faturamento Pago</span> | |
| <span class="metric-value" id="m-paid-revenue" style="color: var(--primary);">R$ 0,00</span> | |
| <span class="metric-sub" id="m-paid-count">0 pedidos pagos</span> | |
| </div> | |
| </section> | |
| <!-- Table Section --> | |
| <section class="card"> | |
| <div class="card-header"> | |
| <div class="card-title"> | |
| <i class="ri-table-2"></i> Detalhamento por Campanha | |
| </div> | |
| </div> | |
| <div class="table-container"> | |
| <table> | |
| <thead> | |
| <tr> | |
| <th onclick="app.sortBy('utm')">Campanha / Conteúdo <i class="ri-arrow-up-down-line"></i></th> | |
| <th class="text-center">Pedidos <i class="ri-arrow-up-down-line opacity-0"></i></th> | |
| <th class="text-center" onclick="app.sortBy('clients')">Clientes <i class="ri-arrow-up-down-line"></i></th> | |
| <th class="text-right" onclick="app.sortBy('total')">Venda Total <i class="ri-arrow-up-down-line"></i></th> | |
| <th class="text-right" onclick="app.sortBy('paid')">Venda Paga <i class="ri-arrow-up-down-line"></i></th> | |
| <th class="text-right" onclick="app.sortBy('rate')">% Pago <i class="ri-arrow-up-down-line"></i></th> | |
| </tr> | |
| </thead> | |
| <tbody id="report-body"> | |
| <!-- Rows injected via JS --> | |
| </tbody> | |
| </table> | |
| </div> | |
| <div id="no-data-msg" class="state-container hidden" style="padding: 20px;"> | |
| <p style="font-size: 0.9rem;">Nenhum dado encontrado para o período selecionado.</p> | |
| </div> | |
| </section> | |
| </div> | |
| </main> | |
| <script> | |
| /** | |
| * UTILITIES | |
| */ | |
| const Utils = { | |
| formatCurrency(value) { | |
| return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(value); | |
| }, | |
| formatDate(isoString) { | |
| return new Date(isoString).toLocaleString('pt-BR'); | |
| }, | |
| showAlert(message, type = 'info') { | |
| const area = document.getElementById('alert-area'); | |
| const alert = document.createElement('div'); | |
| alert.className = `alert alert-${type} animate-fade-in`; | |
| let icon = 'ri-information-line'; | |
| if(type === 'error') icon = 'ri-error-warning-line'; | |
| if(type === 'warning') icon = 'ri-alert-line'; | |
| alert.innerHTML = `<i class="${icon}"></i> <span>${message}</span>`; | |
| area.innerHTML = ''; | |
| area.appendChild(alert); | |
| // Auto dismiss | |
| setTimeout(() => { | |
| alert.style.opacity = '0'; | |
| setTimeout(() => alert.remove(), 300); | |
| }, 5000); | |
| } | |
| }; | |
| /** | |
| * SHOPIFY SERVICE | |
| * Handles API communication | |
| */ | |
| const ShopifyService = { | |
| config: { storeName: '', accessToken: '', corsProxy: '', apiVersion: '2024-01' }, | |
| loadConfig() { | |
| const stored = localStorage.getItem('utm_pro_config'); | |
| if (stored) { | |
| this.config = { ...this.config, ...JSON.parse(stored) }; | |
| return this.config; | |
| } | |
| return null; | |
| }, | |
| saveConfig(newConfig) { | |
| this.config = { ...this.config, ...newConfig }; | |
| localStorage.setItem('utm_pro_config', JSON.stringify(this.config)); | |
| }, | |
| buildUrl(startDate, endDate, pageinfo = '') { | |
| const baseUrl = `https://${this.config.storeName}.myshopify.com/admin/api/${this.config.apiVersion}/orders.json`; | |
| const params = new URLSearchParams({ | |
| status: 'any', | |
| created_at_min: startDate, | |
| created_at_max: endDate, | |
| limit: '250', | |
| fields: 'id,name,total_price,financial_status,processing_method,customer,created_at,landing_site_ref,source_name' // Efficient fields | |
| }); | |
| if (pageinfo) params.append('page_info', pageinfo); | |
| let finalUrl = `${baseUrl}?${params.toString()}`; | |
| if (this.config.corsProxy && this.config.corsProxy.trim() !== '') { | |
| const proxy = this.config.corsProxy.replace(/\/$/, ''); | |
| finalUrl = `${proxy}${encodeURIComponent(finalUrl)}`; | |
| } | |
| return finalUrl; | |
| }, | |
| async fetchAllOrders(startDate, endDate) { | |
| if (!this.config.storeName || !this.config.accessToken) { | |
| throw new Error("Credenciais ausentes."); | |
| } | |
| let orders = []; | |
| let url = this.buildUrl(startDate, endDate); | |
| do { | |
| try { | |
| const response = await fetch(url, { | |
| method: 'GET', | |
| headers: { 'X-Shopify-Access-Token': this.config.accessToken } | |
| }); | |
| if (!response.ok) { | |
| if (response.status === 401) throw new Error("Token inválido (401)."); | |
| if (response.status === 404) throw new Error("Loja não encontrada (404)."); | |
| throw new Error(`Erro na API: ${response.status}`); | |
| } | |
| const data = await response.json(); | |
| if (data.orders) orders = orders.concat(data.orders); | |
| // Pagination | |
| const linkHeader = response.headers.get('Link'); | |
| url = linkHeader ? this.parseLinkHeader(linkHeader, 'next') : null; | |
| } catch (error) { | |
| console.error(error); | |
| if (error.message.includes('Failed to fetch')) { | |
| throw new Error("Erro de CORS. Verifique o Proxy."); | |
| } | |
| throw error; | |
| } | |
| } while (url); | |
| return orders; | |
| }, | |
| parseLinkHeader(header, rel) { | |
| const links = header.split(','); | |
| for (let link of links) { | |
| const parts = link.split(';'); | |
| const url = parts[0].trim().replace(/<|>/g, ''); | |
| if (parts[1].includes(`rel="${rel}"`)) return url; | |
| } | |
| return null; | |
| } | |
| }; | |
| /** | |
| * APP LOGIC | |
| */ | |
| const app = { | |
| state: { | |
| theme: 'dark', | |
| rawData: [], | |
| processedData: [], | |
| snapshot: null, | |
| sortCol: 'paid', | |
| sortAsc: false | |
| }, | |
| init() { | |
| this.cacheDOM(); | |
| this.bindEvents(); | |
| this.loadSettings(); | |
| this.checkTheme(); | |
| }, | |
| cacheDOM() { | |
| this.dom = { | |
| // Config | |
| configCard: document.getElementById('config-card'), | |
| configContent: document.getElementById('config-content'), | |
| toggleConfig: document.getElementById('toggle-config-btn'), | |
| inpStore: document.getElementById('store-name'), | |
| inpToken: document.getElementById('access-token'), | |
| inpProxy: document.getElementById('cors-proxy'), | |
| btnSave: document.getElementById('save-config-btn'), | |
| msgConfig: document.getElementById('config-msg'), | |
| // Filters | |
| dateRange: document.getElementById('date-range'), | |
| customDates: document.getElementById('custom-dates'), | |
| dateStart: document.getElementById('date-start'), | |
| dateEnd: document.getElementById('date-end'), | |
| btnFetch: document.getElementById('btn-fetch'), | |
| btnInitial: document.getElementById('btn-initial-connect'), | |
| btnExport: document.getElementById('btn-export'), | |
| // Views | |
| emptyState: document.getElementById('empty-state'), | |
| loadingState: document.getElementById('loading-state'), | |
| dashboard: document.getElementById('dashboard-content'), | |
| deltaSection: document.getElementById('delta-section'), | |
| deltaContent: document.getElementById('delta-content'), | |
| snapshotTime: document.getElementById('snapshot-time'), | |
| newOrdersCount: document.getElementById('new-orders-count'), | |
| resetSnapshot: document.getElementById('reset-snapshot'), | |
| // Metrics | |
| mTotalOrders: document.getElementById('m-total-orders'), | |
| mUniqueUtms: document.getElementById('m-unique-utms'), | |
| mConvRate: document.getElementById('m-conversion-rate'), | |
| mConvBadge: document.getElementById('m-conversion-badge'), | |
| mTotalRev: document.getElementById('m-total-revenue'), | |
| mPaidRev: document.getElementById('m-paid-revenue'), | |
| mPaidCount: document.getElementById('m-paid-count'), | |
| // Table | |
| tableBody: document.getElementById('report-body'), | |
| noDataMsg: document.getElementById('no-data-msg'), | |
| // Global | |
| themeToggle: document.getElementById('theme-toggle'), | |
| alertArea: document.getElementById('alert-area') | |
| }; | |
| }, | |
| bindEvents() { | |
| // Config | |
| this.dom.toggleConfig.addEventListener('click', () => { | |
| this.dom.configContent.classList.toggle('hidden'); | |
| const icon = this.dom.toggleConfig.querySelector('i'); | |
| icon.className = this.dom.configContent.classList.contains('hidden') | |
| ? 'ri-arrow-down-s-line' | |
| : 'ri-arrow-up-s-line'; | |
| }); | |
| this.dom.btnSave.addEventListener('click', () => this.saveSettings()); | |
| // Data | |
| this.dom.dateRange.addEventListener('change', (e) => { | |
| this.dom.customDates.classList.toggle('hidden', e.target.value !== 'customizado'); | |
| }); | |
| this.dom.btnFetch.addEventListener('click', () => this.fetchData()); | |
| this.dom.btnInitial.addEventListener('click', () => this.fetchData()); | |
| this.dom.btnExport.addEventListener('click', () => this.exportCSV()); | |
| this.dom.resetSnapshot.addEventListener('click', () => { | |
| localStorage.removeItem('utm_pro_snapshot'); | |
| this.state.snapshot = null; | |
| this.dom.deltaSection.classList.add('hidden'); | |
| Utils.showAlert('Base de comparação resetada.', 'info'); | |
| }); | |
| // Theme | |
| this.dom.themeToggle.addEventListener('click', () => this.toggleTheme()); | |
| }, | |
| loadSettings() { | |
| const config = ShopifyService.loadConfig(); | |
| if (config) { | |
| this.dom.inpStore.value = config.storeName || ''; | |
| this.dom.inpToken.value = config.accessToken || ''; | |
| this.dom.inpProxy.value = config.corsProxy || ''; | |
| this.enableControls(); | |
| } | |
| // Load Snapshot | |
| const snap = localStorage.getItem('utm_pro_snapshot'); | |
| if (snap) { | |
| this.state.snapshot = JSON.parse(snap); | |
| } | |
| }, | |
| saveSettings() { | |
| const store = this.dom.inpStore.value.trim(); | |
| const token = this.dom.inpToken.value.trim(); | |
| const proxy = this.dom.inpProxy.value.trim(); | |
| if (!store || !token) { | |
| Utils.showAlert('Preencha Nome da Loja e Token.', 'warning'); | |
| return; | |
| } | |
| ShopifyService.saveConfig({ storeName: store, accessToken: token, corsProxy: proxy }); | |
| this.dom.msgConfig.classList.remove('hidden'); | |
| this.enableControls(); | |
| setTimeout(() => this.dom.msgConfig.classList.add('hidden'), 3000); | |
| }, | |
| enableControls() { | |
| this.dom.btnFetch.disabled = false; | |
| this.dom.btnInitial.disabled = false; | |
| }, | |
| checkTheme() { | |
| const saved = localStorage.getItem('utm_pro_theme') || 'dark'; | |
| document.documentElement.setAttribute('data-theme', saved); | |
| const icon = this.dom.themeToggle.querySelector('i'); | |
| icon.className = saved === 'dark' ? 'ri-sun-line' : 'ri-moon-line'; | |
| }, | |
| toggleTheme() { | |
| const current = document.documentElement.getAttribute('data-theme'); | |
| const next = current === 'dark' ? 'light' : 'dark'; | |
| document.documentElement.setAttribute('data-theme', next); | |
| localStorage.setItem('utm_pro_theme', next); | |
| const icon = this.dom.themeToggle.querySelector('i'); | |
| icon.className = next === 'dark' ? 'ri-sun-line' : 'ri-moon-line'; | |
| }, | |
| getDates() { | |
| const range = this.dom.dateRange.value; | |
| const now = new Date(); | |
| const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); | |
| let start, end = new Date(today.getTime() + 24 * 60 * 60 * 1000 - 1); // End of today | |
| const days = (n) => new Date(today.getTime() - n * 24 * 60 * 60 * 1000); | |
| switch (range) { | |
| case 'hoje': start = today; break; | |
| case 'ontem': start = days(1); end = days(0); break; | |
| case 'ultimos7': start = days(7); break; | |
| case 'ultimos30': start = days(30); break; | |
| case 'customizado': | |
| if(this.dom.dateStart.value && this.dom.dateEnd.value) { | |
| start = new Date(this.dom.dateStart.value); | |
| end = new Date(this.dom.dateEnd.value); | |
| end.setHours(23,59,59,999); | |
| } else { | |
| start = days(7); // Fallback | |
| } | |
| break; | |
| default: start = days(7); | |
| } | |
| return { start: start.toISOString(), end: end.toISOString() }; | |
| }, | |
| async fetchData() { | |
| const config = ShopifyService.loadConfig(); | |
| if (!config || !config.storeName) { | |
| this.dom.configCard.scrollIntoView({ behavior: 'smooth' }); | |
| Utils.showAlert('Configure a API primeiro.', 'warning'); | |
| return; | |
| } | |
| this.setLoading(true); | |
| this.dom.alertArea.innerHTML = ''; | |
| this.dom.deltaSection.classList.add('hidden'); | |
| try { | |
| const { start, end } = this.getDates(); | |
| const orders = await ShopifyService.fetchAllOrders(start, end); | |
| this.state.rawData = orders; | |
| this.processData(orders); | |
| this.checkForNewOrders(orders); | |
| // Save snapshot for next time | |
| this.state.snapshot = { | |
| timestamp: new Date().toISOString(), | |
| orderIds: orders.map(o => o.id) | |
| }; | |
| localStorage.setItem('utm_pro_snapshot', JSON.stringify(this.state.snapshot)); | |
| this.render(); | |
| } catch (err) { | |
| Utils.showAlert(err.message, 'error'); | |
| this.dom.dashboard.classList.add('hidden'); | |
| this.dom.emptyState.classList.remove('hidden'); | |
| } finally { | |
| this.setLoading(false); | |
| } | |
| }, | |
| checkForNewOrders(currentOrders) { | |
| if (!this.state.snapshot) return; | |
| const previousIds = new Set(this |