utm-analytics-pro / script.js
eubottura's picture
Como conectar com a shopify? com o acesso e etc
4bb92f8 verified
/**
* 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 {
email
}
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();
});