1// --- Referral Deeplink Logic --- 2// Intercepts the URL path when the app is NOT installed and redirects to Play Store. 3(function checkReferralDeepLink() { 4 const path = window.location.pathname.replace(/^\/|\/$/g, ''); 5 6 if (/^[A-Z0-9]{4,8}$/.test(path)) { 7 const marketUrl = `market://details?id=com.jetmax&referrer=${path}`; 8 window.location.replace(marketUrl); 9 10 setTimeout(() => { 11 window.location.replace(`https://play.google.com/store/apps/details?id=com.jetmax&referrer=${path}`); 12 }, 500); 13 } 14})(); 15 16document.addEventListener('DOMContentLoaded', () => { 17 // Reveal onload elements 18 const onloadElements = document.querySelectorAll('.hidden-onload'); 19 setTimeout(() => { 20 onloadElements.forEach(el => { 21 el.style.transition = 'opacity 1s ease'; 22 el.style.opacity = '1'; 23 el.classList.remove('hidden-onload'); 24 }); 25 }, 100); 26 27 // Lottie Animation 28 const streetLottieContainer = document.getElementById('lottie-street-bg'); 29 if (streetLottieContainer && typeof lottie !== 'undefined') { 30 lottie.loadAnimation({ 31 container: streetLottieContainer, 32 renderer: 'svg', 33 loop: true, 34 autoplay: true, 35 path: 'assets/street_move.json', 36 rendererSettings: { 37 preserveAspectRatio: 'xMidYMid slice' 38 } 39 }); 40 } 41 42 // Navbar scroll effect 43 const navbar = document.querySelector('.navbar'); 44 window.addEventListener('scroll', () => { 45 if (window.scrollY > 50) { 46 navbar.classList.add('scrolled'); 47 } else { 48 navbar.classList.remove('scrolled'); 49 } 50 }); 51 52 // Intersection Observer for Scroll Animations 53 const observerOptions = { 54 root: null, 55 rootMargin: '0px', 56 threshold: 0.15 57 }; 58 59 const revealObserver = new IntersectionObserver((entries, observer) => { 60 entries.forEach(entry => { 61 if (entry.isIntersecting) { 62 entry.target.classList.add('active'); 63 observer.unobserve(entry.target); 64 } 65 }); 66 }, observerOptions); 67 68 const revealElements = document.querySelectorAll('.reveal'); 69 revealElements.forEach(el => revealObserver.observe(el)); 70 71 // Carousel Auto-Play and Swipe Support 72 const carouselCards = document.querySelectorAll('.gigu-card'); 73 const container = document.querySelector('.carousel-container'); 74 let currentCardIndex = 0; 75 76 if (carouselCards.length > 0 && container) { 77 function goToNext() { 78 container.classList.remove('reverse-slide'); 79 carouselCards[currentCardIndex].classList.remove('active'); 80 carouselCards[currentCardIndex].classList.add('prev'); 81 82 const prevIndex = currentCardIndex; 83 setTimeout(() => carouselCards[prevIndex].classList.remove('prev'), 600); 84 85 currentCardIndex = (currentCardIndex + 1) % carouselCards.length; 86 carouselCards[currentCardIndex].classList.add('active'); 87 } 88 89 function goToPrev() { 90 container.classList.add('reverse-slide'); 91 carouselCards[currentCardIndex].classList.remove('active'); 92 carouselCards[currentCardIndex].classList.add('prev'); 93 94 const prevIndex = currentCardIndex; 95 setTimeout(() => carouselCards[prevIndex].classList.remove('prev'), 600); 96 97 currentCardIndex = (currentCardIndex - 1 + carouselCards.length) % carouselCards.length; 98 carouselCards[currentCardIndex].classList.add('active'); 99 } 100 101 let slideInterval = setInterval(goToNext, 3500); 102 103 // Touch event handlers 104 let touchstartX = 0; 105 let touchendX = 0; 106 107 container.addEventListener('touchstart', e => { 108 touchstartX = e.changedTouches[0].screenX; 109 }, {passive: true}); 110 111 container.addEventListener('touchend', e => { 112 touchendX = e.changedTouches[0].screenX; 113 handleSwipe(); 114 }, {passive: true}); 115 116 function handleSwipe() { 117 const swipeDistance = touchendX - touchstartX; 118 if (swipeDistance < -40) { // Swipe Left (Next) 119 clearInterval(slideInterval); 120 goToNext(); 121 slideInterval = setInterval(goToNext, 3500); 122 } 123 if (swipeDistance > 40) { // Swipe Right (Prev) 124 clearInterval(slideInterval); 125 goToPrev(); 126 slideInterval = setInterval(goToNext, 3500); 127 } 128 } 129 } 130}); 131 132// Fullscreen Video Player Logic 133window.openVideoPlayer = function(url) { 134 const modal = document.getElementById('video-modal'); 135 const video = document.getElementById('demo-video'); 136 if (modal && video) { 137 video.src = url; 138 modal.style.display = 'flex'; 139 // Auto-play the video 140 video.play().catch(e => console.log('Autoplay blocked:', e)); 141 } 142}; 143 144window.closeVideoPlayer = function() { 145 const modal = document.getElementById('video-modal'); 146 const video = document.getElementById('demo-video'); 147 if (modal && video) { 148 video.pause(); 149 video.currentTime = 0; 150 modal.style.display = 'none'; 151 video.src = ''; 152 } 153}; 154 155// --- Admin Console Logic --- 156 157const ANALYTICS_API_BASE = "https://jetmax-prod.up.railway.app"; 158let adminToken = localStorage.getItem('adminToken') 159 || sessionStorage.getItem('adminToken') 160 || null; // JWT token; localStorage so it survives restarts until JWT expires (72h) 161if (adminToken && !localStorage.getItem('adminToken')) { 162 // Migrate legacy sessionStorage token so a soft refresh of the page doesn't drop it 163 localStorage.setItem('adminToken', adminToken); 164 sessionStorage.removeItem('adminToken'); 165} 166 167const adminModal = document.getElementById('admin-modal'); 168const closeModalBtn = document.getElementById('close-admin-modal'); 169const loginBtn = document.getElementById('admin-login-btn');
170const passwordInput = document.getElementById('admin-password'); 171const errorText = document.getElementById('admin-error'); 172 173const loginView = document.getElementById('admin-login-view'); 174const dashboardLayout = document.getElementById('admin-dashboard-layout'); 175const modalContent = document.querySelector('.admin-modal-content'); 176 177const fcmForm = document.getElementById('fcm-form'); 178const fcmBtnText = document.getElementById('fcm-btn-text'); 179const fcmSpinner = document.getElementById('fcm-spinner'); 180const fcmStatus = document.getElementById('fcm-status'); 181 182let analyticsLoaded = false; 183let analyticsAutoRefreshInterval = null; 184let analyticsCountry = 'BR'; // BR (default) | US â drives the dashboard country filter 185 186function setAdminModalOpen(isOpen) { 187 if (!adminModal) return; 188 adminModal.classList.toggle('active', isOpen); 189 document.body.classList.toggle('admin-console-open', isOpen); 190 if (!isOpen) { 191 adminModal.classList.remove('admin-dashboard-open'); 192 } 193} 194 195function setAdminDashboardMode(isDashboard) { 196 if (adminModal) { 197 adminModal.classList.toggle('admin-dashboard-open', isDashboard); 198 } 199 if (modalContent) { 200 modalContent.classList.toggle('admin-dashboard-active', isDashboard); 201 } 202} 203 204function startAnalyticsAutoRefresh() { 205 stopAnalyticsAutoRefresh(); 206 analyticsAutoRefreshInterval = setInterval(() => { 207 const analyticsPage = document.getElementById('admin-page-analytics'); 208 if (analyticsPage && analyticsPage.classList.contains('active')) { 209 fetchAnalytics(); 210 } 211 }, 300000); 212} 213 214function stopAnalyticsAutoRefresh() { 215 if (analyticsAutoRefreshInterval) { 216 clearInterval(analyticsAutoRefreshInterval); 217 analyticsAutoRefreshInterval = null; 218 } 219} 220 221// Functions 222function openModal() { 223 setAdminModalOpen(true); 224 setAdminDashboardMode(false); 225 passwordInput.value = ''; 226 errorText.style.display = 'none'; 227 loginView.style.display = 'block'; 228 dashboardLayout.style.display = 'none'; 229} 230 231function closeModal() { 232 setAdminModalOpen(false); 233 stopAnalyticsAutoRefresh(); 234} 235 236function showDashboard() { 237 loginView.style.display = 'none'; 238 dashboardLayout.style.display = 'flex'; 239 setAdminDashboardMode(true); 240 applyAnalyticsCountryVisibility(); 241 242 if (!analyticsLoaded) { 243 fetchAnalytics(); 244 } 245 startAnalyticsAutoRefresh(); 246} 247 248async function verifyAdmin() { 249 const pwd = passwordInput.value; 250 if (!pwd) { 251 showError("Informe a senha."); 252 return; 253 } 254 255 try { 256 loginBtn.innerHTML = "Verificando..."; 257 loginBtn.style.opacity = '0.7'; 258 loginBtn.disabled = true; 259 260 const res = await fetch(`${ANALYTICS_API_BASE}/admin/login`, { 261 method: 'POST', 262 headers: { 'Content-Type': 'application/json' }, 263 body: JSON.stringify({ password: pwd }) 264 }); 265 266 const data = await res.json(); 267 268 if (res.ok && data.success && data.token) { 269 adminToken = data.token; 270 localStorage.setItem('adminToken', adminToken); 271 showDashboard(); 272 } else { 273 showError(data.error || "Senha incorreta."); 274 } 275 } catch (e) { 276 showError("Erro ao conectar ao servidor. Tente novamente."); 277 } finally { 278 loginBtn.innerHTML = "Acessar Console"; 279 loginBtn.style.opacity = '1'; 280 loginBtn.disabled = false; 281 } 282} 283 284function showError(msg) { 285 errorText.textContent = msg; 286 errorText.style.display = 'block'; 287} 288 289function handleTokenExpired() { 290 adminToken = null; 291 localStorage.removeItem('adminToken'); 292 // Clear legacy sessionStorage token if present 293 sessionStorage.removeItem('adminToken'); 294 analyticsLoaded = false; 295 stopAnalyticsAutoRefresh(); 296 loginView.style.display = 'block'; 297 dashboardLayout.style.display = 'none'; 298 setAdminDashboardMode(false); 299 showError("Sessão expirada. Faça login novamente."); 300} 301 302// --- Sidebar Navigation --- 303document.querySelectorAll('.sidebar-item').forEach(item => { 304 item.addEventListener('click', () => { 305 const targetPage = item.dataset.page; 306 307 // Update sidebar active state 308 document.querySelectorAll('.sidebar-item').forEach(i => i.classList.remove('active')); 309 item.classList.add('active'); 310 311 // Switch pages 312 document.querySelectorAll('.admin-page').forEach(page => page.classList.remove('active')); 313 const pageEl = document.getElementById(`admin-page-${targetPage}`); 314 if (pageEl) pageEl.classList.add('active'); 315 316 // On phones, always land on the conversation list when (re)opening Chat. 317 if (targetPage === 'chat') pageEl?.classList.remove('thread-open'); 318 }); 319}); 320 321// --- Sidebar Collapse/Expand --- 322const sidebarToggleBtn = document.getElementById('sidebar-toggle-btn'); 323const adminSidebar = document.getElementById('admin-sidebar'); 324if (sidebarToggleBtn && adminSidebar) { 325 sidebarToggleBtn.addEventListener('click', () => { 326 adminSidebar.classList.toggle('collapsed'); 327 const isCollapsed = adminSidebar.classList.contains('collapsed'); 328 sidebarToggleBtn.title = isCollapsed ? 'Expand menu' : 'Collapse menu'; 329 }); 330} 331 332// --- Analytics API --- 333function formatNumber(num) { 334 if (num === null || num === undefined) return 'â';
335 return num.toLocaleString('pt-BR'); 336} 337 338/** Whole miles for US Mileage Tracking cards: "1.234", or "1.1 M" at/above 1,000,000. */ 339function formatMiles(num) { 340 if (num === null || num === undefined || !Number.isFinite(Number(num))) return 'â'; 341 const n = Math.round(Number(num)); 342 if (Math.abs(n) >= 1_000_000) { 343 return `${(n / 1_000_000).toFixed(1)} M`; 344 } 345 return formatNumber(n); 346} 347 348/** Signed count for trend cards: "+148", "0", "-12". */ 349function formatSignedNumber(num) { 350 if (!Number.isFinite(num)) return 'â'; 351 const body = formatNumber(Math.abs(num)); 352 if (num > 0) return '+' + body; 353 if (num < 0) return '-' + body; 354 return body; 355} 356 357/** Card / balloon format: "10 (7 | 2 | 1)". */ 358function formatActiveSubsValue(total, n30, n90, nYear) { 359 if (!Number.isFinite(Number(total))) return 'â'; 360 const t = formatNumber(total); 361 const a = Number.isFinite(Number(n30)) ? formatNumber(n30) : 'â'; 362 const b = Number.isFinite(Number(n90)) ? formatNumber(n90) : 'â'; 363 const c = Number.isFinite(Number(nYear)) ? formatNumber(nYear) : 'â'; 364 return `${t} (${a} | ${b} | ${c})`; 365} 366 367const INSTALLS_4_18D_MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; 368/** Live-store US cohort (AD09, plus AD10 since 8/set/2026); matches server `US_DASHBOARD_VOLUME_EPOCH_UTC_SQL`. */ 369const US_DASHBOARD_VOLUME_EPOCH = new Date(2026, 7, 30); 370 371/** Calendar range for installs_4_18d: days 5â18 ago â "21/May-3/Jun". */ 372function formatInstalls4To18dDateRange(now = new Date()) { 373 let start = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 18); 374 const end = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 5); 375 if (analyticsCountry === 'US') { 376 if (end < US_DASHBOARD_VOLUME_EPOCH) return 'desde 30/Aug'; 377 if (start < US_DASHBOARD_VOLUME_EPOCH) start = new Date(US_DASHBOARD_VOLUME_EPOCH); 378 } 379 const fmt = (d) => `${d.getDate()}/${INSTALLS_4_18D_MONTHS[d.getMonth()]}`; 380 return `${fmt(start)}-${fmt(end)}`; 381} 382 383// --- Entitlement-goal card (BR "Meta 5K" / US "Meta 100") --- 384// `n` is renewing Google Play subscribers in the selected market, plus BR PIX 385// 30/90 recharges still inside their purchase window (lifetime is out). US has 386// no PIX, so Meta 100 equals the ACTIVE SUBS card. BR measures progress across 387// the last 1.000 of the 5.000 goal because a bar from zero would sit at 94% 388// and barely move. 389const META_GOAL_BY_COUNTRY = { 390 BR: { 391 label: 'Meta 5K', 392 target: 5000, 393 floor: 4000, 394 tooltip: 'Progresso em direção à meta de 5.000 usuários do Brasil: assinatura Google Play renovando agora e/ou recarga PIX de 30 dias comprada nos últimos 30 dias, ou recarga de 90 dias comprada nos últimos 90 dias. VitalÃcio não entra. Canceladas (renovação desligada), em carência, em atraso ou expiradas não entram. Cada aparelho conta uma vez. Não inclui dispositivos de desenvolvimento nem de teste da Play Store. O percentual desconsidera os primeiros 4.000 e mede o progresso até 5.000 (ex.: 4.028 = 2,8%). Clique no card para ver o gráfico de n nos últimos 7 dias (pontos a cada 6h).', 395 }, 396 US: { 397 label: 'Meta 100', 398 target: 100, 399 floor: 0, 400 tooltip: 'Progresso em direção à meta de 100 usuários dos Estados Unidos com assinatura Google Play renovando agora â o mesmo conjunto do card ACTIVE SUBS (PIX não existe no mercado americano). Canceladas (renovação desligada, mesmo com acesso até o fim do perÃodo pago) não entram. Só entram contas criadas a partir de 30/ago/2026 (cohort dos EUA com a listagem da Play Store americana no ar: AD09 e, desde 8/set/2026, AD10). Cada aparelho conta uma vez. Não inclui dispositivos de desenvolvimento nem de teste da Play Store. O percentual conta desde zero até 100 (ex.: 37 = 37,0%). Clique no card para ver o gráfico de n nos últimos 7 dias (pontos a cada 6h).', 401 }, 402}; 403 404function metaGoalConfig() { 405 return META_GOAL_BY_COUNTRY[analyticsCountry] || META_GOAL_BY_COUNTRY.BR; 406} 407 408/** Last live Meta 5K / Meta 100 `n` from admin_analytics. Used so the 7-day 409 * graph can drop a definition cliff before the next 6h snapshot is written. */ 410let lastUniquePaidUsersTotal = null; 411 412/** Progress toward the market's target, ignoring everything below its floor. */ 413function formatMetaGoalPct(activePayingUsers, cfg) { 414 const span = cfg.target - cfg.floor; 415 if (!Number.isFinite(activePayingUsers) || span <= 0) return 'â'; 416 return ((Math.max(0, activePayingUsers - cfg.floor) / span) * 100).toFixed(1) + '%'; 417} 418
419/** Retitles the goal card and its history popup for the selected market. */ 420function applyMetaGoalCountry() { 421 const cfg = metaGoalConfig(); 422 const labelEl = document.getElementById('stat-purchases-goal-label'); 423 if (labelEl) labelEl.textContent = cfg.label; 424 const helpEl = document.getElementById('meta4k-help-btn'); 425 if (helpEl) helpEl.setAttribute('data-tooltip', cfg.tooltip); 426 const titleEl = document.getElementById('meta4k-chart-title'); 427 if (titleEl) titleEl.textContent = `${cfg.label} â n nos últimos 7 dias`; 428 const dialogEl = document.getElementById('meta4k-chart-dialog'); 429 if (dialogEl) dialogEl.setAttribute('aria-label', `Histórico ${cfg.label}`); 430} 431 432/** Install â first-payment conversion: (numerator / denominator) à 100, pt-BR %. */ 433function formatConversionPct(numerator, denominator) { 434 const num = Number(numerator); 435 const den = Number(denominator); 436 if (!Number.isFinite(num) || !Number.isFinite(den) || den <= 0) return null; 437 const pct = (num / den) * 100; 438 return pct.toLocaleString('pt-BR', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + '%'; 439} 440 441const MAX_FETCH_RETRIES = 3; 442const RETRY_DELAY_MS = 2000; 443const ANALYTICS_FETCH_TIMEOUT_MS = 45_000; 444 445async function fetchWithRetry(url, options, retries = MAX_FETCH_RETRIES) { 446 for (let attempt = 1; attempt <= retries; attempt++) { 447 try { 448 const res = await fetch(url, options); 449 return res; // Return even non-2xx responses so caller can handle 401 etc. 450 } catch (e) { 451 console.warn(`Fetch attempt ${attempt}/${retries} failed:`, e.message); 452 if (attempt === retries) throw e; 453 await new Promise(r => setTimeout(r, RETRY_DELAY_MS * attempt)); 454 } 455 } 456} 457 458async function fetchAnalytics() { 459 const refreshBtn = document.getElementById('analytics-refresh-btn'); 460 const adminAnalyticsPage = document.getElementById('admin-page-analytics'); 461 const loadingOverlay = document.getElementById('admin-analytics-loading-overlay'); 462 463 if (refreshBtn) refreshBtn.classList.add('spinning'); 464 if (adminAnalyticsPage) adminAnalyticsPage.classList.add('is-reloading'); 465 if (loadingOverlay) { 466 loadingOverlay.hidden = false; 467 loadingOverlay.setAttribute('aria-hidden', 'false'); 468 } 469 470 const allValues = document.querySelectorAll('#admin-page-analytics .analytics-value'); 471 472 try { 473 const res = await fetchWithRetry(`${ANALYTICS_API_BASE}/admin_analytics?country=${encodeURIComponent(analyticsCountry)}`, { 474 headers: { 'Authorization': `Bearer ${adminToken}` }, 475 signal: AbortSignal.timeout(ANALYTICS_FETCH_TIMEOUT_MS), 476 }); 477 if (res.status === 401) { handleTokenExpired(); return; } 478 if (!res.ok) throw new Error('Failed to fetch analytics'); 479 const data = await res.json(); 480 481 // Users 482 const installs24hEl = document.getElementById('stat-installs-24h'); 483 if (installs24hEl) installs24hEl.textContent = formatNumber(data.installs_24h); 484 const installs24hNonAdEl = document.getElementById('stat-installs-24h-non-ad'); 485 if (installs24hNonAdEl) { 486 const installs24To48h = Number(data.installs_24_48h); 487 installs24hNonAdEl.textContent = Number.isFinite(installs24To48h) 488 ? `24â48h: ${formatNumber(installs24To48h)}` 489 : '24â48h: â'; 490 } 491 const installs14dEl = document.getElementById('stat-installs-14d'); 492 if (installs14dEl) installs14dEl.textContent = formatNumber(data.installs_14d); 493 const installs14dPrevEl = document.getElementById('stat-installs-14d-prev'); 494 if (installs14dPrevEl) installs14dPrevEl.textContent = `15â28d anteriores: ${formatNumber(data.installs_14d_prev)}`; 495 const installs4To18dEl = document.getElementById('stat-installs-4-18d'); 496 if (installs4To18dEl) installs4To18dEl.textContent = formatNumber(data.installs_4_18d); 497 const installs4To18dRangeEl = document.getElementById('stat-installs-4-18d-range'); 498 if (installs4To18dRangeEl) installs4To18dRangeEl.textContent = formatInstalls4To18dDateRange(); 499 const usersNonFree24hEl = document.getElementById('stat-users-non-free-24h'); 500 if (usersNonFree24hEl) usersNonFree24hEl.textContent = formatNumber(data.users_non_free_24h); 501 document.getElementById('stat-users-total').textContent = formatNumber(data.users_total); 502 document.getElementById('stat-users-expiring').textContent = formatNumber(data.users_non_free_expiring_24h); 503 document.getElementById('stat-users-no99').textContent = formatNumber(data.users_no_99_app); 504 document.getElementById('stat-users-competitor').textContent = formatNumber(data.users_has_competitor_app); 505 506 const elReferred = document.getElementById('stat-users-referred'); 507 if (elReferred) elReferred.textContent = formatNumber(data.users_referred); 508 509 // Subscriptions 510 const totalSubsEl = document.getElementById('stat-total-subs'); 511 const totalSubsProEl = document.getElementById('stat-total-subs-pro'); 512 const totalSubsMileageProEl = document.getElementById('stat-total-subs-mileage-pro'); 513 if (totalSubsEl) { 514 const total = Number(data.total_subs); 515 const n30 = Number(data.total_subs_30); 516 const n90 = Number(data.total_subs_90); 517 const nYear = Number(data.total_subs_year); 518 totalSubsEl.textContent = formatActiveSubsValue(total, n30, n90, nYear); 519 } 520 if (totalSubsProEl) { 521 totalSubsProEl.textContent = `Pro ${formatActiveSubsValue( 522 data.total_subs_pro, 523 data.total_subs_pro_30, 524 data.total_subs_pro_90, 525 data.total_subs_pro_year 526 )}`; 527 } 528 if (totalSubsMileageProEl) { 529 totalSubsMileageProEl.textContent = `Mileage Pro ${formatActiveSubsValue( 530 data.total_subs_mileage_pro, 531 data.total_subs_mileage_pro_30, 532 data.total_subs_mileage_pro_90, 533 data.total_subs_mileage_pro_year 534 )}`; 535 } 536 const pix30 = Number(data.pix_recharge_30_approved) || 0; 537 const pix90 = Number(data.pix_recharge_90_approved) || 0; 538 const pixLifetime = Number(data.pix_lifetime_approved) || 0; 539 const pixBreakdownEl = document.getElementById('stat-pix-plan-breakdown'); 540 if (pixBreakdownEl) { 541 pixBreakdownEl.textContent = `${formatNumber(pix30)} | ${formatNumber(pix90)} | ${formatNumber(pixLifetime)}`; 542 } 543 const paidUsers14dEl = document.getElementById('stat-paid-users-14d'); 544 if (paidUsers14dEl) paidUsers14dEl.textContent = formatNumber(data.paid_users_336h); 545 const paidUsers14dPrevEl = document.getElementById('stat-paid-users-14d-prev'); 546 if (paidUsers14dPrevEl) paidUsers14dPrevEl.textContent = `15â28d anteriores: ${formatNumber(data.paid_users_336h_prev)}`; 547 const conversion14dEl = document.getElementById('stat-conversion-14d'); 548 const conversionSplitEl = document.getElementById('stat-conversion-split'); 549 const conversion14dPrevEl = document.getElementById('stat-conversion-14d-prev'); 550 const conversion4To18dEl = document.getElementById('stat-conversion-4-18d'); 551 const conversionCohort = formatConversionPct(data.cohort_converted_21_35d, data.cohort_installs_21_35d); 552 const conversionCohortPrev = formatConversionPct(data.cohort_converted_35_49d, data.cohort_installs_35_49d); 553 if (conversion14dEl) conversion14dEl.textContent = conversionCohort ?? 'â'; 554 if (conversionSplitEl) { 555 const orgPct = formatConversionPct(data.cohort_converted_organic_21_35d, data.cohort_installs_organic_21_35d); 556 const adPct = formatConversionPct(data.cohort_converted_ad_21_35d, data.cohort_installs_ad_21_35d); 557 const orgInstalls = Number(data.cohort_installs_organic_21_35d) || 0; 558 const adInstalls = Number(data.cohort_installs_ad_21_35d) || 0; 559 if (orgPct != null || adPct != null) { 560 conversionSplitEl.textContent = `Org ${orgPct ?? 'â'} · Ads ${adPct ?? 'â'}`; 561 conversionSplitEl.setAttribute( 562 'title', 563 `Conversão por canal na janela 21â35d. ` + 564 `Orgânico: ${orgPct ?? 'â'} de ${formatNumber(orgInstalls)}
564 instalações (PLAY, indicações, sem referrer). ` + 565 `Ads (tráfego pago, referrer começando com 'AD'): ${adPct ?? 'â'} de ${formatNumber(adInstalls)} instalações.` 566 ); 567 } else { 568 conversionSplitEl.textContent = 'â'; 569 } 570 } 571 if (conversion14dPrevEl) { 572 conversion14dPrevEl.textContent = conversionCohortPrev != null 573 ? `35â49d anteriores: ${conversionCohortPrev}` 574 : 'â'; 575 } 576 if (conversion4To18dEl) { 577 conversion4To18dEl.textContent = `Instalações 21â35d: ${formatNumber(data.cohort_installs_21_35d)}`; 578 } 579 applyMetaGoalCountry(); 580 const goalEl = document.getElementById('stat-purchases-goal-pct'); 581 const goalCountEl = document.getElementById('stat-purchases-goal-count'); 582 const goalCount6hEl = document.getElementById('stat-purchases-goal-count-6h'); 583 const activePayingUsers = Number(data.unique_paid_users_total); 584 lastUniquePaidUsersTotal = Number.isFinite(activePayingUsers) ? activePayingUsers : null; 585 if (goalEl) { 586 goalEl.textContent = formatMetaGoalPct(activePayingUsers, metaGoalConfig()); 587 } 588 if (goalCountEl) { 589 goalCountEl.textContent = Number.isFinite(activePayingUsers) 590 ? `n = ${formatNumber(activePayingUsers)}` 591 : 'â'; 592 } 593 if (goalCount6hEl) { 594 const activePayingUsers6h = data.unique_paid_users_total_6h; 595 goalCount6hEl.textContent = activePayingUsers6h != null && Number.isFinite(Number(activePayingUsers6h)) 596 ? `n(6h) = ${formatNumber(activePayingUsers6h)}` 597 : 'â'; 598 } 599 const increased7dEl = document.getElementById('stat-increased-7d'); 600 const increased7dPrevEl = document.getElementById('stat-increased-7d-prev'); 601 const increased7dPriorEl = document.getElementById('stat-increased-7d-prior'); 602 const increased7dCard = document.getElementById('increased-7d-card'); 603 const activePayingUsers7d = data.unique_paid_users_total_7d; 604 const activePayingUsers14d = data.unique_paid_users_total_14d; 605 const graphLatest = data.unique_paid_users_graph_latest; 606 const has7d = activePayingUsers7d != null && Number.isFinite(Number(activePayingUsers7d)); 607 const has14d = activePayingUsers14d != null && Number.isFinite(Number(activePayingUsers14d)); 608 const hasGraphLatest = graphLatest != null && Number.isFinite(Number(graphLatest)); 609 // Same firstâlast points as the Meta 5K / Meta 100 7-day graph. Live n 610 // is never subtracted here: mixing it with last week's snapshots is what 611 // produced â550 after the PIX/lifetime definition change. 612 const increase7d = hasGraphLatest && has7d 613 ? Number(graphLatest) - Number(activePayingUsers7d) 614 : null; 615 const increase7dPrior = has7d && has14d 616 ? Number(activePayingUsers7d) - Number(activePayingUsers14d) 617 : null; 618 if (increased7dEl) { 619 increased7dEl.textContent = increase7d != null ? formatSignedNumber(increase7d) : 'â'; 620 } 621 if (increased7dPrevEl) { 622 increased7dPrevEl.textContent = has7d 623 ? `n(7d) = ${formatNumber(Number(activePayingUsers7d))}` 624 : 'â'; 625 } 626 if (increased7dPriorEl) { 627 increased7dPriorEl.textContent = increase7dPrior != null 628 ? `7d anteriores: ${formatSignedNumber(increase7dPrior)}` 629 : 'â'; 630 } 631 if (increased7dCard) { 632 increased7dCard.classList.toggle('delta-down', increase7d != null && increase7d < 0); 633 } 634 635 // Pings 636 document.getElementById('stat-pings-avg').textContent = data.pings_avg_per_min_7d?.toFixed(2) ?? 'â'; 637 const pingsAvgPrevEl = document.getElementById('stat-pings-avg-prev'); 638 if (pingsAvgPrevEl) pingsAvgPrevEl.textContent = `7d anteriores: ${data.pings_avg_per_min_7d_prev?.toFixed(2) ?? 'â'}`; 639 document.getElementById('stat-pings-1min').textContent = formatNumber(data.pings_1min);
640 // Server sums rows-in-table + deleted-by-retention into `pings_total_ever`. 641 // Fall back to client-side sum if the server is still on the older payload. 642 const everRaw = Number(data.pings_total_ever); 643 let totalPings; 644 if (Number.isFinite(everRaw) && everRaw > 0) { 645 totalPings = everRaw; 646 } else { 647 const allRows = Number(data.pings_total_all_rows); 648 const filtered = Number(data.pings_total); 649 const inTable = Number.isFinite(allRows) && allRows >= 0 ? allRows : (Number.isFinite(filtered) ? filtered : 0); 650 const deletedRaw = Number(data.pings_deleted_total); 651 const deleted = Number.isFinite(deletedRaw) && deletedRaw >= 0 ? deletedRaw : 0; 652 totalPings = inTable + deleted; 653 } 654 document.getElementById('stat-pings-total').textContent = formatNumber(totalPings); 655 const trackedTotalEl = document.getElementById('stat-tracked-drives-total'); 656 if (trackedTotalEl) trackedTotalEl.textContent = formatNumber(data.tracked_drives_total); 657 const tracked7dEl = document.getElementById('stat-tracked-drives-7d'); 658 if (tracked7dEl) tracked7dEl.textContent = formatNumber(data.tracked_drives_7d); 659 const tracked7dPrevEl = document.getElementById('stat-tracked-drives-7d-prev'); 660 if (tracked7dPrevEl) tracked7dPrevEl.textContent = `8â14d anteriores: ${formatNumber(data.tracked_drives_7d_prev)}`; 661 const trackedAvgEl = document.getElementById('stat-tracked-drives-avg'); 662 if (trackedAvgEl) { 663 const avg = Number(data.tracked_drives_avg_per_user); 664 trackedAvgEl.textContent = Number.isFinite(avg) ? avg.toFixed(1) : 'â'; 665 } 666 const trackedMilesTotalEl = document.getElementById('stat-tracked-miles-total'); 667 if (trackedMilesTotalEl) trackedMilesTotalEl.textContent = formatMiles(data.tracked_miles_total); 668 const trackedMilesTotalPrevEl = document.getElementById('stat-tracked-miles-total-prev'); 669 if (trackedMilesTotalPrevEl) trackedMilesTotalPrevEl.textContent = `7d atrás: ${formatMiles(data.tracked_miles_total_7d_ago)}`; 670 const trackedMiles7dEl = document.getElementById('stat-tracked-miles-7d'); 671 if (trackedMiles7dEl) trackedMiles7dEl.textContent = formatMiles(data.tracked_miles_7d); 672 const trackedMiles7dPrevEl = document.getElementById('stat-tracked-miles-7d-prev'); 673 if (trackedMiles7dPrevEl) trackedMiles7dPrevEl.textContent = `8â14d anteriores: ${formatMiles(data.tracked_miles_7d_prev)}`; 674 const expired30El = document.getElementById('stat-expired-30-users'); 675 if (expired30El) expired30El.textContent = formatNumber(data.expired_30_users_7d); 676 const past30El = document.getElementById('stat-past-30-drives'); 677 if (past30El) past30El.textContent = `Past 30 = ${formatNumber(data.past_30_drives_7d)}`; 678 document.getElementById('stat-detection-rate').textContent = data.ping_detection_rate_24h != null ? data.ping_detection_rate_24h.toFixed(2) + '%' : 'â'; 679 const detectionDetailEl = document.getElementById('stat-detection-rate-detail'); 680 if (detectionDetailEl) { 681 const retryResolved = Number(data.ping_detection_retry_resolved_24h); 682 if (Number.isFinite(retryResolved) && retryResolved > 0) { 683 detectionDetailEl.textContent = `${formatNumber(retryResolved)} OCR retries recovered`; 684 } else { 685 detectionDetailEl.textContent = ''; 686 } 687 } 688 689 const aAddr = Number(data.address_miss_events_count); 690 const bAddr = Number(data.ping_events_both_exact_addresses); 691 const addrEl = document.getElementById('stat-address-match-metric'); 692 if (addrEl) { 693 if (Number.isFinite(aAddr) && Number.isFinite(bAddr) && aAddr + bAddr > 0) { 694 addrEl.textContent = (100 * bAddr / (aAddr + bAddr)).toFixed(2) + '%'; 695 } else { 696 addrEl.textContent = 'â'; 697 } 698 } 699 700 // Hexagon votes 701 document.getElementById('stat-votes-total').textContent = formatNumber(data.votes_total); 702 document.getElementById('stat-consolidated').textContent = formatNumber(data.consolidated_hexagons); 703 704 // RodÃzio MIAN classifier health vs. Google Geocoding (3 ping-count windows: 705 // last 400 | 401â800 | 801â1200 by verified_rodizio_at, newest on the left). 706 // - Match line : of pings the app decided on BOTH endpoints, % matching Google. 707 // - Coverage line: of all audited pings, % the app decided on both endpoints â 708 // rises as the OTA address/CEP lists fill in. 709 // - n line : audited pings per window (coverage denominator). 710 const rodPctEl = document.getElementById('stat-rodizio-match-pct'); 711 const rodCovEl = document.getElementById('stat-rodizio-coverage-pct'); 712 const rodSamplesEl = document.getElementById('stat-rodizio-match-samples'); 713 if (rodPctEl) { 714 const fmtPct = (pct, n) => (Number.isFinite(n) && n > 0 && Number.isFinite(pct)) 715 ? pct.toFixed(1) + '%' 716 : 'â'; 717 const p0 = Number(data.rodizio_match_pct_0_24h); 718 const p1 = Number(data.rodizio_match_pct_24_48h); 719 const p2 = Number(data.rodizio_match_pct_48_72h); 720 const c0 = Number(data.rodizio_coverage_pct_0_24h); 721 const c1 = Number(data.rodizio_coverage_pct_24_48h); 722 const c2 = Number(data.rodizio_coverage_pct_48_72h); 723 const n0 = Number(data.rodizio_match_samples_0_24h); 724 const n1 = Number(data.rodizio_match_samples_24_48h); 725 const n2 = Number(data.rodizio_match_samples_48_72h); 726 rodPctEl.textContent = `${fmtPct(p0, n0)} | ${fmtPct(p1, n1)} | ${fmtPct(p2, n2)}`; 727 if (rodCovEl) { 728 rodCovEl.textContent = `cob. ${fmtPct(c0, n0)} | ${fmtPct(c1, n1)} | ${fmtPct(c2, n2)}`; 729 } 730 if (rodSamplesEl) { 731 const fmtN = (n) => Number.isFinite(n) ? formatNumber(n) : 'â'; 732 rodSamplesEl.textContent = `n = ${fmtN(n0)} | ${fmtN(n1)} | ${fmtN(n2)}`; 733 } 734 } 735 736 // Errors 737 document.getElementById('stat-errors-total').textContent = formatNumber(data.error_logs_total); 738 739 const cpActiveUsers = Number(data.cp_active_users_24h); 740 const sgAvgEl = document.getElementById('stat-sg-per-user'); 741 const sgDetailEl = document.getElementById('stat-sg-per-user-detail'); 742 const sgAvg = Number(data.avg_sg_per_user_24h); 743 if (sgAvgEl) { 744 sgAvgEl.textContent = (cpActiveUsers > 0 && Number.isFinite(sgAvg)) ? sgAvg.toFixed(2) : 'â'; 745 } 746 if (sgDetailEl) { 747 sgDetailEl.textContent = (cpActiveUsers > 0) 748 ? `${formatNumber(data.sg_events_24h)} SG · ${formatNumber(cpActiveUsers)} users` 749 : ''; 750 } 751 const lmwAvgEl = document.getElementById('stat-lmw-per-user'); 752 const lmwDetailEl = document.getElementById('stat-lmw-per-user-detail'); 753 const lmwAvg = Number(data.avg_lmw_per_user_24h); 754 if (lmwAvgEl) { 755 lmwAvgEl.textContent = (cpActiveUsers > 0 && Number.isFinite(lmwAvg)) ? lmwAvg.toFixed(2) : 'â'; 756 } 757 if (lmwDetailEl) { 758 lmwDetailEl.textContent = (cpActiveUsers > 0) 759 ? `${formatNumber(data.lmw_events_24h)} LMW · ${formatNumber(cpActiveUsers)} users` 760 : ''; 761 } 762 763 analyticsLoaded = true; 764 765 // The CPI 7d card is sourced from a separate lazy endpoint (Meta daily spend ÷ 766 // country-scoped installs). Fire-and-forget so it never blocks the main grid. 767 fetchCpi7d(); 768 769 // Restore opacity and clear any leftover error color
770 allValues.forEach(el => { 771 el.style.opacity = '1'; 772 el.style.color = ''; // Remove inline color so CSS classes apply correctly 773 }); 774 } catch (e) { 775 console.error('Analytics fetch error (all retries exhausted):', e); 776 if (adminAnalyticsPage) adminAnalyticsPage.classList.remove('is-reloading'); 777 if (loadingOverlay) { 778 loadingOverlay.hidden = true; 779 loadingOverlay.setAttribute('aria-hidden', 'true'); 780 } 781 allValues.forEach(el => { 782 el.textContent = 'Error'; 783 el.style.opacity = '0.5'; 784 el.style.color = '#ff4b4b'; 785 }); 786 } finally { 787 if (refreshBtn) refreshBtn.classList.remove('spinning'); 788 if (adminAnalyticsPage) adminAnalyticsPage.classList.remove('is-reloading'); 789 if (loadingOverlay) { 790 loadingOverlay.hidden = true; 791 loadingOverlay.setAttribute('aria-hidden', 'true'); 792 } 793 } 794} 795 796// Refresh button 797const refreshBtn = document.getElementById('analytics-refresh-btn'); 798if (refreshBtn) { 799 refreshBtn.addEventListener('click', () => { 800 analyticsLoaded = false; 801 fetchAnalytics(); 802 }); 803} 804 805// --- Meta 5K history chart popup --- 806// Data is fetched lazily on card click (one indexed SELECT on the small 807// meta4k_history table) so the dashboard load never pays for it. 808const meta4kCard = document.getElementById('meta4k-card'); 809const meta4kChartModal = document.getElementById('meta4k-chart-modal'); 810const meta4kChartBody = document.getElementById('meta4k-chart-body'); 811const meta4kChartCloseBtn = document.getElementById('meta4k-chart-close'); 812 813function setMeta4kChartOpen(isOpen) { 814 if (!meta4kChartModal) return; 815 meta4kChartModal.hidden = !isOpen; 816 meta4kChartModal.setAttribute('aria-hidden', String(!isOpen)); 817} 818 819function meta4kStatusHtml(text) { 820 return `<div class="meta4k-chart-status">${text}</div>`; 821} 822 823/** Same â¥300 one-step break as the server: drop SKU-era points when live n is already 4.7k. */ 824function dropMetaGoalDefinitionCliffsClient(points, liveN) { 825 const sorted = [...points].sort((a, b) => a.t - b.t); 826 if (Number.isFinite(liveN) && sorted.length > 0) { 827 const last = sorted[sorted.length - 1]; 828 if (Math.abs(Number(liveN) - Number(last.n)) >= 300) { 829 return [{ t: Date.now(), n: Number(liveN) }]; 830 } 831 } 832 if (sorted.length < 2) return sorted; 833 let cut = 0; 834 for (let i = 1; i < sorted.length; i++) { 835 if (Math.abs(Number(sorted[i].n) - Number(sorted[i - 1].n)) >= 300) cut = i; 836 } 837 return cut === 0 ? sorted : sorted.slice(cut); 838} 839 840/** Renders the n(t) line chart as an inline SVG (x = 6h buckets, y = n). */ 841function renderMeta4kChart(points) { 842 const W = 800, H = 340; 843 const PAD = { top: 16, right: 20, bottom: 44, left: 56 }; 844 const plotW = W - PAD.left - PAD.right; 845 const plotH = H - PAD.top - PAD.bottom; 846 847 const ts = points.map(p => p.t); 848 const ns = points.map(p => p.n); 849 const tMin = Math.min(...ts), tMax = Math.max(...ts); 850 let nMin = Math.min(...ns), nMax = Math.max(...ns); 851 if (nMin === nMax) { nMin -= 1; nMax += 1; } 852 const nPadding = Math.max(1, Math.round((nMax - nMin) * 0.1)); 853 nMin -= nPadding; 854 nMax += nPadding; 855 856 const x = t => tMax === tMin 857 ? PAD.left + plotW / 2 858 : PAD.left + ((t - tMin) / (tMax - tMin)) * plotW; 859 const y = n => PAD.top + (1 - (n - nMin) / (nMax - nMin)) * plotH; 860 861 const fmtDay = ms => { 862 const d = new Date(ms); 863 return `${String(d.getDate()).padStart(2, '0')}/${String(d.getMonth() + 1).padStart(2, '0')}`; 864 }; 865 866 const coords = points.map(p => ({ 867 cx: x(p.t), 868 cy: y(p.n), 869 day: fmtDay(p.t), 870 hour: `${String(new Date(p.t).getHours()).padStart(2, '0')}:00`, 871 n: Number(p.n), 872 })); 873 874 let svg = `<svg viewBox="0 0 ${W} ${H}" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Gráfico ${metaGoalConfig().label}">`; 875 876 // Horizontal grid + y labels (5 ticks). 877 const yTicks = 5; 878 for (let i = 0; i <= yTicks; i++) { 879 const nVal = nMin + ((nMax - nMin) * i) / yTicks; 880 const yy = y(nVal); 881 svg += `<line x1="${PAD.left}" y1="${yy}" x2="${W - PAD.right}" y2="${yy}" stroke="rgba(255,255,255,0.07)" stroke-width="1"/>`; 882 svg += `<text x="${PAD.left - 8}" y="${yy + 4}" text-anchor="end" font-size="11" fill="rgba(255,255,255,0.5)">
882${Math.round(nVal).toLocaleString('pt-BR')}</text>`; 883 } 884 885 // Vertical grid at each midnight bucket + day labels. 886 for (const p of points) { 887 const d = new Date(p.t); 888 if (d.getHours() !== 0) continue; 889 const xx = x(p.t); 890 svg += `<line x1="${xx}" y1="${PAD.top}" x2="${xx}" y2="${H - PAD.bottom}" stroke="rgba(255,255,255,0.1)" stroke-width="1"/>`; 891 svg += `<text x="${xx}" y="${H - PAD.bottom + 18}" text-anchor="middle" font-size="11" fill="rgba(255,255,255,0.55)">${fmtDay(p.t)}</text>`; 892 } 893 894 // Line path + points. 895 const path = points.map((p, i) => `${i === 0 ? 'M' : 'L'}${x(p.t).toFixed(1)},${y(p.n).toFixed(1)}`).join(' '); 896 svg += `<path d="${path}" fill="none" stroke="#facc15" stroke-width="2.5" stroke-linejoin="round" stroke-linecap="round"/>`; 897 for (let i = 0; i < coords.length; i++) { 898 const c = coords[i]; 899 svg += `<circle class="meta4k-pt" data-i="${i}" cx="${c.cx.toFixed(1)}" cy="${c.cy.toFixed(1)}" r="3.5" fill="#facc15" stroke="#19191f" stroke-width="1.5"/>`; 900 } 901 // Hover highlight ring (moved/shown by bindMeta4kChartHover). 902 svg += `<circle class="meta4k-hover-ring" cx="0" cy="0" r="7" fill="none" stroke="#ffffff" stroke-width="2.5" opacity="0" pointer-events="none"/>`; 903 904 svg += '</svg>'; 905 return { 906 html: `<div class="meta4k-chart-wrap"><div class="meta4k-chart-balloon" hidden></div>${svg}</div>`, 907 coords,
908 viewBox: { W, H }, 909 }; 910} 911 912/** Immediate hover balloon + stroked highlight when the pointer is near a point. */ 913function bindMeta4kChartHover(container, chart) { 914 const wrap = container.querySelector('.meta4k-chart-wrap'); 915 const svg = container.querySelector('svg'); 916 const balloon = container.querySelector('.meta4k-chart-balloon'); 917 const ring = container.querySelector('.meta4k-hover-ring'); 918 if (!wrap || !svg || !balloon || !ring || !chart?.coords?.length) return; 919 920 const HIT_RADIUS = 28; // SVG units â generous so "near" a point is enough 921 let activeIdx = -1; 922 923 const clientToSvg = (clientX, clientY) => { 924 const pt = svg.createSVGPoint(); 925 pt.x = clientX; 926 pt.y = clientY; 927 const ctm = svg.getScreenCTM(); 928 if (!ctm) return null; 929 return pt.matrixTransform(ctm.inverse()); 930 }; 931 932 const hide = () => { 933 if (activeIdx >= 0) { 934 const prev = svg.querySelector(`.meta4k-pt[data-i="${activeIdx}"]`); 935 if (prev) { 936 prev.setAttribute('r', '3.5'); 937 prev.setAttribute('stroke', '#19191f'); 938 prev.setAttribute('stroke-width', '1.5'); 939 } 940 } 941 activeIdx = -1; 942 ring.setAttribute('opacity', '0'); 943 balloon.hidden = true; 944 balloon.classList.remove('visible'); 945 }; 946 947 const show = (idx) => { 948 const c = chart.coords[idx]; 949 if (idx !== activeIdx) { 950 if (activeIdx >= 0) { 951 const prev = svg.querySelector(`.meta4k-pt[data-i="${activeIdx}"]`); 952 if (prev) { 953 prev.setAttribute('r', '3.5'); 954 prev.setAttribute('stroke', '#19191f'); 955 prev.setAttribute('stroke-width', '1.5'); 956 } 957 } 958 const cur = svg.querySelector(`.meta4k-pt[data-i="${idx}"]`); 959 if (cur) { 960 cur.setAttribute('r', '5'); 961 cur.setAttribute('stroke', '#ffffff'); 962 cur.setAttribute('stroke-width', '2.5'); 963 } 964 activeIdx = idx; 965 } 966 967 ring.setAttribute('cx', c.cx.toFixed(1)); 968 ring.setAttribute('cy', c.cy.toFixed(1)); 969 ring.setAttribute('opacity', '1'); 970 971 balloon.innerHTML = 972 `<div class="meta4k-balloon-row"><span>Dia</span><strong>${c.day}</strong></div>` + 973 `<div class="meta4k-balloon-row"><span>Hora</span><strong>${c.hour}</strong></div>` + 974 `<div class="meta4k-balloon-row"><span>n</span><strong>${c.n.toLocaleString('pt-BR')}</strong></div>`; 975 balloon.hidden = false; 976 balloon.classList.add('visible'); 977 978 // Always above the point (may overflow the wrap top so it never covers the dot). 979 const svgRect = svg.getBoundingClientRect(); 980 const wrapRect = wrap.getBoundingClientRect(); 981 const scaleX = svgRect.width / chart.viewBox.W; 982 const scaleY = svgRect.height / chart.viewBox.H; 983 const px = (svgRect.left - wrapRect.left) + c.cx * scaleX; 984 const py = (svgRect.top - wrapRect.top) + c.cy * scaleY; 985 const bw = balloon.offsetWidth || 120; 986 const bh = balloon.offsetHeight || 70; 987 let left = px - bw / 2; 988 left = Math.max(4, Math.min(left, wrapRect.width - bw - 4)); 989 balloon.style.left = `${left}px`; 990 balloon.style.top = `${py - bh - 14}px`; 991 }; 992 993 svg.addEventListener('mousemove', (e) => { 994 const svgPt = clientToSvg(e.clientX, e.clientY); 995 if (!svgPt) { hide(); return; } 996 997 let bestIdx = -1; 998 let bestDist = HIT_RADIUS; 999 for (let i = 0; i < chart.coords.length; i++) { 1000 const c = chart.coords[i]; 1001 const dx = c.cx - svgPt.x; 1002 const dy = c.cy - svgPt.y; 1003 const dist = Math.hypot(dx, dy); 1004 if (dist < bestDist) { 1005 bestDist = dist; 1006 bestIdx = i; 1007 } 1008 } 1009 if (bestIdx < 0) hide(); 1010 else show(bestIdx); 1011 }); 1012 1013 svg.addEventListener('mouseleave', hide); 1014} 1015 1016async function openMeta4kChart() { 1017 if (!meta4kChartModal || !meta4kChartBody) return; 1018 applyMetaGoalCountry(); 1019 setMeta4kChartOpen(true); 1020 meta4kChartBody.innerHTML = meta4kStatusHtml('Carregandoâ¦'); 1021 try { 1022 const res = await fetchWithRetry(`${ANALYTICS_API_BASE}/admin_analytics/meta4k_history?country=${encodeURIComponent(analyticsCountry)}`, { 1023 headers: { 'Authorization': `Bearer ${adminToken}` }, 1024 signal: AbortSignal.timeout(ANALYTICS_FETCH_TIMEOUT_MS), 1025 }); 1026 if (res.status === 401) { setMeta4kChartOpen(false); handleTokenExpired(); return; } 1027 if (!res.ok) throw new Error(`HTTP ${res.status}`); 1028 const data = await res.json(); 1029 const points = dropMetaGoalDefinitionCliffsClient( 1030 (data.points || []).filter(p => Number.isFinite(Number(p.n)) && Number.isFinite(Number(p.t))), 1031 lastUniquePaidUsersTotal, 1032 ); 1033 if (points.length === 0) { 1034 meta4kChartBody.innerHTML = meta4kStatusHtml('Ainda não há pontos registrados â o servidor grava um valor de n a cada 6 horas a partir de agora.'); 1035 return; 1036 } 1037 const chart = renderMeta4kChart(points); 1038 meta4kChartBody.innerHTML = chart.html; 1039 bindMeta4kChartHover(meta4kChartBody, chart); 1040 } catch (e) { 1041 console.error(`${metaGoalConfig().label} history fetch error:`, e); 1042 meta4kChartBody.innerHTML = meta4kStatusHtml('Erro ao carregar o histórico. Tente novamente.'); 1043 } 1044} 1045 1046if (meta4kCard) { 1047 meta4kCard.addEventListener('click', (e) => { 1048 if (e.target.closest('.card-help-btn')) return; // "?" tooltip only 1049 openMeta4kChart(); 1050 }); 1051 meta4kCard.addEventListener('keydown', (e) => { 1052 if (e.key === 'Enter' || e.key === ' ') { 1053 e.preventDefault(); 1054 openMeta4kChart(); 1055 } 1056 }); 1057} 1058if (meta4kChartCloseBtn) { 1059 meta4kChartCloseBtn.addEventListener('click', () => setMeta4kChartOpen(false)); 1060} 1061if (meta4kChartModal) { 1062 meta4kChartModal.addEventListener('click', (e) => { 1063 if (e.target === meta4kChartModal) setMeta4kChartOpen(false); 1064 }); 1065 document.addEventListener('keydown', (e) => { 1066 if (e.key === 'Escape' && !meta4kChartModal.hidden) setMeta4kChartOpen(false); 1067 }); 1068} 1069 1070// --- ACTIVE SUBS weekly history chart popup --- 1071// Same click-to-graph pattern as Meta 5K. Data is fetched lazily on card click 1072// (reconstructed 9 weekly points, cached 30min server-side). 1073const activeSubsCard = document.getElementById('active-subs-card'); 1074const activeSubsChartModal = document.getElementById('active-subs-chart-modal'); 1075const activeSubsChartBody = document.getElementById('active-subs-chart-body'); 1076const activeSubsChartCloseBtn = document.getElementById('active-subs-chart-close'); 1077let activeSubsHistoryCache = null; 1078let activeSubsHistoryCacheCountry = null; 1079 1080function setActiveSubsChartOpen(isOpen) { 1081 if (!activeSubsChartModal) return; 1082 activeSubsChartModal.hidden = !isOpen; 1083 activeSubsChartModal.setAttribute('aria-hidden', String(!isOpen)); 1084} 1085 1086function activeSubsStatusHtml(text) { 1087 return `<div class="meta4k-chart-status">${text}</div>`; 1088} 1089 1090/** Renders total-active n(t) as a weekly line chart (balloon shows 30 | 90 | YEAR). */ 1091function renderActiveSubsChart(points) { 1092 const W = 800, H = 340; 1093 const PAD = { top: 16, right: 20, bottom: 44, left: 56 }; 1094 const plotW = W - PAD.left - PAD.right; 1095 const plotH = H - PAD.top - PAD.bottom; 1096 1097 const ts = points.map(p => p.t); 1098 const ns = points.map(p => p.n);
1099 const tMin = Math.min(...ts), tMax = Math.max(...ts); 1100 let nMin = Math.min(...ns), nMax = Math.max(...ns); 1101 if (nMin === nMax) { nMin -= 1; nMax += 1; } 1102 const nPadding = Math.max(1, Math.round((nMax - nMin) * 0.1)); 1103 nMin -= nPadding; 1104 nMax += nPadding; 1105 1106 const x = t => tMax === tMin 1107 ? PAD.left + plotW / 2 1108 : PAD.left + ((t - tMin) / (tMax - tMin)) * plotW; 1109 const y = n => PAD.top + (1 - (n - nMin) / (nMax - nMin)) * plotH; 1110 1111 const fmtDay = ms => { 1112 const d = new Date(ms); 1113 return `${String(d.getDate()).padStart(2, '0')}/${String(d.getMonth() + 1).padStart(2, '0')}`; 1114 }; 1115 1116 const coords = points.map(p => ({ 1117 cx: x(p.t), 1118 cy: y(p.n), 1119 day: fmtDay(p.t), 1120 n: Number(p.n), 1121 n30: Number(p.n30), 1122 n90: Number(p.n90), 1123 nYear: Number(p.n_year), 1124 label: formatActiveSubsValue(p.n, p.n30, p.n90, p.n_year), 1125 })); 1126 1127 let svg = `<svg viewBox="0 0 ${W} ${H}" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Gráfico ACTIVE SUBS">`; 1128 1129 const yTicks = 5; 1130 for (let i = 0; i <= yTicks; i++) { 1131 const nVal = nMin + ((nMax - nMin) * i) / yTicks; 1132 const yy = y(nVal); 1133 svg += `<line x1="${PAD.left}" y1="${yy}" x2="${W - PAD.right}" y2="${yy}" stroke="rgba(255,255,255,0.07)" stroke-width="1"/>`; 1134 svg += `<text x="${PAD.left - 8}" y="${yy + 4}" text-anchor="end" font-size="11" fill="rgba(255,255,255,0.5)">${Math.round(nVal).toLocaleString('pt-BR')}</text>`; 1135 } 1136 1137 for (const p of points) { 1138 const xx = x(p.t); 1139 svg += `<line x1="${xx}" y1="${PAD.top}" x2="${xx}" y2="${H - PAD.bottom}" stroke="rgba(255,255,255,0.1)" stroke-width="1"/>`; 1140 svg += `<text x="${xx}" y="${H - PAD.bottom + 18}" text-anchor="middle" font-size="11" fill="rgba(255,255,255,0.55)">${fmtDay(p.t)}</text>`; 1141 } 1142 1143 const path = points.map((p, i) => `${i === 0 ? 'M' : 'L'}${x(p.t).toFixed(1)},${y(p.n).toFixed(1)}`).join(' '); 1144 svg += `<path d="${path}" fill="none" stroke="#34d399" stroke-width="2.5" stroke-linejoin="round" stroke-linecap="round"/>`; 1145 for (let i = 0; i < coords.length; i++) { 1146 const c = coords[i]; 1147 svg += `<circle class="meta4k-pt" data-i="${i}" cx="${c.cx.toFixed(1)}" cy="${c.cy.toFixed(1)}" r="3.5" fill="#34d399" stroke="#19191f" stroke-width="1.5"/>`; 1148 } 1149 svg += `<circle class="meta4k-hover-ring" cx="0" cy="0" r="7" fill="none" stroke="#ffffff" stroke-width="2.5" opacity="0" pointer-events="none"/>`; 1150 1151 svg += '</svg>'; 1152 return { 1153 html: `<div class="meta4k-chart-wrap"><div class="meta4k-chart-balloon" hidden></div>${svg}</div>`, 1154 coords,
1155 viewBox: { W, H }, 1156 }; 1157} 1158 1159function bindActiveSubsChartHover(container, chart) { 1160 const wrap = container.querySelector('.meta4k-chart-wrap'); 1161 const svg = container.querySelector('svg'); 1162 const balloon = container.querySelector('.meta4k-chart-balloon'); 1163 const ring = container.querySelector('.meta4k-hover-ring'); 1164 if (!wrap || !svg || !balloon || !ring || !chart?.coords?.length) return; 1165 1166 const HIT_RADIUS = 28; 1167 let activeIdx = -1; 1168 1169 const clientToSvg = (clientX, clientY) => { 1170 const pt = svg.createSVGPoint(); 1171 pt.x = clientX; 1172 pt.y = clientY; 1173 const ctm = svg.getScreenCTM(); 1174 if (!ctm) return null; 1175 return pt.matrixTransform(ctm.inverse()); 1176 }; 1177 1178 const hide = () => { 1179 if (activeIdx >= 0) { 1180 const prev = svg.querySelector(`.meta4k-pt[data-i="${activeIdx}"]`); 1181 if (prev) { 1182 prev.setAttribute('r', '3.5'); 1183 prev.setAttribute('stroke', '#19191f'); 1184 prev.setAttribute('stroke-width', '1.5'); 1185 } 1186 } 1187 activeIdx = -1; 1188 ring.setAttribute('opacity', '0'); 1189 balloon.hidden = true; 1190 balloon.classList.remove('visible'); 1191 }; 1192 1193 const show = (idx) => { 1194 const c = chart.coords[idx]; 1195 if (idx !== activeIdx) { 1196 if (activeIdx >= 0) { 1197 const prev = svg.querySelector(`.meta4k-pt[data-i="${activeIdx}"]`); 1198 if (prev) { 1199 prev.setAttribute('r', '3.5'); 1200 prev.setAttribute('stroke', '#19191f'); 1201 prev.setAttribute('stroke-width', '1.5'); 1202 } 1203 } 1204 const cur = svg.querySelector(`.meta4k-pt[data-i="${idx}"]`); 1205 if (cur) { 1206 cur.setAttribute('r', '5'); 1207 cur.setAttribute('stroke', '#ffffff'); 1208 cur.setAttribute('stroke-width', '2.5'); 1209 } 1210 activeIdx = idx; 1211 } 1212 1213 ring.setAttribute('cx', c.cx.toFixed(1)); 1214 ring.setAttribute('cy', c.cy.toFixed(1)); 1215 ring.setAttribute('opacity', '1'); 1216 1217 balloon.innerHTML = 1218 `<div class="meta4k-balloon-row"><span>Semana</span><strong>${c.day}</strong></div>` + 1219 `<div class="meta4k-balloon-row"><span>Ativas</span><strong>${c.label}</strong></div>` + 1220 `<div class="meta4k-balloon-row"><span>30</span><strong>${Number(c.n30).toLocaleString('pt-BR')}</strong></div>` + 1221 `<div class="meta4k-balloon-row"><span>90</span><strong>${Number(c.n90).toLocaleString('pt-BR')}</strong></div>` + 1222 `<div class="meta4k-balloon-row"><span>YEAR</span><strong>${Number(c.nYear).toLocaleString('pt-BR')}</strong></div>`; 1223 balloon.hidden = false; 1224 balloon.classList.add('visible'); 1225 1226 const svgRect = svg.getBoundingClientRect(); 1227 const wrapRect = wrap.getBoundingClientRect(); 1228 const scaleX = svgRect.width / chart.viewBox.W; 1229 const scaleY = svgRect.height / chart.viewBox.H; 1230 const px = (svgRect.left - wrapRect.left) + c.cx * scaleX; 1231 const py = (svgRect.top - wrapRect.top) + c.cy * scaleY; 1232 const bw = balloon.offsetWidth || 160; 1233 const bh = balloon.offsetHeight || 90; 1234 let left = px - bw / 2; 1235 left = Math.max(4, Math.min(left, wrapRect.width - bw - 4)); 1236 balloon.style.left = `${left}px`; 1237 balloon.style.top = `${py - bh - 14}px`; 1238 }; 1239 1240 svg.addEventListener('mousemove', (e) => { 1241 const svgPt = clientToSvg(e.clientX, e.clientY); 1242 if (!svgPt) { hide(); return; } 1243 1244 let bestIdx = -1; 1245 let bestDist = HIT_RADIUS; 1246 for (let i = 0; i < chart.coords.length; i++) { 1247 const c = chart.coords[i]; 1248 const dx = c.cx - svgPt.x; 1249 const dy = c.cy - svgPt.y; 1250 const dist = Math.hypot(dx, dy); 1251 if (dist < bestDist) { 1252 bestDist = dist; 1253 bestIdx = i; 1254 } 1255 } 1256 if (bestIdx < 0) hide(); 1257 else show(bestIdx); 1258 }); 1259 1260 svg.addEventListener('mouseleave', hide); 1261} 1262 1263async function openActiveSubsChart() { 1264 if (!activeSubsChartModal || !activeSubsChartBody) return; 1265 setActiveSubsChartOpen(true); 1266 activeSubsChartBody.innerHTML = activeSubsStatusHtml('Carregandoâ¦'); 1267 try { 1268 if (!activeSubsHistoryCache || activeSubsHistoryCacheCountry !== analyticsCountry) { 1269 const res = await fetchWithRetry(`${ANALYTICS_API_BASE}/admin_analytics/active_subs_history?country=${encodeURIComponent(analyticsCountry)}`, { 1270 headers: { 'Authorization': `Bearer ${adminToken}` }, 1271 signal: AbortSignal.timeout(ANALYTICS_FETCH_TIMEOUT_MS), 1272 }); 1273 if (res.status === 401) { setActiveSubsChartOpen(false); handleTokenExpired(); return; } 1274 if (!res.ok) throw new Error(`HTTP ${res.status}`); 1275 activeSubsHistoryCache = await res.json();
1276 activeSubsHistoryCacheCountry = analyticsCountry; 1277 } 1278 const points = (activeSubsHistoryCache.points || []).filter(p => 1279 Number.isFinite(Number(p.n)) && Number.isFinite(Number(p.t)) 1280 ); 1281 if (points.length === 0) { 1282 activeSubsChartBody.innerHTML = activeSubsStatusHtml('Ainda não há pontos registrados.'); 1283 return; 1284 } 1285 const chart = renderActiveSubsChart(points); 1286 activeSubsChartBody.innerHTML = chart.html; 1287 bindActiveSubsChartHover(activeSubsChartBody, chart); 1288 } catch (e) { 1289 console.error('ACTIVE SUBS history fetch error:', e); 1290 activeSubsChartBody.innerHTML = activeSubsStatusHtml('Erro ao carregar o histórico. Tente novamente.'); 1291 } 1292} 1293 1294if (activeSubsCard) { 1295 activeSubsCard.addEventListener('click', (e) => { 1296 if (e.target.closest('.card-help-btn')) return; 1297 openActiveSubsChart(); 1298 }); 1299 activeSubsCard.addEventListener('keydown', (e) => { 1300 if (e.key === 'Enter' || e.key === ' ') { 1301 e.preventDefault(); 1302 openActiveSubsChart(); 1303 } 1304 }); 1305} 1306if (activeSubsChartCloseBtn) { 1307 activeSubsChartCloseBtn.addEventListener('click', () => setActiveSubsChartOpen(false)); 1308} 1309if (activeSubsChartModal) { 1310 activeSubsChartModal.addEventListener('click', (e) => { 1311 if (e.target === activeSubsChartModal) setActiveSubsChartOpen(false); 1312 }); 1313 document.addEventListener('keydown', (e) => { 1314 if (e.key === 'Escape' && !activeSubsChartModal.hidden) setActiveSubsChartOpen(false); 1315 }); 1316} 1317 1318// --- CPI 7d card value + history chart popup --- 1319// Blended cost-per-install (Meta ad spend of the selected country's campaigns ÷ that 1320// country's new installs) as a 7-day trailing moving average. BR = AD02/AD07/AD08; 1321// US = AD09 + AD10 summed, floored server-side at US_METRICS_EPOCH_DAY (28/ago/2026) 1322// because the earlier US run pointed at a Play listing that was not distributed there. 1323// The US number is therefore a two-campaign blend, not the job-title set alone. Sourced from 1324// the lazy /admin_analytics/cpi_history endpoint (Meta daily spend + installs, cached 1325// 30min server-side per country). The card value = the most recent point; the popup 1326// graphs the last 30 days. Reuses the meta4k-chart-* CSS. 1327const cpiCard = document.getElementById('cpi-card'); 1328const cpiChartModal = document.getElementById('cpi-chart-modal'); 1329const cpiChartBody = document.getElementById('cpi-chart-body'); 1330const cpiChartCloseBtn = document.getElementById('cpi-chart-close'); 1331 1332// Shared between the card value and the popup so a click doesn't re-fetch. 1333let cpiHistoryCache = null; 1334let cpiHistoryCacheCountry = null; 1335 1336/** BRL currency, pt-BR (e.g. "R$ 0,83"). */ 1337function formatCpi(value) { 1338 const n = Number(value); 1339 if (value === null || value === undefined || !Number.isFinite(n)) return 'â'; 1340 return 'R$ ' + n.toLocaleString('pt-BR', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); 1341} 1342 1343function cpiHistoryUrl() { 1344 return `${ANALYTICS_API_BASE}/admin_analytics/cpi_history?country=${encodeURIComponent(analyticsCountry)}`; 1345} 1346 1347function applyCpiChartCountryCopy() { 1348 const title = document.querySelector('#cpi-chart-modal .meta4k-chart-title'); 1349 const subtitle = document.querySelector('#cpi-chart-modal .meta4k-chart-subtitle'); 1350 if (title) { 1351 title.textContent = analyticsCountry === 'US' 1352 ? 'CPI 7d (US / AD09+AD10) â média móvel de 7 dias' 1353 : 'CPI 7d (BR / AD02+AD07+AD08) â média móvel de 7 dias'; 1354 } 1355 if (subtitle) { 1356 subtitle.textContent = analyticsCountry === 'US' 1357 ? 'Cada ponto = gasto Meta AD09+AD10 com impostos ÷ instalações nos EUA nos 7 dias anteriores (série começa em 28/ago/2026)' 1358 : 'Cada ponto = gasto Meta AD02/AD07/AD08 com impostos ÷ instalações no Brasil nos 7 dias anteriores (últimos 30 dias)'; 1359 } 1360} 1361 1362async function fetchCpi7d() { 1363 const cpiValueEl = document.getElementById('stat-cpi-7d'); 1364 try { 1365 const res = await fetchWithRetry(cpiHistoryUrl(), { 1366 headers: { 'Authorization': `Bearer ${adminToken}` }, 1367 signal: AbortSignal.timeout(ANALYTICS_FETCH_TIMEOUT_MS), 1368 }); 1369 if (res.status === 401) { handleTokenExpired(); return; } 1370 if (!res.ok) throw new Error(`HTTP ${res.status}`); 1371 const data = await res.json(); 1372 cpiHistoryCache = data;
1373 cpiHistoryCacheCountry = analyticsCountry; 1374 if (cpiValueEl) cpiValueEl.textContent = formatCpi(data.current_cpi_7d); 1375 } catch (e) { 1376 console.error('CPI 7d fetch error:', e); 1377 if (cpiValueEl) cpiValueEl.textContent = 'â'; 1378 } 1379} 1380 1381function setCpiChartOpen(isOpen) { 1382 if (!cpiChartModal) return; 1383 cpiChartModal.hidden = !isOpen; 1384 cpiChartModal.setAttribute('aria-hidden', String(!isOpen)); 1385} 1386 1387function cpiStatusHtml(text) { 1388 return `<div class="meta4k-chart-status">${text}</div>`; 1389} 1390 1391const cpiBrl = v => 'R$ ' + Number(v).toLocaleString('pt-BR', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); 1392 1393/** Renders the CPI(day) moving-average line chart as an inline SVG (x = day, y = R$/install). */ 1394function renderCpiChart(points) { 1395 const W = 800, H = 340; 1396 const PAD = { top: 16, right: 20, bottom: 44, left: 68 }; 1397 const plotW = W - PAD.left - PAD.right; 1398 const plotH = H - PAD.top - PAD.bottom; 1399 1400 const ts = points.map(p => p.t); 1401 const ys = points.map(p => p.cpi); 1402 const tMin = Math.min(...ts), tMax = Math.max(...ts); 1403 let yMin = Math.min(...ys), yMax = Math.max(...ys); 1404 if (yMin === yMax) { yMin -= 0.1; yMax += 0.1; } 1405 const yPad = Math.max(0.01, (yMax - yMin) * 0.1); 1406 yMin = Math.max(0, yMin - yPad); 1407 yMax += yPad; 1408 1409 const x = t => tMax === tMin ? PAD.left + plotW / 2 : PAD.left + ((t - tMin) / (tMax - tMin)) * plotW; 1410 const y = v => PAD.top + (1 - (v - yMin) / (yMax - yMin)) * plotH; 1411 1412 const fmtDay = ms => { 1413 const d = new Date(ms); 1414 return `${String(d.getDate()).padStart(2, '0')}/${String(d.getMonth() + 1).padStart(2, '0')}`; 1415 }; 1416 1417 const coords = points.map(p => ({ 1418 cx: x(p.t), 1419 cy: y(p.cpi), 1420 day: fmtDay(p.t), 1421 cpi: Number(p.cpi), 1422 spend: Number(p.spend), 1423 installs: Number(p.installs), 1424 })); 1425 1426 let svg = `<svg viewBox="0 0 ${W} ${H}" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Gráfico CPI 7d">`; 1427 1428 // Horizontal grid + y labels (5 ticks, R$). 1429 const yTicks = 5; 1430 for (let i = 0; i <= yTicks; i++) { 1431 const v = yMin + ((yMax - yMin) * i) / yTicks; 1432 const yy = y(v); 1433 svg += `<line x1="${PAD.left}" y1="${yy}" x2="${W - PAD.right}" y2="${yy}" stroke="rgba(255,255,255,0.07)" stroke-width="1"/>`; 1434 svg += `<text x="${PAD.left - 8}" y="${yy + 4}" text-anchor="end" font-size="11" fill="rgba(255,255,255,0.5)">${cpiBrl(v)}</text>`; 1435 } 1436 1437 // Vertical grid + day labels, thinned to ~6 to avoid clutter (always label the last point). 1438 const labelEvery = Math.max(1, Math.round(points.length / 6)); 1439 points.forEach((p, i) => { 1440 if (i % labelEvery !== 0 && i !== points.length - 1) return; 1441 const xx = x(p.t); 1442 svg += `<line x1="${xx}" y1="${PAD.top}" x2="${xx}" y2="${H - PAD.bottom}" stroke="rgba(255,255,255,0.1)" stroke-width="1"/>`; 1443 svg += `<text x="${xx}" y="${H - PAD.bottom + 18}" text-anchor="middle" font-size="11" fill="rgba(255,255,255,0.55)">${fmtDay(p.t)}</text>`; 1444 }); 1445 1446 // Line path + points. 1447 const path = points.map((p, i) => `${i === 0 ? 'M' : 'L'}${x(p.t).toFixed(1)},${y(p.cpi).toFixed(1)}`).join(' '); 1448 svg += `<path d="${path}" fill="none" stroke="#34d399" stroke-width="2.5" stroke-linejoin="round" stroke-linecap="round"/>`; 1449 for (let i = 0; i < coords.length; i++) { 1450 const c = coords[i]; 1451 svg += `<circle class="cpi-pt" data-i="${i}" cx="${c.cx.toFixed(1)}" cy="${c.cy.toFixed(1)}" r="3.5" fill="#34d399" stroke="#19191f" stroke-width="1.5"/>`; 1452 } 1453 // Hover highlight ring (moved/shown by bindCpiChartHover). 1454 svg += `<circle class="cpi-hover-ring" cx="0" cy="0" r="7" fill="none" stroke="#ffffff" stroke-width="2.5" opacity="0" pointer-events="none"/>`; 1455 1456 svg += '</svg>'; 1457 return { 1458 html: `<div class="meta4k-chart-wrap"><div class="meta4k-chart-balloon" hidden></div>${svg}</div>`, 1459 coords,
1460 viewBox: { W, H }, 1461 }; 1462} 1463 1464/** Immediate hover balloon + stroked highlight when the pointer is near a point. */ 1465function bindCpiChartHover(container, chart) { 1466 const wrap = container.querySelector('.meta4k-chart-wrap'); 1467 const svg = container.querySelector('svg'); 1468 const balloon = container.querySelector('.meta4k-chart-balloon'); 1469 const ring = container.querySelector('.cpi-hover-ring'); 1470 if (!wrap || !svg || !balloon || !ring || !chart?.coords?.length) return; 1471 1472 const HIT_RADIUS = 28; 1473 let activeIdx = -1; 1474 1475 const clientToSvg = (clientX, clientY) => { 1476 const pt = svg.createSVGPoint(); 1477 pt.x = clientX; 1478 pt.y = clientY; 1479 const ctm = svg.getScreenCTM(); 1480 if (!ctm) return null; 1481 return pt.matrixTransform(ctm.inverse()); 1482 }; 1483 1484 const resetPt = (idx) => { 1485 const el = svg.querySelector(`.cpi-pt[data-i="${idx}"]`); 1486 if (el) { el.setAttribute('r', '3.5'); el.setAttribute('stroke', '#19191f'); el.setAttribute('stroke-width', '1.5'); } 1487 }; 1488 1489 const hide = () => { 1490 if (activeIdx >= 0) resetPt(activeIdx); 1491 activeIdx = -1; 1492 ring.setAttribute('opacity', '0'); 1493 balloon.hidden = true; 1494 balloon.classList.remove('visible'); 1495 }; 1496 1497 const show = (idx) => { 1498 const c = chart.coords[idx]; 1499 if (idx !== activeIdx) { 1500 if (activeIdx >= 0) resetPt(activeIdx); 1501 const cur = svg.querySelector(`.cpi-pt[data-i="${idx}"]`); 1502 if (cur) { cur.setAttribute('r', '5'); cur.setAttribute('stroke', '#ffffff'); cur.setAttribute('stroke-width', '2.5'); } 1503 activeIdx = idx; 1504 } 1505 1506 ring.setAttribute('cx', c.cx.toFixed(1)); 1507 ring.setAttribute('cy', c.cy.toFixed(1)); 1508 ring.setAttribute('opacity', '1'); 1509 1510 balloon.innerHTML = 1511 `<div class="meta4k-balloon-row"><span>Dia</span><strong>${c.day}</strong></div>` + 1512 `<div class="meta4k-balloon-row"><span>CPI 7d</span><strong>${cpiBrl(c.cpi)}</strong></div>` + 1513 `<div class="meta4k-balloon-row"><span>Gasto</span><strong>${cpiBrl(c.spend)}</strong></div>` + 1514 `<div class="meta4k-balloon-row"><span>Instalações</span><strong>${c.installs.toLocaleString('pt-BR')}</strong></div>`; 1515 balloon.hidden = false; 1516 balloon.classList.add('visible'); 1517 1518 // Always above the point (may overflow the wrap top so it never covers the dot). 1519 const svgRect = svg.getBoundingClientRect(); 1520 const wrapRect = wrap.getBoundingClientRect(); 1521 const scaleX = svgRect.width / chart.viewBox.W; 1522 const scaleY = svgRect.height / chart.viewBox.H; 1523 const px = (svgRect.left - wrapRect.left) + c.cx * scaleX; 1524 const py = (svgRect.top - wrapRect.top) + c.cy * scaleY; 1525 const bw = balloon.offsetWidth || 130; 1526 const bh = balloon.offsetHeight || 84; 1527 let left = px - bw / 2; 1528 left = Math.max(4, Math.min(left, wrapRect.width - bw - 4)); 1529 balloon.style.left = `${left}px`; 1530 balloon.style.top = `${py - bh - 14}px`; 1531 }; 1532 1533 svg.addEventListener('mousemove', (e) => { 1534 const svgPt = clientToSvg(e.clientX, e.clientY); 1535 if (!svgPt) { hide(); return; } 1536 let bestIdx = -1; 1537 let bestDist = HIT_RADIUS; 1538 for (let i = 0; i < chart.coords.length; i++) { 1539 const c = chart.coords[i]; 1540 const dist = Math.hypot(c.cx - svgPt.x, c.cy - svgPt.y); 1541 if (dist < bestDist) { bestDist = dist; bestIdx = i; } 1542 } 1543 if (bestIdx < 0) hide(); 1544 else show(bestIdx); 1545 }); 1546 1547 svg.addEventListener('mouseleave', hide); 1548} 1549 1550async function openCpiChart() { 1551 if (!cpiChartModal || !cpiChartBody) return; 1552 applyCpiChartCountryCopy(); 1553 setCpiChartOpen(true); 1554 1555 const render = (data) => { 1556 const points = (data?.points || []).filter(p => Number.isFinite(Number(p.cpi)) && Number.isFinite(Number(p.t))); 1557 if (points.length === 0) { 1558 cpiChartBody.innerHTML = cpiStatusHtml('Ainda não há dados suficientes de CPI.'); 1559 return; 1560 } 1561 const chart = renderCpiChart(points); 1562 cpiChartBody.innerHTML = chart.html; 1563 bindCpiChartHover(cpiChartBody, chart); 1564 }; 1565 1566 // Reuse the series already fetched for the card when it's for this country. 1567 if (cpiHistoryCache && cpiHistoryCacheCountry === analyticsCountry) { render(cpiHistoryCache); return; } 1568 1569 cpiChartBody.innerHTML = cpiStatusHtml('Carregandoâ¦'); 1570 try { 1571 const res = await fetchWithRetry(cpiHistoryUrl(), { 1572 headers: { 'Authorization': `Bearer ${adminToken}` }, 1573 signal: AbortSignal.timeout(ANALYTICS_FETCH_TIMEOUT_MS), 1574 }); 1575 if (res.status === 401) { setCpiChartOpen(false); handleTokenExpired(); return; } 1576 if (!res.ok) throw new Error(`HTTP ${res.status}`); 1577 const data = await res.json(); 1578 cpiHistoryCache = data;
1579 cpiHistoryCacheCountry = analyticsCountry; 1580 render(data); 1581 } catch (e) { 1582 console.error('CPI history fetch error:', e); 1583 cpiChartBody.innerHTML = cpiStatusHtml('Erro ao carregar o histórico. Tente novamente.'); 1584 } 1585} 1586 1587if (cpiCard) { 1588 cpiCard.addEventListener('click', (e) => { 1589 if (e.target.closest('.card-help-btn')) return; // "?" tooltip only 1590 openCpiChart(); 1591 }); 1592 cpiCard.addEventListener('keydown', (e) => { 1593 if (e.key === 'Enter' || e.key === ' ') { 1594 e.preventDefault(); 1595 openCpiChart(); 1596 } 1597 }); 1598} 1599if (cpiChartCloseBtn) { 1600 cpiChartCloseBtn.addEventListener('click', () => setCpiChartOpen(false)); 1601} 1602if (cpiChartModal) { 1603 cpiChartModal.addEventListener('click', (e) => { 1604 if (e.target === cpiChartModal) setCpiChartOpen(false); 1605 }); 1606 document.addEventListener('keydown', (e) => { 1607 if (e.key === 'Escape' && !cpiChartModal.hidden) setCpiChartOpen(false); 1608 }); 1609} 1610 1611// --- 1ª PAG 14d history chart popup --- 1612// Unique first-time payers as a 14-day trailing count (the same moving window 1613// as the card). Reconstructed per complete SP day for the last 30 days. 1614// Lazy fetch on card click (/admin_analytics/first_payers_history), cached 1615// 30min server-side per country. Reuses the meta4k-chart-* CSS. 1616const firstPagCard = document.getElementById('first-pag-card'); 1617const firstPagChartModal = document.getElementById('first-pag-chart-modal'); 1618const firstPagChartBody = document.getElementById('first-pag-chart-body'); 1619const firstPagChartCloseBtn = document.getElementById('first-pag-chart-close'); 1620 1621let firstPagHistoryCache = null; 1622let firstPagHistoryCacheCountry = null; 1623 1624function firstPagHistoryUrl() { 1625 return `${ANALYTICS_API_BASE}/admin_analytics/first_payers_history?country=${encodeURIComponent(analyticsCountry)}`; 1626} 1627 1628function applyFirstPagChartCountryCopy() { 1629 const title = document.querySelector('#first-pag-chart-modal .meta4k-chart-title'); 1630 const subtitle = document.querySelector('#first-pag-chart-modal .meta4k-chart-subtitle'); 1631 if (title) { 1632 title.textContent = '1ª PAG 14d â média móvel de 14 dias'; 1633 } 1634 if (subtitle) { 1635 subtitle.textContent = analyticsCountry === 'US' 1636 ? 'Cada ponto = motoristas com primeira assinatura Play nos 14 dias anteriores (últimos 30 dias)' 1637 : 'Cada ponto = motoristas com primeira compra (Play ou PIX) nos 14 dias anteriores (últimos 30 dias)'; 1638 } 1639} 1640 1641function setFirstPagChartOpen(isOpen) { 1642 if (!firstPagChartModal) return; 1643 firstPagChartModal.hidden = !isOpen; 1644 firstPagChartModal.setAttribute('aria-hidden', String(!isOpen)); 1645} 1646 1647function firstPagStatusHtml(text) { 1648 return `<div class="meta4k-chart-status">${text}</div>`; 1649} 1650 1651/** Renders the 1ª PAG 14d trailing-count line chart as an inline SVG. */ 1652function renderFirstPagChart(points) { 1653 const W = 800, H = 340; 1654 const PAD = { top: 16, right: 20, bottom: 44, left: 56 }; 1655 const plotW = W - PAD.left - PAD.right; 1656 const plotH = H - PAD.top - PAD.bottom; 1657 1658 const ts = points.map(p => p.t); 1659 const ys = points.map(p => p.n); 1660 const tMin = Math.min(...ts), tMax = Math.max(...ts); 1661 let yMin = Math.min(...ys), yMax = Math.max(...ys); 1662 if (yMin === yMax) { yMin -= 1; yMax += 1; } 1663 const yPad = Math.max(1, Math.round((yMax - yMin) * 0.1)); 1664 yMin = Math.max(0, yMin - yPad); 1665 yMax += yPad; 1666 1667 const x = t => tMax === tMin ? PAD.left + plotW / 2 : PAD.left + ((t - tMin) / (tMax - tMin)) * plotW; 1668 const y = v => PAD.top + (1 - (v - yMin) / (yMax - yMin)) * plotH; 1669 1670 const fmtDay = ms => { 1671 const d = new Date(ms); 1672 return `${String(d.getDate()).padStart(2, '0')}/${String(d.getMonth() + 1).padStart(2, '0')}`; 1673 }; 1674 1675 const coords = points.map(p => ({ 1676 cx: x(p.t), 1677 cy: y(p.n), 1678 day: fmtDay(p.t), 1679 n: Number(p.n), 1680 dayN: Number(p.day_n), 1681 })); 1682 1683 let svg = `<svg viewBox="0 0 ${W} ${H}" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Gráfico 1ª PAG 14d">`; 1684 1685 const yTicks = 5; 1686 for (let i = 0; i <= yTicks; i++) {
1687 const v = yMin + ((yMax - yMin) * i) / yTicks; 1688 const yy = y(v); 1689 svg += `<line x1="${PAD.left}" y1="${yy}" x2="${W - PAD.right}" y2="${yy}" stroke="rgba(255,255,255,0.07)" stroke-width="1"/>`; 1690 svg += `<text x="${PAD.left - 8}" y="${yy + 4}" text-anchor="end" font-size="11" fill="rgba(255,255,255,0.5)">${Math.round(v).toLocaleString('pt-BR')}</text>`; 1691 } 1692 1693 const labelEvery = Math.max(1, Math.round(points.length / 6)); 1694 points.forEach((p, i) => { 1695 if (i % labelEvery !== 0 && i !== points.length - 1) return; 1696 const xx = x(p.t); 1697 svg += `<line x1="${xx}" y1="${PAD.top}" x2="${xx}" y2="${H - PAD.bottom}" stroke="rgba(255,255,255,0.1)" stroke-width="1"/>`; 1698 svg += `<text x="${xx}" y="${H - PAD.bottom + 18}" text-anchor="middle" font-size="11" fill="rgba(255,255,255,0.55)">${fmtDay(p.t)}</text>`; 1699 }); 1700 1701 const path = points.map((p, i) => `${i === 0 ? 'M' : 'L'}${x(p.t).toFixed(1)},${y(p.n).toFixed(1)}`).join(' '); 1702 svg += `<path d="${path}" fill="none" stroke="#34d399" stroke-width="2.5" stroke-linejoin="round" stroke-linecap="round"/>`; 1703 for (let i = 0; i < coords.length; i++) { 1704 const c = coords[i]; 1705 svg += `<circle class="first-pag-pt" data-i="${i}" cx="${c.cx.toFixed(1)}" cy="${c.cy.toFixed(1)}" r="3.5" fill="#34d399" stroke="#19191f" stroke-width="1.5"/>`; 1706 } 1707 svg += `<circle class="first-pag-hover-ring" cx="0" cy="0" r="7" fill="none" stroke="#ffffff" stroke-width="2.5" opacity="0" pointer-events="none"/>`; 1708 1709 svg += '</svg>'; 1710 return { 1711 html: `<div class="meta4k-chart-wrap"><div class="meta4k-chart-balloon" hidden></div>${svg}</div>`, 1712 coords,
1713 viewBox: { W, H }, 1714 }; 1715} 1716 1717function bindFirstPagChartHover(container, chart) { 1718 const wrap = container.querySelector('.meta4k-chart-wrap'); 1719 const svg = container.querySelector('svg'); 1720 const balloon = container.querySelector('.meta4k-chart-balloon'); 1721 const ring = container.querySelector('.first-pag-hover-ring'); 1722 if (!wrap || !svg || !balloon || !ring || !chart?.coords?.length) return; 1723 1724 const HIT_RADIUS = 28; 1725 let activeIdx = -1; 1726 1727 const clientToSvg = (clientX, clientY) => { 1728 const pt = svg.createSVGPoint(); 1729 pt.x = clientX; 1730 pt.y = clientY; 1731 const ctm = svg.getScreenCTM(); 1732 if (!ctm) return null; 1733 return pt.matrixTransform(ctm.inverse()); 1734 }; 1735 1736 const resetPt = (idx) => { 1737 const el = svg.querySelector(`.first-pag-pt[data-i="${idx}"]`); 1738 if (el) { el.setAttribute('r', '3.5'); el.setAttribute('stroke', '#19191f'); el.setAttribute('stroke-width', '1.5'); } 1739 }; 1740 1741 const hide = () => { 1742 if (activeIdx >= 0) resetPt(activeIdx); 1743 activeIdx = -1; 1744 ring.setAttribute('opacity', '0'); 1745 balloon.hidden = true; 1746 balloon.classList.remove('visible'); 1747 }; 1748 1749 const show = (idx) => { 1750 const c = chart.coords[idx]; 1751 if (idx !== activeIdx) { 1752 if (activeIdx >= 0) resetPt(activeIdx); 1753 const cur = svg.querySelector(`.first-pag-pt[data-i="${idx}"]`); 1754 if (cur) { cur.setAttribute('r', '5'); cur.setAttribute('stroke', '#ffffff'); cur.setAttribute('stroke-width', '2.5'); } 1755 activeIdx = idx; 1756 } 1757 1758 ring.setAttribute('cx', c.cx.toFixed(1)); 1759 ring.setAttribute('cy', c.cy.toFixed(1)); 1760 ring.setAttribute('opacity', '1'); 1761 1762 balloon.innerHTML = 1763 `<div class="meta4k-balloon-row"><span>Dia</span><strong>${c.day}</strong></div>` + 1764 `<div class="meta4k-balloon-row"><span>1ª PAG 14d</span><strong>${c.n.toLocaleString('pt-BR')}</strong></div>` + 1765 `<div class="meta4k-balloon-row"><span>No dia</span><strong>${c.dayN.toLocaleString('pt-BR')}</strong></div>`; 1766 balloon.hidden = false; 1767 balloon.classList.add('visible'); 1768 1769 const svgRect = svg.getBoundingClientRect(); 1770 const wrapRect = wrap.getBoundingClientRect(); 1771 const scaleX = svgRect.width / chart.viewBox.W; 1772 const scaleY = svgRect.height / chart.viewBox.H; 1773 const px = (svgRect.left - wrapRect.left) + c.cx * scaleX; 1774 const py = (svgRect.top - wrapRect.top) + c.cy * scaleY; 1775 const bw = balloon.offsetWidth || 130; 1776 const bh = balloon.offsetHeight || 84; 1777 let left = px - bw / 2; 1778 left = Math.max(4, Math.min(left, wrapRect.width - bw - 4)); 1779 balloon.style.left = `${left}px`; 1780 balloon.style.top = `${py - bh - 14}px`; 1781 }; 1782 1783 svg.addEventListener('mousemove', (e) => { 1784 const svgPt = clientToSvg(e.clientX, e.clientY); 1785 if (!svgPt) { hide(); return; } 1786 let bestIdx = -1; 1787 let bestDist = HIT_RADIUS; 1788 for (let i = 0; i < chart.coords.length; i++) { 1789 const c = chart.coords[i]; 1790 const dist = Math.hypot(c.cx - svgPt.x, c.cy - svgPt.y); 1791 if (dist < bestDist) { bestDist = dist; bestIdx = i; } 1792 } 1793 if (bestIdx < 0) hide(); 1794 else show(bestIdx); 1795 }); 1796 1797 svg.addEventListener('mouseleave', hide); 1798} 1799 1800async function openFirstPagChart() { 1801 if (!firstPagChartModal || !firstPagChartBody) return; 1802 applyFirstPagChartCountryCopy(); 1803 setFirstPagChartOpen(true); 1804 1805 const render = (data) => { 1806 const points = (data?.points || []).filter(p => Number.isFinite(Number(p.n)) && Number.isFinite(Number(p.t))); 1807 if (points.length === 0) { 1808 firstPagChartBody.innerHTML = firstPagStatusHtml('Ainda não há dados suficientes de 1ª PAG.'); 1809 return; 1810 } 1811 const chart = renderFirstPagChart(points); 1812 firstPagChartBody.innerHTML = chart.html; 1813 bindFirstPagChartHover(firstPagChartBody, chart); 1814 }; 1815 1816 if (firstPagHistoryCache && firstPagHistoryCacheCountry === analyticsCountry) { 1817 render(firstPagHistoryCache); 1818 return; 1819 } 1820 1821 firstPagChartBody.innerHTML = firstPagStatusHtml('Carregandoâ¦'); 1822 try { 1823 const res = await fetchWithRetry(firstPagHistoryUrl(), { 1824 headers: { 'Authorization': `Bearer ${adminToken}` }, 1825 signal: AbortSignal.timeout(ANALYTICS_FETCH_TIMEOUT_MS), 1826 }); 1827 if (res.status === 401) { setFirstPagChartOpen(false); handleTokenExpired(); return; } 1828 if (!res.ok) throw new Error(`HTTP ${res.status}`); 1829 const data = await res.json(); 1830 firstPagHistoryCache = data;
1831 firstPagHistoryCacheCountry = analyticsCountry; 1832 render(data); 1833 } catch (e) { 1834 console.error('1ª PAG history fetch error:', e); 1835 firstPagChartBody.innerHTML = firstPagStatusHtml('Erro ao carregar o histórico. Tente novamente.'); 1836 } 1837} 1838 1839if (firstPagCard) { 1840 firstPagCard.addEventListener('click', (e) => { 1841 if (e.target.closest('.card-help-btn')) return; 1842 openFirstPagChart(); 1843 }); 1844 firstPagCard.addEventListener('keydown', (e) => { 1845 if (e.key === 'Enter' || e.key === ' ') { 1846 e.preventDefault(); 1847 openFirstPagChart(); 1848 } 1849 }); 1850} 1851if (firstPagChartCloseBtn) { 1852 firstPagChartCloseBtn.addEventListener('click', () => setFirstPagChartOpen(false)); 1853} 1854if (firstPagChartModal) { 1855 firstPagChartModal.addEventListener('click', (e) => { 1856 if (e.target === firstPagChartModal) setFirstPagChartOpen(false); 1857 }); 1858 document.addEventListener('keydown', (e) => { 1859 if (e.key === 'Escape' && !firstPagChartModal.hidden) setFirstPagChartOpen(false); 1860 }); 1861} 1862 1863// --- Avg. SG/user + Avg. LMW/user history chart popup --- 1864// Trailing-24h rate sampled every 12h for the last 14 days. Lazy fetch on card 1865// click (one grouped SELECT on service_lifecycle_event), cached 30min server-side. 1866// Reuses the meta4k-chart-* presentational CSS. 1867const sgRateCard = document.getElementById('sg-rate-card'); 1868const lmwRateCard = document.getElementById('lmw-rate-card'); 1869const lifecycleRateChartModal = document.getElementById('lifecycle-rate-chart-modal'); 1870const lifecycleRateChartBody = document.getElementById('lifecycle-rate-chart-body'); 1871const lifecycleRateChartCloseBtn = document.getElementById('lifecycle-rate-chart-close'); 1872const lifecycleRateChartTitle = document.getElementById('lifecycle-rate-chart-title'); 1873 1874const LIFECYCLE_RATE_METRICS = { 1875 sg: { 1876 key: 'sg', 1877 label: 'Avg. SG/user', 1878 color: '#f87171', 1879 eventsKey: 'sg_events', 1880 eventsLabel: 'SIGKILLs', 1881 }, 1882 lmw: { 1883 key: 'lmw', 1884 label: 'Avg. LMW/user', 1885 color: '#fb923c', 1886 eventsKey: 'lmw_events', 1887 eventsLabel: 'LMW', 1888 }, 1889}; 1890 1891let lifecycleRateCache = null; 1892let lifecycleRateCacheCountry = null; 1893 1894function setLifecycleRateChartOpen(isOpen) { 1895 if (!lifecycleRateChartModal) return; 1896 lifecycleRateChartModal.hidden = !isOpen; 1897 lifecycleRateChartModal.setAttribute('aria-hidden', String(!isOpen)); 1898} 1899 1900function lifecycleRateStatusHtml(text) { 1901 return `<div class="meta4k-chart-status">${text}</div>`; 1902} 1903 1904function formatRate(value) { 1905 const n = Number(value); 1906 if (!Number.isFinite(n)) return 'â'; 1907 return n.toLocaleString('pt-BR', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); 1908} 1909 1910/** Trailing-24h rate line chart (x = 12h buckets, y = events/user). */ 1911function renderLifecycleRateChart(points, metric) { 1912 const W = 800, H = 340; 1913 const PAD = { top: 16, right: 20, bottom: 44, left: 56 }; 1914 const plotW = W - PAD.left - PAD.right; 1915 const plotH = H - PAD.top - PAD.bottom; 1916 1917 const ts = points.map(p => p.t); 1918 const ys = points.map(p => Number(p[metric.key])); 1919 const tMin = Math.min(...ts), tMax = Math.max(...ts); 1920 let yMin = Math.min(...ys), yMax = Math.max(...ys); 1921 if (yMin === yMax) { yMin = Math.max(0, yMin - 0.1); yMax += 0.1; } 1922 const yPad = Math.max(0.05, (yMax - yMin) * 0.1); 1923 yMin = Math.max(0, yMin - yPad); 1924 yMax += yPad; 1925 1926 const x = t => tMax === tMin ? PAD.left + plotW / 2 : PAD.left + ((t - tMin) / (tMax - tMin)) * plotW; 1927 const y = v => PAD.top + (1 - (v - yMin) / (yMax - yMin)) * plotH; 1928 1929 const fmtDay = ms => { 1930 const d = new Date(ms); 1931 return `${String(d.getDate()).padStart(2, '0')}/${String(d.getMonth() + 1).padStart(2, '0')}`; 1932 }; 1933 1934 const coords = points.map(p => ({ 1935 cx: x(p.t), 1936 cy: y(Number(p[metric.key])), 1937 day: fmtDay(p.t), 1938 hour: `${String(new Date(p.t).getHours()).padStart(2, '0')}:00`, 1939 rate: Number(p[metric.key]), 1940 events: Number(p[metric.eventsKey]), 1941 users: Number(p.active_users), 1942 })); 1943 1944 let svg = `<svg viewBox="0 0 ${W} ${H}" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Gráfico ${metric.label}">`; 1945 1946 const yTicks = 5; 1947 for (let i = 0; i <= yTicks; i++) {
1948 const v = yMin + ((yMax - yMin) * i) / yTicks; 1949 const yy = y(v); 1950 svg += `<line x1="${PAD.left}" y1="${yy}" x2="${W - PAD.right}" y2="${yy}" stroke="rgba(255,255,255,0.07)" stroke-width="1"/>`; 1951 svg += `<text x="${PAD.left - 8}" y="${yy + 4}" text-anchor="end" font-size="11" fill="rgba(255,255,255,0.5)">${v.toFixed(2)}</text>`; 1952 } 1953 1954 for (const p of points) { 1955 const d = new Date(p.t); 1956 if (d.getHours() !== 0) continue; 1957 const xx = x(p.t); 1958 svg += `<line x1="${xx}" y1="${PAD.top}" x2="${xx}" y2="${H - PAD.bottom}" stroke="rgba(255,255,255,0.1)" stroke-width="1"/>`; 1959 svg += `<text x="${xx}" y="${H - PAD.bottom + 18}" text-anchor="middle" font-size="11" fill="rgba(255,255,255,0.55)">${fmtDay(p.t)}</text>`; 1960 } 1961 1962 const path = points.map((p, i) => `${i === 0 ? 'M' : 'L'}${x(p.t).toFixed(1)},${y(Number(p[metric.key])).toFixed(1)}`).join(' '); 1963 svg += `<path d="${path}" fill="none" stroke="${metric.color}" stroke-width="2.5" stroke-linejoin="round" stroke-linecap="round"/>`; 1964 for (let i = 0; i < coords.length; i++) { 1965 const c = coords[i]; 1966 svg += `<circle class="life-pt" data-i="${i}" cx="${c.cx.toFixed(1)}" cy="${c.cy.toFixed(1)}" r="3.5" fill="${metric.color}" stroke="#19191f" stroke-width="1.5"/>`; 1967 } 1968 svg += `<circle class="life-hover-ring" cx="0" cy="0" r="7" fill="none" stroke="#ffffff" stroke-width="2.5" opacity="0" pointer-events="none"/>`; 1969 svg += '</svg>'; 1970 return { 1971 html: `<div class="meta4k-chart-wrap"><div class="meta4k-chart-balloon" hidden></div>${svg}</div>`, 1972 coords,
1973 viewBox: { W, H }, 1974 metric, 1975 }; 1976} 1977 1978function bindLifecycleRateChartHover(container, chart) { 1979 const wrap = container.querySelector('.meta4k-chart-wrap'); 1980 const svg = container.querySelector('svg'); 1981 const balloon = container.querySelector('.meta4k-chart-balloon'); 1982 const ring = container.querySelector('.life-hover-ring'); 1983 if (!wrap || !svg || !balloon || !ring || !chart?.coords?.length) return; 1984 1985 const HIT_RADIUS = 28; 1986 let activeIdx = -1; 1987 1988 const clientToSvg = (clientX, clientY) => { 1989 const pt = svg.createSVGPoint(); 1990 pt.x = clientX; 1991 pt.y = clientY; 1992 const ctm = svg.getScreenCTM(); 1993 if (!ctm) return null; 1994 return pt.matrixTransform(ctm.inverse()); 1995 }; 1996 1997 const resetPt = (idx) => { 1998 const el = svg.querySelector(`.life-pt[data-i="${idx}"]`); 1999 if (el) { el.setAttribute('r', '3.5'); el.setAttribute('stroke', '#19191f'); el.setAttribute('stroke-width', '1.5'); } 2000 }; 2001 2002 const hide = () => { 2003 if (activeIdx >= 0) resetPt(activeIdx); 2004 activeIdx = -1; 2005 ring.setAttribute('opacity', '0'); 2006 balloon.hidden = true; 2007 balloon.classList.remove('visible'); 2008 }; 2009 2010 const show = (idx) => { 2011 const c = chart.coords[idx]; 2012 if (idx !== activeIdx) { 2013 if (activeIdx >= 0) resetPt(activeIdx); 2014 const cur = svg.querySelector(`.life-pt[data-i="${idx}"]`); 2015 if (cur) { cur.setAttribute('r', '5'); cur.setAttribute('stroke', '#ffffff'); cur.setAttribute('stroke-width', '2.5'); } 2016 activeIdx = idx; 2017 } 2018 2019 ring.setAttribute('cx', c.cx.toFixed(1)); 2020 ring.setAttribute('cy', c.cy.toFixed(1)); 2021 ring.setAttribute('opacity', '1'); 2022 2023 balloon.innerHTML = 2024 `<div class="meta4k-balloon-row"><span>Dia</span><strong>${c.day}</strong></div>` + 2025 `<div class="meta4k-balloon-row"><span>Hora</span><strong>${c.hour}</strong></div>` + 2026 `<div class="meta4k-balloon-row"><span>${chart.metric.label}</span><strong>${formatRate(c.rate)}</strong></div>` + 2027 `<div class="meta4k-balloon-row"><span>${chart.metric.eventsLabel}</span><strong>${c.events.toLocaleString('pt-BR')}</strong></div>` + 2028 `<div class="meta4k-balloon-row"><span>Users</span><strong>${c.users.toLocaleString('pt-BR')}</strong></div>`; 2029 balloon.hidden = false; 2030 balloon.classList.add('visible'); 2031 2032 const svgRect = svg.getBoundingClientRect(); 2033 const wrapRect = wrap.getBoundingClientRect(); 2034 const scaleX = svgRect.width / chart.viewBox.W; 2035 const scaleY = svgRect.height / chart.viewBox.H; 2036 const px = (svgRect.left - wrapRect.left) + c.cx * scaleX; 2037 const py = (svgRect.top - wrapRect.top) + c.cy * scaleY; 2038 const bw = balloon.offsetWidth || 140; 2039 const bh = balloon.offsetHeight || 90; 2040 let left = px - bw / 2; 2041 left = Math.max(4, Math.min(left, wrapRect.width - bw - 4)); 2042 balloon.style.left = `${left}px`; 2043 balloon.style.top = `${py - bh - 14}px`; 2044 }; 2045 2046 svg.addEventListener('mousemove', (e) => { 2047 const svgPt = clientToSvg(e.clientX, e.clientY); 2048 if (!svgPt) { hide(); return; } 2049 let bestIdx = -1; 2050 let bestDist = HIT_RADIUS; 2051 for (let i = 0; i < chart.coords.length; i++) { 2052 const c = chart.coords[i]; 2053 const dist = Math.hypot(c.cx - svgPt.x, c.cy - svgPt.y); 2054 if (dist < bestDist) { bestDist = dist; bestIdx = i; } 2055 } 2056 if (bestIdx < 0) hide(); 2057 else show(bestIdx); 2058 }); 2059 2060 svg.addEventListener('mouseleave', hide); 2061} 2062 2063async function openLifecycleRateChart(metricKey) { 2064 if (!lifecycleRateChartModal || !lifecycleRateChartBody) return; 2065 const metric = LIFECYCLE_RATE_METRICS[metricKey]; 2066 if (!metric) return; 2067 if (lifecycleRateChartTitle) { 2068 lifecycleRateChartTitle.textContent = `${metric.label} â últimos 14 dias`; 2069 lifecycleRateChartTitle.style.color = metric.color; 2070 } 2071 setLifecycleRateChartOpen(true); 2072 2073 const render = (data) => { 2074 const points = (data?.points || []).filter(p => 2075 Number.isFinite(Number(p[metric.key])) && Number.isFinite(Number(p.t))); 2076 if (points.length === 0) { 2077 lifecycleRateChartBody.innerHTML = lifecycleRateStatusHtml('Ainda não há dados suficientes para este gráfico.'); 2078 return; 2079 } 2080 const chart = renderLifecycleRateChart(points, metric); 2081 lifecycleRateChartBody.innerHTML = chart.html; 2082 bindLifecycleRateChartHover(lifecycleRateChartBody, chart); 2083 }; 2084 2085 if (lifecycleRateCache && lifecycleRateCacheCountry === analyticsCountry) { 2086 render(lifecycleRateCache); 2087 return; 2088 } 2089 2090 lifecycleRateChartBody.innerHTML = lifecycleRateStatusHtml('Carregandoâ¦'); 2091 try { 2092 const res = await fetchWithRetry(`${ANALYTICS_API_BASE}/admin_analytics/lifecycle_rate_history?country=${encodeURIComponent(analyticsCountry)}`, { 2093 headers: { 'Authorization': `Bearer ${adminToken}` }, 2094 signal: AbortSignal.timeout(60_000), 2095 });
2096 if (res.status === 401) { setLifecycleRateChartOpen(false); handleTokenExpired(); return; } 2097 if (!res.ok) throw new Error(`HTTP ${res.status}`); 2098 const data = await res.json(); 2099 lifecycleRateCache = data; 2100 lifecycleRateCacheCountry = analyticsCountry; 2101 render(data); 2102 } catch (e) { 2103 console.error(`${metric.label} history fetch error:`, e); 2104 lifecycleRateChartBody.innerHTML = lifecycleRateStatusHtml('Erro ao carregar o histórico. Tente novamente.'); 2105 } 2106} 2107 2108function bindLifecycleRateCard(card, metricKey) { 2109 if (!card) return; 2110 card.addEventListener('click', (e) => { 2111 if (e.target.closest('.card-help-btn')) return; 2112 openLifecycleRateChart(metricKey); 2113 }); 2114 card.addEventListener('keydown', (e) => { 2115 if (e.key === 'Enter' || e.key === ' ') { 2116 e.preventDefault(); 2117 openLifecycleRateChart(metricKey); 2118 } 2119 }); 2120} 2121bindLifecycleRateCard(sgRateCard, 'sg'); 2122bindLifecycleRateCard(lmwRateCard, 'lmw'); 2123 2124if (lifecycleRateChartCloseBtn) { 2125 lifecycleRateChartCloseBtn.addEventListener('click', () => setLifecycleRateChartOpen(false)); 2126} 2127if (lifecycleRateChartModal) { 2128 lifecycleRateChartModal.addEventListener('click', (e) => { 2129 if (e.target === lifecycleRateChartModal) setLifecycleRateChartOpen(false); 2130 }); 2131 document.addEventListener('keydown', (e) => { 2132 if (e.key === 'Escape' && !lifecycleRateChartModal.hidden) setLifecycleRateChartOpen(false); 2133 }); 2134} 2135 2136// Country filter (BR default). BR-only cards (No 99 App, Non-free 24h, 2137// 30|90|Lifetime, Total Votes, Consolidated, RodÃzio) are hidden when US is 2138// selected; US-only cards (Tracked Drives / Last 7 days / Avg. drives/user / 2139// Total tracked / Increased miles 7d / 30 tracked expired) are hidden on BR. 2140// ACTIVE SUBS stacks Pro vs Mileage Pro on US and keeps the combined line on BR. 2141// The entitlement-goal card retitles itself (Meta 5K â Meta 100). 2142function applyAnalyticsCountryVisibility() { 2143 const isUs = analyticsCountry === 'US'; 2144 document.querySelectorAll('#admin-page-analytics .br-only-card').forEach(card => { 2145 card.style.display = isUs ? 'none' : ''; 2146 }); 2147 document.querySelectorAll('#admin-page-analytics .us-only-card').forEach(card => { 2148 card.style.display = isUs ? '' : 'none'; 2149 }); 2150 const combinedSubs = document.getElementById('stat-total-subs'); 2151 const proSubs = document.getElementById('stat-total-subs-pro'); 2152 const mileageProSubs = document.getElementById('stat-total-subs-mileage-pro'); 2153 if (combinedSubs) combinedSubs.style.display = isUs ? 'none' : ''; 2154 if (proSubs) proSubs.style.display = isUs ? 'block' : 'none'; 2155 if (mileageProSubs) mileageProSubs.style.display = isUs ? 'block' : 'none'; 2156 document.querySelectorAll('#admin-page-analytics .card-help-btn[data-tooltip-us]').forEach(btn => { 2157 if (!btn.dataset.tooltipBr) btn.dataset.tooltipBr = btn.getAttribute('data-tooltip') || ''; 2158 btn.setAttribute('data-tooltip', isUs ? btn.dataset.tooltipUs : btn.dataset.tooltipBr); 2159 }); 2160 applyMetaGoalCountry(); 2161} 2162 2163document.querySelectorAll('input[name="analytics-country"]').forEach(radio => { 2164 radio.addEventListener('change', () => { 2165 if (!radio.checked || radio.value === analyticsCountry) return; 2166 analyticsCountry = radio.value === 'US' ? 'US' : 'BR'; 2167 applyAnalyticsCountryVisibility(); 2168 analyticsLoaded = false; 2169 lifecycleRateCache = null; 2170 lifecycleRateCacheCountry = null; 2171 activeSubsHistoryCache = null; 2172 activeSubsHistoryCacheCountry = null; 2173 cpiHistoryCache = null; 2174 cpiHistoryCacheCountry = null; 2175 firstPagHistoryCache = null; 2176 firstPagHistoryCacheCountry = null; 2177 const cpiValueEl = document.getElementById('stat-cpi-7d'); 2178 if (cpiValueEl) cpiValueEl.textContent = 'â'; 2179 fetchAnalytics(); 2180 }); 2181}); 2182 2183// Event Listeners 2184function checkHashForAdmin() { 2185 if (window.location.hash === '#admin-console') { 2186 if (adminToken) { 2187 // Token exists in localStorage â skip login and go straight to dashboard 2188 setAdminModalOpen(true); 2189 showDashboard(); 2190 } else { 2191 openModal(); 2192 } 2193 } 2194} 2195 2196// Run on page load 2197document.addEventListener('DOMContentLoaded', checkHashForAdmin); 2198// Also run immediately in case it's already loaded 2199checkHashForAdmin(); 2200 2201// Listen for hash changes so it works without reloading the page 2202window.addEventListener('hashchange', checkHashForAdmin); 2203 2204if (closeModalBtn) { 2205 closeModalBtn.addEventListener('click', closeModal); 2206} 2207 2208const adminLoginForm = document.getElementById('admin-login-form'); 2209if (adminLoginForm) { 2210 adminLoginForm.addEventListener('submit', function (e) { 2211 e.preventDefault(); 2212 verifyAdmin(); 2213 }); 2214} 2215 2216// Remove dismiss to outside click, so you don't lose typed form data accidentaly. 2217 2218// --- Autenticação OAuth2 Service Account via Web Crypto API --- 2219function pemToArrayBuffer(pem) { 2220 const b64Lines = pem.replace(/-----BEGIN PRIVATE KEY-----/g, '') 2221 .replace(/-----END PRIVATE KEY-----/g, '') 2222 .replace(/\n/g, ''); 2223 const b64Str = b64Lines.replace(/\s+/g, ''); 2224 const binary = atob(b64Str); 2225 const len = binary.length; 2226 const bytes = new Uint8Array(len); 2227 for (let i = 0; i < len; i++) {
2228 bytes[i] = binary.charCodeAt(i); 2229 } 2230 return bytes.buffer; 2231} 2232 2233function base64urlEncode(source) { 2234 let str = typeof source === 'string' ? source : JSON.stringify(source); 2235 let encoded = btoa(unescape(encodeURIComponent(str))); 2236 return encoded.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); 2237} 2238 2239async function createSignedJWT(serviceAccountJson, scopes) { 2240 const header = { alg: "RS256", typ: "JWT" }; 2241 const now = Math.floor(Date.now() / 1000); 2242 const payload = { 2243 iss: serviceAccountJson.client_email, 2244 scope: scopes || "https://www.googleapis.com/auth/firebase.messaging", 2245 aud: "https://oauth2.googleapis.com/token", 2246 exp: now + 3600, 2247 iat: now 2248 }; 2249 2250 const unsignedToken = `${base64urlEncode(header)}.${base64urlEncode(payload)}`; 2251 2252 const keyData = pemToArrayBuffer(serviceAccountJson.private_key); 2253 const privateKey = await crypto.subtle.importKey( 2254 "pkcs8", 2255 keyData, 2256 { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, 2257 false, 2258 ["sign"] 2259 ); 2260 2261 const encoder = new TextEncoder(); 2262 const signature = await crypto.subtle.sign( 2263 "RSASSA-PKCS1-v1_5", 2264 privateKey, 2265 encoder.encode(unsignedToken) 2266 ); 2267 2268 const signatureBytes = new Uint8Array(signature); 2269 let signatureString = ''; 2270 for (let i = 0; i < signatureBytes.length; i++) { 2271 signatureString += String.fromCharCode(signatureBytes[i]); 2272 } 2273 const signatureEncoded = btoa(signatureString).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); 2274 2275 return `${unsignedToken}.${signatureEncoded}`; 2276} 2277 2278async function getAccessToken(serviceAccountJson, extraScopes) { 2279 // GCS rejects tokens that also carry firebase.messaging ("Provided scope(s) 2280 // are not authorized"). Request one API's scopes at a time. 2281 const scopes = (extraScopes && extraScopes.length) 2282 ? extraScopes.join(' ') 2283 : 'https://www.googleapis.com/auth/firebase.messaging'; 2284 const jwt = await createSignedJWT(serviceAccountJson, scopes); 2285 const response = await fetch('https://oauth2.googleapis.com/token', { 2286 method: 'POST', 2287 headers: { 2288 'Content-Type': 'application/x-www-form-urlencoded' 2289 }, 2290 body: new URLSearchParams({ 2291 grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', 2292 assertion: jwt 2293 }) 2294 }); 2295 2296 if (!response.ok) { 2297 throw new Error("Falha ao obter token de acesso do Google OAuth2."); 2298 } 2299 const data = await response.json(); 2300 return data.access_token; 2301} 2302 2303// Custom 63:40 FCM thumbnails â sizes match CustomThumbnailNotifier.kt on the app. 2304const FCM_THUMB_ASPECT = 63 / 40; 2305const FCM_THUMB_COLLAPSED = { w: 378, h: 240 }; 2306const FCM_THUMB_EXPANDED = { w: 756, h: 480 }; 2307const FCM_THUMB_EXPANDED_RADIUS_PX = 36; 2308const FCM_STORAGE_BUCKET = 'km-top.firebasestorage.app'; 2309const FCM_STORAGE_PREFIX = 'jetmax stuff/fcm_custom_notifications'; 2310const FCM_THUMB_KEY_COLLAPSED = 'thumb_c'; 2311const FCM_THUMB_KEY_EXPANDED = 'thumb_e'; 2312const FCM_RAISE_COPY_KEY = 'raise_copy'; 2313 2314function isFcmCustomEnabled() { 2315 return document.getElementById('fcm-custom-enabled')?.checked === true; 2316} 2317 2318function isFcmRaiseCopyEnabled() { 2319 return document.getElementById('fcm-raise-copy')?.checked === true; 2320} 2321 2322function syncFcmRaiseCopyPreview() { 2323 document.getElementById('fcm-custom-image-preview') 2324 ?.classList.toggle('is-raised', isFcmRaiseCopyEnabled()); 2325} 2326 2327function fcmThumbSourceCrop(imgW, imgH) { 2328 const src = imgW / imgH; 2329 if (src > FCM_THUMB_ASPECT) { 2330 const w = imgH * FCM_THUMB_ASPECT; 2331 return { sx: (imgW - w) / 2, sy: 0, sw: w, sh: imgH }; 2332 } 2333 const h = imgW / FCM_THUMB_ASPECT; 2334 return { sx: 0, sy: (imgH - h) / 2, sw: imgW, sh: h }; 2335} 2336 2337function fcmRoundRectPath(ctx, w, h, r) { 2338 const radius = Math.min(r, w / 2, h / 2); 2339 ctx.beginPath(); 2340 ctx.moveTo(radius, 0); 2341 ctx.arcTo(w, 0, w, h, radius); 2342 ctx.arcTo(w, h, 0, h, radius); 2343 ctx.arcTo(0, h, 0, 0, radius); 2344 ctx.arcTo(0, 0, w, 0, radius); 2345 ctx.closePath(); 2346} 2347 2348function loadImageFromFile(file) { 2349 return new Promise((resolve, reject) => { 2350 const url = URL.createObjectURL(file); 2351 const img = new Image(); 2352 img.onload = () => { 2353 URL.revokeObjectURL(url); 2354 resolve(img); 2355 }; 2356 img.onerror = () => { 2357 URL.revokeObjectURL(url); 2358 reject(new Error('Não foi possÃvel ler a imagem selecionada.')); 2359 }; 2360 img.src = url; 2361 }); 2362} 2363 2364function encodeFcmThumb(img, width, height, roundPx) { 2365 const canvas = document.createElement('canvas'); 2366 canvas.width = width; 2367 canvas.height = height; 2368 const ctx = canvas.getContext('2d');
2369 const crop = fcmThumbSourceCrop(img.naturalWidth || img.width, img.naturalHeight || img.height); 2370 if (roundPx > 0) { 2371 fcmRoundRectPath(ctx, width, height, roundPx); 2372 ctx.clip(); 2373 } 2374 ctx.drawImage(img, crop.sx, crop.sy, crop.sw, crop.sh, 0, 0, width, height); 2375 return new Promise((resolve, reject) => { 2376 canvas.toBlob( 2377 (blob) => blob ? resolve(blob) : reject(new Error('Este navegador não consegue exportar WebP. Use Chrome ou Edge.')), 2378 'image/webp', 2379 0.82 2380 ); 2381 }); 2382} 2383 2384async function uploadFcmThumbToStorage(accessToken, objectPath, blob, downloadToken) { 2385 const boundary = `jetmax_thumb_${Date.now()}_${Math.random().toString(16).slice(2)}`; 2386 const metadata = { 2387 name: objectPath, 2388 contentType: 'image/webp', 2389 cacheControl: 'public,max-age=31536000', 2390 metadata: { firebaseStorageDownloadTokens: downloadToken }, 2391 }; 2392 const metaPart = `--${boundary}\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n${JSON.stringify(metadata)}\r\n`; 2393 const fileHeader = `--${boundary}\r\nContent-Type: image/webp\r\n\r\n`; 2394 const closer = `\r\n--${boundary}--`; 2395 const fileBytes = new Uint8Array(await blob.arrayBuffer()); 2396 const encoder = new TextEncoder(); 2397 const metaBytes = encoder.encode(metaPart); 2398 const headerBytes = encoder.encode(fileHeader); 2399 const closerBytes = encoder.encode(closer); 2400 const body = new Uint8Array(metaBytes.length + headerBytes.length + fileBytes.length + closerBytes.length); 2401 body.set(metaBytes, 0); 2402 body.set(headerBytes, metaBytes.length); 2403 body.set(fileBytes, metaBytes.length + headerBytes.length); 2404 body.set(closerBytes, metaBytes.length + headerBytes.length + fileBytes.length); 2405 2406 const uploadUrl = `https://storage.googleapis.com/upload/storage/v1/b/${FCM_STORAGE_BUCKET}/o?uploadType=multipart`; 2407 const uploadRes = await fetch(uploadUrl, { 2408 method: 'POST', 2409 headers: { 2410 Authorization: `Bearer ${accessToken}`, 2411 'Content-Type': `multipart/related; boundary=${boundary}`, 2412 }, 2413 body, 2414 }); 2415 if (!uploadRes.ok) { 2416 const err = await uploadRes.text(); 2417 throw new Error(`Falha no upload para o Storage (${uploadRes.status}). Confira se a Service Account tem permissão de Storage. ${err.slice(0, 180)}`); 2418 } 2419 return `https://firebasestorage.googleapis.com/v0/b/${FCM_STORAGE_BUCKET}/o/${encodeURIComponent(objectPath)}?alt=media&token=${downloadToken}`; 2420} 2421 2422function newFcmDownloadToken() { 2423 if (crypto.randomUUID) return crypto.randomUUID(); 2424 const bytes = new Uint8Array(16); 2425 crypto.getRandomValues(bytes); 2426 bytes[6] = (bytes[6] & 0x0f) | 0x40; 2427 bytes[8] = (bytes[8] & 0x3f) | 0x80; 2428 const hex = [...bytes].map((b) => b.toString(16).padStart(2, '0')).join(''); 2429 return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; 2430} 2431 2432function syncFcmCustomPreviewCopy() { 2433 const title = document.getElementById('fcm-title')?.value || 'TÃtulo'; 2434 const body = fcmPreviewSubtitle(); 2435 const ids = ['fcm-preview-title', 'fcm-preview-title-exp', 'fcm-preview-sub', 'fcm-preview-sub-exp']; 2436 const values = [title, title, body, body]; 2437 ids.forEach((id, i) => { 2438 const el = document.getElementById(id); 2439 if (el) el.textContent = values[i]; 2440 }); 2441} 2442 2443function fcmPreviewSubtitle() { 2444 const body = document.getElementById('fcm-body')?.value || ''; 2445 const action = document.getElementById('fcm-action')?.value; 2446 if (action === 'open_browser' || action === 'open_whatsapp_support') { 2447 try { 2448 const parsed = JSON.parse(body); 2449 return parsed.description || parsed.message || body || 'SubtÃtulo'; 2450 } catch { 2451 return body || 'SubtÃtulo'; 2452 } 2453 } 2454 return body || 'SubtÃtulo'; 2455} 2456 2457let fcmCustomPreviewUrls = []; 2458 2459function revokeFcmCustomPreviewUrls() { 2460 fcmCustomPreviewUrls.forEach((url) => URL.revokeObjectURL(url)); 2461 fcmCustomPreviewUrls = []; 2462} 2463 2464async function refreshFcmCustomImagePreview(file) { 2465 const preview = document.getElementById('fcm-custom-image-preview'); 2466 const collapsedImg = document.getElementById('fcm-preview-collapsed-img'); 2467 const expandedImg = document.getElementById('fcm-preview-expanded-img'); 2468 if (!preview || !collapsedImg || !expandedImg) return; 2469 if (!file) { 2470 preview.hidden = true; 2471 revokeFcmCustomPreviewUrls(); 2472 return; 2473 } 2474 const img = await loadImageFromFile(file); 2475 const [collapsedBlob, expandedBlob] = await Promise.all([ 2476 encodeFcmThumb(img, FCM_THUMB_COLLAPSED.w, FCM_THUMB_COLLAPSED.h, 0), 2477 encodeFcmThumb(img, FCM_THUMB_EXPANDED.w, FCM_THUMB_EXPANDED.h, FCM_THUMB_EXPANDED_RADIUS_PX), 2478 ]); 2479 revokeFcmCustomPreviewUrls(); 2480 const collapsedUrl = URL.createObjectURL(collapsedBlob); 2481 const expandedUrl = URL.createObjectURL(expandedBlob); 2482 fcmCustomPreviewUrls = [collapsedUrl, expandedUrl]; 2483 collapsedImg.src = collapsedUrl; 2484 expandedImg.src = expandedUrl; 2485 syncFcmCustomPreviewCopy(); 2486 syncFcmRaiseCopyPreview(); 2487 preview.hidden = false;
2488} 2489 2490function setFcmCustomImageGroupVisible(visible) { 2491 const group = document.getElementById('fcm-custom-image-group'); 2492 if (!group) return; 2493 group.style.display = visible ? 'flex' : 'none'; 2494 if (!visible) { 2495 const input = document.getElementById('fcm-custom-image'); 2496 if (input) input.value = ''; 2497 refreshFcmCustomImagePreview(null).catch(() => {}); 2498 } 2499} 2500 2501// FCM Form Submit 2502if (fcmForm) { 2503 // Target mode toggle (Token vs Topic) 2504 const fcmTargetMode = document.getElementById('fcm-target-mode'); 2505 const fcmTokenGroup = document.getElementById('fcm-token-group'); 2506 const fcmTopicGroup = document.getElementById('fcm-topic-group'); 2507 const fcmConditionGroup = document.getElementById('fcm-condition-group'); 2508 if (fcmTargetMode) { 2509 fcmTargetMode.addEventListener('change', () => { 2510 fcmTokenGroup.style.display = 'none'; 2511 fcmTopicGroup.style.display = 'none'; 2512 fcmConditionGroup.style.display = 'none'; 2513 if (fcmTargetMode.value === 'token') { 2514 fcmTokenGroup.style.display = 'flex'; 2515 } else if (fcmTargetMode.value === 'condition') { 2516 fcmConditionGroup.style.display = 'block'; 2517 updateConditionPreview(); 2518 } else { 2519 fcmTopicGroup.style.display = 'flex'; 2520 } 2521 }); 2522 } 2523 2524 // --- Topic Condition Builder --- 2525 const conditionRowsContainer = document.getElementById('condition-rows'); 2526 const conditionAddBtn = document.getElementById('condition-add-btn'); 2527 const conditionPreviewCode = document.getElementById('condition-preview-code'); 2528 2529 const TOPIC_OPTIONS = [ 2530 'debug_or_dev_device', 'all_users', 'anonymous_users', 'registered_users', 2531 'is_free_user', 'is_not_free_user', 'is_subscriber', 'is_not_subscriber', 2532 'is_lifetime', 'is_recharged', 'is_in_metro_sp', 2533 'have_competitor_app', 'had_competitor_app', 'has_keeta_installed', 2534 'country_br', 'country_us', 'country_ar', 'country_es', 'country_mx', 2535 'country_uy', 'country_fr', 'country_pt', 2536 '__version__' 2537 ]; 2538 const MAX_CONDITION_TOPICS = 5; 2539 2540 // --- Version Picker Helpers --- 2541 const currentYear = new Date().getFullYear() % 100; // e.g., 26 2542 const currentMonth = new Date().getMonth() + 1; // 1-12 2543 2544 function populateVersionSelect(selectEl, min, max) { 2545 selectEl.innerHTML = ''; 2546 for (let i = min; i <= max; i++) { 2547 const opt = document.createElement('option'); 2548 opt.value = i; 2549 opt.textContent = i; 2550 selectEl.appendChild(opt); 2551 } 2552 } 2553 2554 function composeVersionTopic(yearEl, monthEl, patchEl) { 2555 return `version_${yearEl.value}_${monthEl.value}_${patchEl.value}`; 2556 } 2557 2558 // Initialize single-topic version picker 2559 const fcmVersionPicker = document.getElementById('fcm-version-picker'); 2560 const fcmVersionYear = document.getElementById('fcm-version-year'); 2561 const fcmVersionMonth = document.getElementById('fcm-version-month'); 2562 const fcmVersionPatch = document.getElementById('fcm-version-patch'); 2563 const fcmVersionResult = document.getElementById('fcm-version-result'); 2564 2565 function updateSingleVersionResult() { 2566 if (fcmVersionResult && fcmVersionYear && fcmVersionMonth && fcmVersionPatch) { 2567 fcmVersionResult.textContent = composeVersionTopic(fcmVersionYear, fcmVersionMonth, fcmVersionPatch); 2568 } 2569 } 2570 2571 if (fcmVersionYear && fcmVersionMonth && fcmVersionPatch) { 2572 populateVersionSelect(fcmVersionYear, currentYear, currentYear + 1); 2573 populateVersionSelect(fcmVersionMonth, 1, 12); 2574 fcmVersionMonth.value = currentMonth; 2575 populateVersionSelect(fcmVersionPatch, 1, 20); 2576 fcmVersionYear.addEventListener('change', updateSingleVersionResult); 2577 fcmVersionMonth.addEventListener('change', updateSingleVersionResult); 2578 fcmVersionPatch.addEventListener('change', updateSingleVersionResult); 2579 updateSingleVersionResult(); 2580 } 2581 2582 // Show/hide version picker when single topic changes 2583 const fcmTopicSelect = document.getElementById('fcm-topic'); 2584 if (fcmTopicSelect && fcmVersionPicker) { 2585 fcmTopicSelect.addEventListener('change', () => { 2586 fcmVersionPicker.style.display = fcmTopicSelect.value === '__version__' ? 'flex' : 'none'; 2587 }); 2588 } 2589 2590 function buildTopicSelectHTML() { 2591 return TOPIC_OPTIONS.map(t => { 2592 const label = t === '__version__' ? 'â¡ version' : t; 2593 return `<option value="${t}">${label}</option>`; 2594 }).join(''); 2595 } 2596 2597 function getConditionRowCount() { 2598 return conditionRowsContainer ? conditionRowsContainer.querySelectorAll('.condition-row').length : 0; 2599 } 2600 2601 function updateAddBtnState() { 2602 if (conditionAddBtn) { 2603 conditionAddBtn.disabled = getConditionRowCount() >= MAX_CONDITION_TOPICS; 2604 } 2605 } 2606 2607 function resolveRowTopic(row) { 2608 const topicSelect = row.querySelector('.condition-topic'); 2609 if (!topicSelect) return ''; 2610 if (topicSelect.value === '__version__') { 2611 const vp = row.querySelector('.condition-version-picker'); 2612 if (vp) { 2613 const [y, m, p] = vp.querySelectorAll('.version-select'); 2614 return `version_${y.value}_${m.value}_${p.value}`; 2615 } 2616 return 'version_0_0_0'; 2617 } 2618 return topicSelect.value; 2619 } 2620 2621 function updateConditionPreview() { 2622 if (!conditionPreviewCode || !conditionRowsContainer) return; 2623 const rows = conditionRowsContainer.querySelectorAll('.condition-row'); 2624 if (rows.length === 0) { 2625 conditionPreviewCode.textContent = ''; 2626 return; 2627 } 2628 let parts = []; 2629 rows.forEach((row, i) => { 2630 const negate = row.querySelector('.condition-negate')?.checked || false; 2631 const topic = resolveRowTopic(row); 2632 let expr = `'${topic}' in topics`; 2633 if (negate) expr = `!(${expr})`; 2634 2635 if (i === 0) { 2636 parts.push(expr); 2637 } else { 2638 const op = row.querySelector('.condition-operator'); 2639 const operator = op ? op.value : '&&'; 2640 parts.push(` ${operator} ${expr}`); 2641 } 2642 }); 2643 conditionPreviewCode.textContent = parts.join(''); 2644 } 2645 2646 function injectVersionPicker(row) { 2647 const inner = row.querySelector('.condition-row-inner'); 2648 const removeBtn = row.querySelector('.condition-remove-btn'); 2649 // Remove existing picker if any 2650 const existing = row.querySelector('.condition-version-picker'); 2651 if (existing) existing.remove(); 2652 2653 const picker = document.createElement('div'); 2654 picker.className = 'condition-version-picker'; 2655 picker.innerHTML = ` 2656 <select class="version-select"></select>
2657 <span class="version-dot">.</span> 2658 <select class="version-select"></select> 2659 <span class="version-dot">.</span> 2660 <select class="version-select"></select> 2661 `; 2662 inner.insertBefore(picker, removeBtn); 2663 2664 const [yearSel, monthSel, patchSel] = picker.querySelectorAll('.version-select'); 2665 populateVersionSelect(yearSel, currentYear, currentYear + 1); 2666 populateVersionSelect(monthSel, 1, 12); 2667 monthSel.value = currentMonth; 2668 populateVersionSelect(patchSel, 1, 20); 2669 yearSel.addEventListener('change', updateConditionPreview); 2670 monthSel.addEventListener('change', updateConditionPreview); 2671 patchSel.addEventListener('change', updateConditionPreview); 2672 } 2673 2674 function removeVersionPicker(row) { 2675 const existing = row.querySelector('.condition-version-picker'); 2676 if (existing) existing.remove(); 2677 } 2678 2679 function handleTopicChange(row) { 2680 const topicSelect = row.querySelector('.condition-topic'); 2681 if (topicSelect.value === '__version__') { 2682 injectVersionPicker(row); 2683 } else { 2684 removeVersionPicker(row); 2685 } 2686 updateConditionPreview(); 2687 } 2688 2689 function addConditionRow() { 2690 if (getConditionRowCount() >= MAX_CONDITION_TOPICS) return; 2691 const index = getConditionRowCount(); 2692 const row = document.createElement('div'); 2693 row.className = 'condition-row'; 2694 row.dataset.index = index; 2695 row.innerHTML = ` 2696 <div class="condition-row-inner"> 2697 <select class="condition-operator"> 2698 <option value="&&">AND</option> 2699 <option value="||">OR</option> 2700 </select> 2701 <label class="condition-negate-label" title="NOT â user must NOT be subscribed"> 2702 <input type="checkbox" class="condition-negate"> 2703 <span class="condition-negate-badge">NOT</span> 2704 </label> 2705 <select class="condition-topic">${buildTopicSelectHTML()}</select> 2706 <button type="button" class="condition-remove-btn" title="Remove">â</button> 2707 </div> 2708 `; 2709 conditionRowsContainer.appendChild(row); 2710 2711 // Bind remove 2712 row.querySelector('.condition-remove-btn').addEventListener('click', () => { 2713 row.remove(); 2714 updateAddBtnState(); 2715 updateConditionPreview(); 2716 }); 2717 2718 // Bind live preview updates 2719 row.querySelector('.condition-operator').addEventListener('change', updateConditionPreview); 2720 row.querySelector('.condition-negate').addEventListener('change', updateConditionPreview); 2721 row.querySelector('.condition-topic').addEventListener('change', () => handleTopicChange(row)); 2722 2723 updateAddBtnState(); 2724 updateConditionPreview(); 2725 } 2726 2727 if (conditionAddBtn) { 2728 conditionAddBtn.addEventListener('click', addConditionRow); 2729 } 2730 2731 // Bind live preview for the initial row 2732 if (conditionRowsContainer) { 2733 const firstRow = conditionRowsContainer.querySelector('.condition-row'); 2734 if (firstRow) { 2735 firstRow.querySelector('.condition-negate')?.addEventListener('change', updateConditionPreview); 2736 firstRow.querySelector('.condition-topic')?.addEventListener('change', () => handleTopicChange(firstRow)); 2737 } 2738 updateConditionPreview(); 2739 } 2740 2741 // Auto-fill body based on selected action 2742 const fcmActionInput = document.getElementById('fcm-action'); 2743 const fcmBodyInput = document.getElementById('fcm-body'); 2744 if (fcmActionInput && fcmBodyInput) { 2745 const actionDefaults = { 2746 'open_app': { body: 'Abra o JetMAX para continuar.', placeholder: 'Ex: Abra o app para ver as novidades' }, 2747 'open_mileage_tab': { body: 'Confira suas milhas no Mileage Tracking.', placeholder: 'Ex: Veja suas corridas no Mileage Tracking' }, 2748 'open_browser': { body: '{"url": "https://", "description": ""}', placeholder: '{"url": "https://...", "description": "Tap to learn more"}' }, 2749 'open_play_store': { body: 'Avalie o JetMAX na Play Store! â', placeholder: 'Ex: Avalie o JetMAX na Play Store!' }, 2750 'navigate_to_danger_zone_map': { body: 'Confira as áreas de risco ao seu redor no mapa!', placeholder: 'Ex: Confira as áreas de risco ao seu redor!' }, 2751 'share_app_cta': { body: 'Convide seus amigos e ganhe benefÃcios!', placeholder: 'Ex: Convide seus amigos!' },
2752 'open_whatsapp_support': { 2753 body: JSON.stringify({ 2754 url: 'https://wa.me/5511914203562', 2755 message: 'Oi, eu tenho prints de quando toca a Keeta, gostaria de enviar', 2756 description: 'Fale com o suporte no WhatsApp', 2757 }), 2758 placeholder: '{"url": "https://wa.me/...", "message": "...", "description": "..."}', 2759 }, 2760 'launch_subs_screen': { body: 'Aproveite e adquira o JetMAX Pro! ð', placeholder: 'Ex: Adquira o JetMAX Pro!' }, 2761 'welcome_video': { 2762 body: 'Assista ao vÃdeo explicativo e comece a usar o JetMAX agora!', 2763 placeholder: 'Ex: Assista ao vÃdeo explicativo e comece a usar o JetMAX agora!', 2764 }, 2765 }; 2766 2767 fcmActionInput.addEventListener('change', () => { 2768 const preset = actionDefaults[fcmActionInput.value]; 2769 if (preset) { 2770 fcmBodyInput.value = preset.body; 2771 fcmBodyInput.placeholder = preset.placeholder; 2772 } else { 2773 fcmBodyInput.placeholder = 'Ex: There is a danger zone nearby'; 2774 } 2775 syncFcmCustomPreviewCopy(); 2776 }); 2777 } 2778 2779 const fcmCustomEnabled = document.getElementById('fcm-custom-enabled'); 2780 if (fcmCustomEnabled) { 2781 const syncCustomImageGroup = () => setFcmCustomImageGroupVisible(fcmCustomEnabled.checked); 2782 fcmCustomEnabled.addEventListener('change', syncCustomImageGroup); 2783 syncCustomImageGroup(); 2784 } 2785 2786 const fcmRaiseCopy = document.getElementById('fcm-raise-copy'); 2787 if (fcmRaiseCopy) { 2788 fcmRaiseCopy.addEventListener('change', syncFcmRaiseCopyPreview); 2789 syncFcmRaiseCopyPreview(); 2790 } 2791 2792 const fcmCustomImageInput = document.getElementById('fcm-custom-image'); 2793 if (fcmCustomImageInput) { 2794 fcmCustomImageInput.addEventListener('change', () => { 2795 const file = fcmCustomImageInput.files && fcmCustomImageInput.files[0]; 2796 refreshFcmCustomImagePreview(file || null).catch((err) => { 2797 console.error(err); 2798 if (fcmStatus) { 2799 fcmStatus.style.color = '#ff4b4b'; 2800 fcmStatus.textContent = err.message || 'Falha ao processar a imagem.'; 2801 } 2802 }); 2803 }); 2804 } 2805 document.getElementById('fcm-title')?.addEventListener('input', syncFcmCustomPreviewCopy); 2806 document.getElementById('fcm-body')?.addEventListener('input', syncFcmCustomPreviewCopy); 2807 2808 fcmForm.addEventListener('submit', async (e) => { 2809 e.preventDefault(); 2810 2811 const targetMode = document.getElementById('fcm-target-mode').value; 2812 const token = document.getElementById('fcm-token').value; 2813 let topic = document.getElementById('fcm-topic').value; 2814 // Resolve version topic for single-topic mode 2815 if (topic === '__version__' && fcmVersionYear && fcmVersionMonth && fcmVersionPatch) { 2816 topic = composeVersionTopic(fcmVersionYear, fcmVersionMonth, fcmVersionPatch); 2817 } 2818 const title = document.getElementById('fcm-title').value; 2819 const body = document.getElementById('fcm-body').value; 2820 const performAction = document.getElementById('fcm-action').value; 2821 2822 const saFileInput = document.getElementById('fcm-sa-file'); 2823 2824 // Limpar status 2825 fcmStatus.textContent = ''; 2826 fcmStatus.style.color = '#fff'; 2827 2828 // Validate target 2829 if (targetMode === 'token' && !token.trim()) { 2830 fcmStatus.style.color = '#ff4b4b'; 2831 fcmStatus.textContent = 'Erro: Informe o Device Token.'; 2832 return; 2833 } 2834 2835 // Build condition string for condition mode 2836 let conditionString = ''; 2837 if (targetMode === 'condition') { 2838 conditionString = document.getElementById('condition-preview-code')?.textContent || ''; 2839 if (!conditionString.trim()) { 2840 fcmStatus.style.color = '#ff4b4b'; 2841 fcmStatus.textContent = 'Erro: A condição de topics está vazia.'; 2842 return; 2843 } 2844 } 2845 2846 // Validate JSON body when open_browser action is selected 2847 if (performAction === 'open_browser') { 2848 try { 2849 const parsed = JSON.parse(body); 2850 if (!parsed.url) throw new Error('missing url'); 2851 const urlObj = new URL(parsed.url); 2852 if (urlObj.protocol !== 'http:' && urlObj.protocol !== 'https:') throw new Error('invalid protocol'); 2853 if (!urlObj.hostname || !urlObj.hostname.includes('.')) throw new Error('invalid hostname'); 2854 } catch { 2855 fcmStatus.style.color = '#ff4b4b'; 2856 fcmStatus.textContent = 'Erro: Body deve ser JSON válido com "url" (ex: {"url": "https://...", "description": "..."}).'; 2857 return; 2858 } 2859 } 2860 2861 if (performAction === 'open_whatsapp_support') { 2862 try { 2863 const parsed = JSON.parse(body); 2864 if (!parsed.url) throw new Error('missing url'); 2865 if (!parsed.message) throw new Error('missing message'); 2866 const urlObj = new URL(parsed.url); 2867 if (urlObj.protocol !== 'http:' && urlObj.protocol !== 'https:') throw new Error('invalid protocol'); 2868 } catch { 2869 fcmStatus.style.color = '#ff4b4b'; 2870 fcmStatus.textContent = 'Erro: Body deve ser JSON com "url" e "message" (ex: {"url": "https://wa.me/...", "message": "...", "description": "..."}).'; 2871 return; 2872 } 2873 } 2874 2875 if (isFcmCustomEnabled()) { 2876 const imageInput = document.getElementById('fcm-custom-image'); 2877 if (!imageInput?.files || imageInput.files.length === 0) { 2878 fcmStatus.style.color = '#ff4b4b'; 2879 fcmStatus.textContent = 'Erro: Anexe uma imagem para a
2879notificação customizada.'; 2880 return; 2881 } 2882 } 2883 2884 if (!saFileInput.files || saFileInput.files.length === 0) { 2885 fcmStatus.style.color = '#ff4b4b'; 2886 fcmStatus.textContent = "Erro: Selecione o arquivo Service Account JSON."; 2887 return; 2888 } 2889 2890 try { 2891 fcmBtnText.style.display = 'none'; 2892 fcmSpinner.style.display = 'block'; 2893 document.getElementById('fcm-send-btn').disabled = true; 2894 2895 const fileText = await saFileInput.files[0].text(); 2896 let serviceAccountJson; 2897 try { 2898 serviceAccountJson = JSON.parse(fileText); 2899 } catch (e) { 2900 throw new Error("O arquivo selecionado não é um JSON válido."); 2901 } 2902 2903 if (!serviceAccountJson.project_id || !serviceAccountJson.client_email || !serviceAccountJson.private_key) { 2904 throw new Error("Arquivo inválido. Certifique-se de que é a chave privada do Service Account (.json)."); 2905 } 2906 2907 fcmStatus.style.color = '#fff'; 2908 fcmStatus.textContent = "Gerando autorização..."; 2909 2910 const accessToken = await getAccessToken(serviceAccountJson); 2911 const projectId = serviceAccountJson.project_id; 2912 2913 const messageData = { 2914 "title": title, 2915 "body": body, 2916 "perform_action": performAction 2917 }; 2918 2919 if (isFcmCustomEnabled()) { 2920 const imageFile = document.getElementById('fcm-custom-image').files[0]; 2921 if (imageFile.size > 12 * 1024 * 1024) { 2922 throw new Error('A imagem original deve ter no máximo 12 MB.'); 2923 } 2924 fcmStatus.textContent = "Autorizando o Storageâ¦"; 2925 const storageToken = await getAccessToken(serviceAccountJson, [ 2926 'https://www.googleapis.com/auth/devstorage.full_control', 2927 ]); 2928 fcmStatus.textContent = "Processando imagem 63:40â¦"; 2929 const img = await loadImageFromFile(imageFile); 2930 const [collapsedBlob, expandedBlob] = await Promise.all([ 2931 encodeFcmThumb(img, FCM_THUMB_COLLAPSED.w, FCM_THUMB_COLLAPSED.h, 0), 2932 encodeFcmThumb(img, FCM_THUMB_EXPANDED.w, FCM_THUMB_EXPANDED.h, FCM_THUMB_EXPANDED_RADIUS_PX), 2933 ]); 2934 const folder = `${FCM_STORAGE_PREFIX}/${Date.now()}_${newFcmDownloadToken().slice(0, 8)}`; 2935 fcmStatus.textContent = "Enviando imagens para o Firebase Storageâ¦"; 2936 const collapsedToken = newFcmDownloadToken(); 2937 const expandedToken = newFcmDownloadToken(); 2938 const [collapsedUrl, expandedUrl] = await Promise.all([ 2939 uploadFcmThumbToStorage(storageToken, `${folder}/collapsed.webp`, collapsedBlob, collapsedToken), 2940 uploadFcmThumbToStorage(storageToken, `${folder}/expanded.webp`, expandedBlob, expandedToken), 2941 ]); 2942 messageData[FCM_THUMB_KEY_COLLAPSED] = collapsedUrl; 2943 messageData[FCM_THUMB_KEY_EXPANDED] = expandedUrl; 2944 if (isFcmRaiseCopyEnabled()) { 2945 messageData[FCM_RAISE_COPY_KEY] = '1'; 2946 } 2947 } 2948 2949 fcmStatus.textContent = "Enviando notificação..."; 2950 2951 // URL para HTTP v1 API 2952 const fcmUrl = `https://fcm.googleapis.com/v1/projects/${projectId}/messages:send`; 2953 2954 // Formata o payload â usa token ou topic conforme selecionado 2955 const messagePayload = { 2956 "data": messageData 2957 }; 2958 if (isFcmCustomEnabled()) { 2959 messagePayload.android = { priority: 'HIGH' }; 2960 } 2961 2962 if (targetMode === 'token') { 2963 messagePayload["token"] = token; 2964 } else if (targetMode === 'condition') { 2965 messagePayload["condition"] = conditionString; 2966 } else { 2967 messagePayload["topic"] = topic; 2968 } 2969 2970 const payload = { "message": messagePayload }; 2971 2972 const res = await fetch(fcmUrl, { 2973 method: 'POST', 2974 headers: { 2975 'Content-Type': 'application/json', 2976 'Authorization': `Bearer ${accessToken}` 2977 }, 2978 body: JSON.stringify(payload) 2979 }); 2980 2981 const responseData = await res.json(); 2982 2983 if (res.ok) { 2984 fcmStatus.style.color = 'var(--brand-green)'; 2985 fcmStatus.textContent = "Sucesso! Notificação enviada."; 2986 2987 // Save to history (debug_or_dev_device is excluded inside saveFcmHistoryEntry) 2988 saveFcmHistoryEntry({ 2989 timestamp: new Date().toISOString(), 2990 targetMode, 2991 target: targetMode === 'token' ? token : targetMode === 'condition' ? conditionString : topic, 2992 title, 2993 body, 2994 performAction 2995 }); 2996 } else { 2997 console.error("FCM Error:", responseData); 2998 fcmStatus.style.color = '#ff4b4b'; 2999 fcmStatus.textContent = `Erro: ${responseData.error?.message || 'Falha no envio'}`; 3000 } 3001 3002 } catch (error) { 3003 console.error("Request Error:", error); 3004 fcmStatus.style.color = '#ff4b4b'; 3005 fcmStatus.textContent = error?.message || "Erro de rede ao tentar enviar."; 3006 } finally { 3007 fcmBtnText.style.display = 'block'; 3008 fcmSpinner.style.display = 'none'; 3009 document.getElementById('fcm-send-btn').disabled = false;
3010 } 3011 }); 3012} 3013 3014// --- Reviews --- 3015let reviewsLoaded = false; 3016 3017function formatReviewStars(n) { 3018 const stars = Math.max(0, Math.min(5, Number(n) || 0)); 3019 return 'â '.repeat(stars) + 'â'.repeat(5 - stars); 3020} 3021 3022function formatReviewDate(isoOrSql) { 3023 if (!isoOrSql) return 'â'; 3024 const normalized = String(isoOrSql).replace(' ', 'T').replace(/\.\d+$/, ''); 3025 const d = new Date(normalized.endsWith('Z') || normalized.includes('+') ? normalized : normalized + 'Z'); 3026 if (isNaN(d)) return isoOrSql; 3027 return d.toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit', year: '2-digit' }) + 3028 ' ' + d.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' }); 3029} 3030 3031function androidSdkLabel(sdk) { 3032 if (sdk == null || sdk === '') return 'â'; 3033 const n = Number(sdk); 3034 const map = { 3035 36: '16', 35: '15', 34: '14', 33: '13', 32: '12L', 31: '12', 3036 30: '11', 29: '10', 28: '9', 27: '8.1', 26: '8.0', 25: '7.1', 24: '7.0' 3037 }; 3038 const ver = map[n]; 3039 return ver ? `Android ${ver} (API ${n})` : `API ${n}`; 3040} 3041 3042function formatAvgStars(v) { 3043 if (v == null || Number.isNaN(Number(v))) return 'â'; 3044 return Number(v).toLocaleString('pt-BR', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); 3045} 3046 3047function escapeHtml(str) { 3048 return String(str ?? '') 3049 .replace(/&/g, '&') 3050 .replace(/</g, '<') 3051 .replace(/>/g, '>') 3052 .replace(/"/g, '"') 3053 .replace(/'/g, '''); 3054} 3055
3056function renderStarBars(containerId, distribution) { 3057 const el = document.getElementById(containerId); 3058 if (!el) return; 3059 const rows = Array.isArray(distribution) ? distribution.slice() : []; 3060 if (rows.length === 0) { 3061 el.innerHTML = '<div class="reviews-empty" style="padding:8px 0;">Sem dados</div>'; 3062 return; 3063 } 3064 const max = Math.max(...rows.map(r => Number(r.count) || 0), 1); 3065 const byStar = new Map(rows.map(r => [Number(r.stars), r])); 3066 el.innerHTML = [5, 4, 3, 2, 1].map(s => { 3067 const row = byStar.get(s) || { count: 0 }; 3068 const count = Number(row.count) || 0; 3069 const pct = Math.round((count / max) * 100); 3070 return `<div class="reviews-star-row"> 3071 <span>${s}â </span> 3072 <div class="reviews-star-track"><div class="reviews-star-fill" style="width:${pct}%"></div></div> 3073 <span>${formatNumber(count)}</span> 3074 </div>`; 3075 }).join(''); 3076} 3077 3078function renderCountryList(containerId, countries) { 3079 const el = document.getElementById(containerId); 3080 if (!el) return; 3081 const rows = Array.isArray(countries) ? countries : []; 3082 if (rows.length === 0) { 3083 el.innerHTML = '<div class="reviews-empty" style="padding:8px 0;">Sem dados</div>'; 3084 return; 3085 } 3086 el.innerHTML = rows.map(c => { 3087 return `<div class="reviews-country-row"> 3088 <strong>${escapeHtml(c.country || '??')}</strong> 3089 <span class="reviews-country-meta">${formatNumber(c.count)} · â ${formatAvgStars(c.avg_stars)} · ${formatNumber(c.with_comment || 0)} cmt</span> 3090 </div>`; 3091 }).join(''); 3092} 3093 3094function renderInAppComments(comments) { 3095 const el = document.getElementById('reviews-inapp-comments'); 3096 if (!el) return; 3097 if (!comments || comments.length === 0) { 3098 el.innerHTML = '<div class="reviews-empty">Nenhum review com comentário</div>'; 3099 return; 3100 } 3101 el.innerHTML = comments.map(c => { 3102 const name = (c.account_user_name || '').trim() || 'â'; 3103 const email = (c.account_email || '').trim(); 3104 const paidChips = []; 3105 if (c.is_subscriber) paidChips.push('<span class="reviews-chip paid">subscriber</span>'); 3106 if (c.is_pix_paid_active) paidChips.push('<span class="reviews-chip paid">PIX ativo</span>'); 3107 if (c.is_free_account) paidChips.push('<span class="reviews-chip">free</span>'); 3108 if (c.has_competitor_app) paidChips.push('<span class="reviews-chip warn">competitor</span>'); 3109 const shortUser = (c.user_id || '').slice(0, 8); 3110 const shortDevice = (c.device_id || '').slice(0, 8); 3111 return `<article class="reviews-comment-card"> 3112 <div class="reviews-comment-top"> 3113 <span class="reviews-stars" title="${c.stars} stars">${formatReviewStars(c.stars)}</span> 3114 <span>${formatReviewDate(c.submitted_at)}</span> 3115 <span>${escapeHtml(c.country || '')}</span> 3116 <span>v${escapeHtml(c.app_version || '')}</span> 3117 </div> 3118 <div class="reviews-comment-text">${escapeHtml(c.feedback)}</div> 3119 <div class="reviews-comment-meta"> 3120 <span class="reviews-chip" title="${escapeHtml(email)}">${escapeHtml(name)}${email ? ' · ' + escapeHtml(email) : ''}</span> 3121 <span class="reviews-chip">${escapeHtml(c.device_brand || '')} ${escapeHtml(c.device_model || '')}</span> 3122 <span class="reviews-chip">${androidSdkLabel(c.android_sdk)}</span> 3123 <span class="reviews-chip">pings ${formatNumber(c.pings_detected)} · NM ${formatNumber(c.near_miss_count)} · sess ${formatNumber(c.app_session_count)}</span> 3124 <span class="reviews-chip" title="user_id=${escapeHtml(c.user_id)}">uid ${escapeHtml(shortUser)}â¦</span> 3125 <span class="reviews-chip" title="device_id=${escapeHtml(c.device_id)}">dev ${escapeHtml(shortDevice)}â¦</span> 3126 ${paidChips.join('')} 3127 </div> 3128 </article>`; 3129 }).join(''); 3130} 3131 3132function renderPlayComments(comments) { 3133 const el = document.getElementById('reviews-play-comments'); 3134 if (!el) return; 3135 if (!comments || comments.length === 0) { 3136 el.innerHTML = '<div class="reviews-empty">Nenhum review com comentário (últimos 7 dias)</div>'; 3137 return; 3138 } 3139 el.innerHTML = comments.map(c => { 3140 const deviceLabel = (c.device_product_name || '').trim()
3141 || [c.device_manufacturer, c.device].filter(Boolean).join(' ') 3142 || 'â'; 3143 return `<article class="reviews-comment-card"> 3144 <div class="reviews-comment-top"> 3145 <span class="reviews-stars" title="${c.stars} stars">${formatReviewStars(c.stars)}</span> 3146 <span>${formatReviewDate(c.submitted_at)}</span> 3147 <span title="Inferido de ${escapeHtml(c.reviewer_language || '')}">${escapeHtml(c.country || '??')}</span> 3148 <span>${escapeHtml(c.author_name || 'Anônimo')}</span> 3149 <span>v${escapeHtml(c.app_version_name || 'â')}</span> 3150 </div> 3151 <div class="reviews-comment-text">${escapeHtml(c.text)}</div> 3152 <div class="reviews-comment-meta"> 3153 <span class="reviews-chip" title="device codename: ${escapeHtml(c.device || '')}">${escapeHtml(deviceLabel)}</span> 3154 <span class="reviews-chip">${androidSdkLabel(c.android_os_version)}</span> 3155 <span class="reviews-chip">lang ${escapeHtml(c.reviewer_language || 'â')}</span> 3156 </div> 3157 </article>`; 3158 }).join(''); 3159} 3160 3161function setReviewsOverviewLoading() { 3162 ['reviews-inapp-total', 'reviews-inapp-avg', 'reviews-inapp-24h', 'reviews-inapp-7d', 'reviews-inapp-with-comment', 3163 'reviews-play-total', 'reviews-play-avg', 'reviews-play-with-comment'].forEach(id => { 3164 const el = document.getElementById(id); 3165 if (el) { el.textContent = '...'; el.style.opacity = '0.4'; } 3166 }); 3167 const inAppList = document.getElementById('reviews-inapp-comments'); 3168 const playList = document.getElementById('reviews-play-comments'); 3169 if (inAppList) inAppList.innerHTML = '<div class="reviews-empty">Loading...</div>'; 3170 if (playList) playList.innerHTML = '<div class="reviews-empty">Loading...</div>'; 3171} 3172 3173async function fetchAdminReviews() { 3174 const refreshBtn = document.getElementById('reviews-refresh-btn'); 3175 if (refreshBtn) refreshBtn.classList.add('spinning'); 3176 setReviewsOverviewLoading(); 3177 3178 try { 3179 const res = await fetchWithRetry(`${ANALYTICS_API_BASE}/admin/reviews`, { 3180 headers: { 'Authorization': `Bearer ${adminToken}` } 3181 }); 3182 if (res.status === 401) { handleTokenExpired(); return; } 3183 if (!res.ok) throw new Error('Failed to fetch reviews'); 3184 const data = await res.json(); 3185 const inApp = data.in_app || {}; 3186 const play = data.play_store || {}; 3187 3188 const setVal = (id, text) => { 3189 const el = document.getElementById(id); 3190 if (!el) return; 3191 el.textContent = text; 3192 el.style.opacity = '1'; 3193 }; 3194 3195 setVal('reviews-inapp-total', formatNumber(inApp.total)); 3196 setVal('reviews-inapp-avg', formatAvgStars(inApp.avg_stars)); 3197 setVal('reviews-inapp-24h', formatNumber(inApp.last_24h)); 3198 setVal('reviews-inapp-7d', formatNumber(inApp.last_7d)); 3199 setVal('reviews-inapp-with-comment', formatNumber(inApp.with_comment)); 3200 renderStarBars('reviews-inapp-stars', inApp.star_distribution); 3201 renderCountryList('reviews-inapp-countries', inApp.by_country); 3202 renderInAppComments(inApp.comments); 3203 3204 if (play.available === false && play.error) { 3205 setVal('reviews-play-total', 'â'); 3206 setVal('reviews-play-avg', 'â'); 3207 setVal('reviews-play-with-comment', 'â'); 3208 renderStarBars('reviews-play-stars', []); 3209 renderCountryList('reviews-play-countries', []); 3210 const playList = document.getElementById('reviews-play-comments'); 3211 if (playList) playList.innerHTML = `<div class="reviews-error">${escapeHtml(play.error)}</div>`; 3212 } else { 3213 setVal('reviews-play-total', formatNumber(play.total)); 3214 setVal('reviews-play-avg', formatAvgStars(play.avg_stars)); 3215 setVal('reviews-play-with-comment', formatNumber(play.with_comment)); 3216 renderStarBars('reviews-play-stars', play.star_distribution); 3217 renderCountryList('reviews-play-countries', play.by_country); 3218 renderPlayComments(play.comments); 3219 } 3220 3221 reviewsLoaded = true; 3222 } catch (e) { 3223 console.error('Reviews fetch error (all retries exhausted):', e); 3224 ['reviews-inapp-total', 'reviews-inapp-avg', 'reviews-inapp-24h', 'reviews-inapp-7d', 'reviews-inapp-with-comment', 3225 'reviews-play-total', 'reviews-play-avg', 'reviews-play-with-comment'].forEach(id => { 3226 const el = document.getElementById(id); 3227 if (el) { el.textContent = 'Error'; el.style.opacity = '0.5'; el.style.color = '#ff4b4b'; } 3228 }); 3229 const inAppList = document.getElementById('reviews-inapp-comments'); 3230 const playList = document.getElementById('reviews-play-comments'); 3231 if (inAppList) inAppList.innerHTML = '<div class="reviews-error">Failed to load data</div>'; 3232 if (playList) playList.innerHTML = '<div class="reviews-error">Failed to load data</div>'; 3233 } finally { 3234 if (refreshBtn) refreshBtn.classList.remove('spinning'); 3235 } 3236} 3237 3238const reviewsRefreshBtn = document.getElementById('reviews-refresh-btn'); 3239if (reviewsRefreshBtn) { 3240 reviewsRefreshBtn.addEventListener('click', () => { 3241 reviewsLoaded = false;
3242 fetchAdminReviews(); 3243 }); 3244} 3245 3246document.querySelectorAll('.sidebar-item').forEach(item => { 3247 item.addEventListener('click', () => { 3248 if (item.dataset.page === 'reviews' && !reviewsLoaded) { 3249 fetchAdminReviews(); 3250 } 3251 if (item.dataset.page === 'fcm') { 3252 renderFcmHistory(); 3253 } 3254 if (item.dataset.page === 'reminders' && !remindersLoaded) { 3255 fetchReminders(); 3256 } 3257 if (item.dataset.page === 'chat') { 3258 openSupportChat(); 3259 } else if (typeof stopSupportPolling === 'function') { 3260 stopSupportPolling(); 3261 } 3262 }); 3263}); 3264 3265// --- FCM Notification History --- 3266 3267function formatFcmTimestamp(isoOrSql) { 3268 // MySQL timestamps arrive as "2026-05-03 18:00:00.0" â make them parseable 3269 const normalized = (isoOrSql || '').replace(' ', 'T').replace(/\.\d+$/, ''); 3270 const d = new Date(normalized); 3271 if (isNaN(d)) return isoOrSql || ''; 3272 return d.toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit', year: '2-digit' }) + 3273 ' ' + d.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' }); 3274} 3275 3276function renderFcmHistoryEntries(entries) { 3277 const listEl = document.getElementById('fcm-history-list'); 3278 const countEl = document.getElementById('fcm-history-count'); 3279 if (!listEl) return; 3280 3281 if (countEl) { 3282 countEl.textContent = entries.length > 0 ? String(entries.length) : ''; 3283 countEl.style.display = entries.length > 0 ? '' : 'none'; 3284 } 3285 3286 if (entries.length === 0) { 3287 listEl.innerHTML = '<p style="color: rgba(255,255,255,0.25); font-size: 0.875rem; text-align: center; padding: 28px 0;">Nenhum envio registrado ainda.</p>'; 3288 return; 3289 } 3290 3291 const modeLabels = { token: 'Token', condition: 'Condição', topic: 'Tópico' }; 3292 const modeColors = { token: '#60a5fa', condition: '#c084fc', topic: '#34d399' }; 3293 3294 listEl.innerHTML = entries.map(entry => { 3295 const mode = entry.target_mode || entry.targetMode || ''; 3296 const label = modeLabels[mode] || mode; 3297 const color = modeColors[mode] || '#9ca3af'; 3298 const ts = entry.sent_at || entry.sentAt || entry.timestamp || ''; 3299 const act = entry.perform_action || entry.performAction || ''; 3300 3301 let targetDisplay = entry.target || ''; 3302 if (mode === 'token' && targetDisplay.length > 22) { 3303 targetDisplay = targetDisplay.substring(0, 12) + 'â¦' + targetDisplay.slice(-6); 3304 } 3305 const bodyDisplay = (entry.body || '').length > 160 3306 ? (entry.body || '').substring(0, 157) + 'â¦' 3307 : (entry.body || ''); 3308 3309 return `<div style="background: rgba(255,255,255,0.035); border: 1px solid rgba(255,255,255,0.07); border-radius: 10px; padding: 14px 16px; margin-bottom: 10px;"> 3310 <div style="display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 11px; gap: 8px; flex-wrap: wrap;"> 3311 <div style="display: flex; align-items: center; gap: 8px; flex-wrap: wrap; min-width: 0;"> 3312 <span style="background: ${color}18; border: 1px solid ${color}40; color: ${color}; font-size: 10px; font-weight: 700; border-radius: 4px; padding: 2px 7px; text-transform: uppercase; letter-spacing: 0.5px; white-space: nowrap;">${label}</span> 3313 <span style="font-size: 11.5px; color: rgba(255,255,255,0.45); font-family: monospace; word-break: break-all;">${targetDisplay}</span> 3314 </div> 3315 <span style="font-size: 11px; color: rgba(255,255,255,0.3); white-space: nowrap; flex-shrink: 0;">${formatFcmTimestamp(ts)}</span> 3316 </div> 3317 <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 10px 20px; font-size: 12.5px;"> 3318 <div> 3319 <span style="color: rgba(255,255,255,0.35); display: block; font-size: 10px; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 3px;">TÃtulo</span> 3320 <span style="color: rgba(255,255,255,0.88); font-weight: 600;">${entry.title}</span> 3321 </div> 3322 <div>
3323 <span style="color: rgba(255,255,255,0.35); display: block; font-size: 10px; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 3px;">Ação</span> 3324 <span style="color: rgba(255,255,255,0.7); font-family: monospace; font-size: 11.5px;">${act}</span> 3325 </div> 3326 <div style="grid-column: 1 / -1;"> 3327 <span style="color: rgba(255,255,255,0.35); display: block; font-size: 10px; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 3px;">Body</span> 3328 <span style="color: rgba(255,255,255,0.65); line-height: 1.45;">${bodyDisplay}</span> 3329 </div> 3330 </div> 3331 </div>`; 3332 }).join(''); 3333} 3334 3335async function renderFcmHistory() { 3336 const listEl = document.getElementById('fcm-history-list'); 3337 if (!listEl) return; 3338 if (!adminToken) return; 3339 3340 listEl.innerHTML = '<p style="color: rgba(255,255,255,0.25); font-size: 0.875rem; text-align: center; padding: 28px 0;">Carregando...</p>'; 3341 3342 try { 3343 const res = await fetchWithRetry(`${ANALYTICS_API_BASE}/admin/fcm_history`, { 3344 headers: { 'Authorization': `Bearer ${adminToken}` } 3345 }); 3346 if (res.status === 401) { handleTokenExpired(); return; } 3347 if (!res.ok) throw new Error('Failed to fetch FCM history'); 3348 const data = await res.json(); 3349 renderFcmHistoryEntries(data.entries || []); 3350 } catch (e) { 3351 console.error('FCM history fetch error:', e); 3352 listEl.innerHTML = '<p style="color: rgba(255,77,77,0.6); font-size: 0.875rem; text-align: center; padding: 28px 0;">Erro ao carregar histórico.</p>'; 3353 } 3354} 3355 3356async function saveFcmHistoryEntry(entry) { 3357 if (!adminToken) return; 3358 3359 try { 3360 await fetch(`${ANALYTICS_API_BASE}/admin/fcm_history`, { 3361 method: 'POST', 3362 headers: { 3363 'Content-Type': 'application/json', 3364 'Authorization': `Bearer ${adminToken}` 3365 }, 3366 body: JSON.stringify({ 3367 target_mode: entry.targetMode, 3368 target: entry.target, 3369 title: entry.title, 3370 body: entry.body, 3371 perform_action: entry.performAction 3372 }) 3373 }); 3374 // Refresh the rendered list after saving 3375 renderFcmHistory(); 3376 } catch (e) { 3377 console.error('FCM history save error:', e); 3378 } 3379} 3380 3381// =================================================================== 3382// Support Chat (admin console) 3383// =================================================================== 3384 3385let supportSelectedUserId = null; 3386let supportSelectedName = ''; 3387let supportMessagesTimer = null; 3388let supportConvTimer = null; 3389let supportLastMessageCount = 0; 3390let supportThreadSignature = ''; 3391let supportSelectedIsPro = false; 3392let supportSelectedIsMileagePro = false; 3393let supportReportFlags = {}; // user_id -> ping_report_flag from the last conversation list 3394// AI run state for the open thread (drives the "Assistant is typingâ¦" spinner). 3395let supportAiState = 'idle'; 3396let supportAiLockedAt = 0; // epoch ms of the worker claim lock (0 = none) 3397let supportThreadServerNow = 0; // server clock at the last thread poll 3398// Feedback (1â5 stars) on AI bubbles. While a comment box is open we pause the 3399// 4s thread re-render so it isn't torn out from under the operator mid-type. 3400let supportFeedbackOpenMsgId = null; 3401const supportFeedbackDrafts = {}; // msgId -> in-progress comment (survives re-render) 3402// Instruction reply on an AI escalation note ("internal_ai"). Same as the 3403// feedback box: while it's open we pause the 4s thread re-render so it isn't torn 3404// out from under the admin mid-type. 3405let supportInstructionOpenMsgId = null; 3406const supportInstructionDrafts = {}; // msgId -> in-progress instruction text 3407// Teaching note on a USER message/attachment ("when a user sends this, do X"). 3408// Same as above: while the box is open we pause the 4s thread re-render so it 3409// isn't torn out from under the admin mid-type. 3410let supportTeachOpenMsgId = null; 3411const supportTeachDrafts = {}; // msgId -> in-progress teaching note 3412// Mirror of the server's SUPPORT_AI_LOCK_TTL_SECONDS default (8 min â must stay 3413// above the worker's AGENT_TIMEOUT_MS of 7 min): a `processing` state older 3414// than this means the worker died, so clear the spinner. 3415const SUPPORT_AI_LOCK_TTL_MS = 480000; 3416 3417// Root-absolute so the admin console on /pt-BR/ still hits /assets/, not 3418// /pt-BR/assets/ (where these files were missing and showed a broken image). 3419const SUPPORT_PRO_BADGE_SRC = '/assets/pro_badge.webp'; 3420const SUPPORT_MILEAGE_PRO_BADGE_SRC = '/assets/mileage_pro.webp'; 3421 3422function supportPlanBadgeHtml(className, isMileagePro) { 3423 if (isMileagePro) { 3424 return `<img src="${SUPPORT_MILEAGE_PRO_BADGE_SRC}" alt="Mileage Pro" class="${className} support-pro-badge--mileage">`; 3425 } 3426 return `<img src="${SUPPORT_PRO_BADGE_SRC}" alt="PRO" class="${className}">`; 3427} 3428 3429function escapeSupportHtml(value) { 3430 const div = document.createElement('div'); 3431 div.textContent = value == null ? '' : String(value); 3432 return div.innerHTML; 3433} 3434 3435function linkifySupportText(value) { 3436 const escaped = escapeSupportHtml(value == null ? '' : String(value)); 3437 const urlRegex = /((?:https?:\/\/|www\.)[^\s<]+[^\s<.,;:!?\)\]\}>'"])/gi; 3438 return escaped.replace(urlRegex, (match) => { 3439 const href = /^www\./i.test(match) ? `https://${match}` : match; 3440 return `<a href="${escapeSupportHtml(href)}" target="_blank" rel="noopener noreferrer">${match}</a>`; 3441 }); 3442} 3443 3444function formatSupportDeviceId(deviceId) { 3445 if (!deviceId) return ''; 3446 return String(deviceId).slice(0, 6); 3447} 3448 3449function formatSupportTime(epochMs) { 3450 if (!epochMs) return ''; 3451 const d = new Date(Number(epochMs)); 3452 if (isNaN(d)) return ''; 3453 const today = new Date(); 3454 const sameDay = d.toDateString() === today.toDateString(); 3455 const time = d.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' }); 3456 if (sameDay) return time; 3457 return d.toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit' }) + ' ' + time; 3458} 3459 3460function openSupportChat() { 3461 loadSupportConversations(); 3462 loadSkillUpdateStatus(); 3463 startSupportPolling(); 3464} 3465 3466function startSupportPolling() { 3467 stopSupportPolling(); 3468 supportConvTimer = setInterval(loadSupportConversations, 8000); 3469 supportMessagesTimer = setInterval(() => { 3470 if (supportSelectedUserId) loadSupportMessages(supportSelectedUserId, true); 3471 }, 4000); 3472 supportSkillTimer = setInterval(loadSkillUpdateStatus, 6000); 3473} 3474 3475function stopSupportPolling() { 3476 if (supportConvTimer) { clearInterval(supportConvTimer); supportConvTimer = null; } 3477 if (supportMessagesTimer) { clearInterval(supportMessagesTimer); supportMessagesTimer = null; } 3478 if (supportSkillTimer) { clearInterval(supportSkillTimer); supportSkillTimer = null; } 3479} 3480
3481// ââ On-demand "Atualizar skill da IA" (runs the local worker's skill-update) ââ 3482let supportSkillTimer = null; 3483let skillUpdateBusyUntil = 0; // debounce: ignore the button right after a click 3484 3485function setSkillUpdateUi(state, text) { 3486 const btn = document.getElementById('skill-update-btn'); 3487 const statusEl = document.getElementById('skill-update-status'); 3488 const busy = state === 'requested' || state === 'running'; 3489 if (btn) { 3490 btn.disabled = busy; 3491 btn.classList.toggle('busy', busy); 3492 } 3493 if (statusEl) { 3494 statusEl.className = 'skill-update-status' + (state ? ' ' + state : ''); 3495 statusEl.innerHTML = busy 3496 ? `${SUPPORT_TYPING_DOTS_HTML}<span class="skill-update-status-text">${escapeSupportHtml(text)}</span>` 3497 : `<span class="skill-update-status-text">${escapeSupportHtml(text)}</span>`; 3498 } 3499} 3500 3501function describeSkillStatus(s) { 3502 const when = s.last_finished_at ? formatSupportTime(s.last_finished_at) : ''; 3503 const trig = s.last_trigger === 'scheduled' ? ' (noturno)' : (s.last_trigger === 'on_demand' ? ' (manual)' : ''); 3504 switch (s.last_status) { 3505 case 'updated': return `Skill atualizado${trig} ${when} â ${s.last_summary || ''}`.trim(); 3506 case 'no_change': return `Sem mudanças necessárias${trig} ${when}`.trim(); 3507 case 'no_pending': return `Nenhum feedback pendente${trig} ${when}`.trim(); 3508 case 'error': return `Falha na última execução${trig} ${when} â ${s.last_summary || ''}`.trim(); 3509 default: return 'Pronto para atualizar'; 3510 } 3511} 3512 3513let skillLastFinishedSeen = 0;
3514function renderSkillUpdateStatus(s) { 3515 if (!s || s.success === false) { setSkillUpdateUi('', 'Estado indisponÃvel'); return; } 3516 // A run just completed â refresh the history popover if it's open. 3517 if (s.last_finished_at && s.last_finished_at !== skillLastFinishedSeen) { 3518 skillLastFinishedSeen = s.last_finished_at; 3519 const panel = document.getElementById('skill-update-history'); 3520 if (panel && !panel.hidden) loadSkillUpdateHistory(); 3521 } 3522 const serverNow = Number(s.server_now) || Date.now(); 3523 const started = Number(s.started_at) || 0; 3524 // Treat a stale running lock (worker died mid-run) as not-running after ~10min. 3525 const runningFresh = s.running && (started === 0 || (serverNow - started) < 600000); 3526 if (runningFresh) { 3527 setSkillUpdateUi('running', 'Atualizando skill⦠(rodando no seu Mac)'); 3528 } else if (s.requested) { 3529 setSkillUpdateUi('requested', 'Solicitado â aguardando o worker no seu Macâ¦'); 3530 } else { 3531 setSkillUpdateUi('idle', describeSkillStatus(s)); 3532 } 3533} 3534 3535async function loadSkillUpdateStatus() { 3536 if (!adminToken) return; 3537 try { 3538 const res = await fetchWithRetry(`${ANALYTICS_API_BASE}/admin/ai_support/skill_update/status`, { 3539 headers: { 'Authorization': `Bearer ${adminToken}` }, 3540 signal: AbortSignal.timeout(ANALYTICS_FETCH_TIMEOUT_MS), 3541 }); 3542 if (res.status === 401) { handleTokenExpired(); return; } 3543 if (!res.ok) return; 3544 renderSkillUpdateStatus(await res.json()); 3545 } catch (e) { 3546 // Non-fatal: leave the last rendered state. 3547 } 3548} 3549 3550// ââ "Histórico de atualizações" popover (last ~10 runs) ââââââââââââââââââââââ 3551function skillRunStatusLabel(run) { 3552 const trig = run.trigger === 'scheduled' ? 'noturno' : (run.trigger === 'on_demand' ? 'manual' : ''); 3553 const map = { updated: 'Skill atualizado', no_change: 'Sem mudanças', no_pending: 'Nenhum pendente', error: 'Erro' }; 3554 const base = map[run.status] || run.status || 'â'; 3555 return trig ? `${base} · ${trig}` : base; 3556} 3557
3558function renderSkillUpdateHistory(runs) { 3559 if (!runs || !runs.length) { 3560 return '<div class="skill-update-history-empty">Nenhuma atualização registrada ainda.</div>'; 3561 } 3562 const rows = runs.map(r => { 3563 const cls = r.status === 'updated' ? 'updated' : (r.status === 'error' ? 'error' : 'neutral'); 3564 const when = formatSkillRunWhen(r.finished_at); 3565 const ver = r.commit ? `<code class="skill-run-commit">${escapeSupportHtml(r.commit)}</code>` : ''; 3566 const count = r.count ? `<span class="skill-run-count">${r.count} feedback${r.count > 1 ? 's' : ''}</span>` : ''; 3567 const summary = r.changed && r.summary 3568 ? `<div class="skill-run-summary">${escapeSupportHtml(r.summary)}</div>` 3569 : ''; 3570 return ` 3571 <div class="skill-run ${cls}"> 3572 <div class="skill-run-head"> 3573 <span class="skill-run-status">${escapeSupportHtml(skillRunStatusLabel(r))}</span> 3574 <span class="skill-run-when">${escapeSupportHtml(when)}</span> 3575 </div> 3576 <div class="skill-run-meta">${count}${ver}</div> 3577 ${summary} 3578 </div>`; 3579 }).join(''); 3580 return rows; 3581} 3582 3583function formatSkillRunWhen(epochMs) { 3584 if (!epochMs) return ''; 3585 const d = new Date(Number(epochMs)); 3586 if (isNaN(d)) return ''; 3587 return d.toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit' }) + ' ' + 3588 d.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' }); 3589} 3590 3591async function loadSkillUpdateHistory() { 3592 const panel = document.getElementById('skill-update-history'); 3593 if (!panel || !adminToken) return; 3594 panel.innerHTML = '<div class="skill-update-history-empty">Carregandoâ¦</div>'; 3595 try { 3596 const res = await fetchWithRetry(`${ANALYTICS_API_BASE}/admin/ai_support/skill_update/history?limit=10`, { 3597 headers: { 'Authorization': `Bearer ${adminToken}` }, 3598 signal: AbortSignal.timeout(ANALYTICS_FETCH_TIMEOUT_MS), 3599 }); 3600 if (res.status === 401) { handleTokenExpired(); return; } 3601 if (!res.ok) { panel.innerHTML = '<div class="skill-update-history-empty">Falha ao carregar.</div>'; return; } 3602 const data = await res.json(); 3603 panel.innerHTML = '<div class="skill-update-history-title">Histórico de atualizações</div>' + 3604 renderSkillUpdateHistory(data.runs || []); 3605 } catch (e) { 3606 panel.innerHTML = '<div class="skill-update-history-empty">Falha ao carregar.</div>'; 3607 } 3608} 3609 3610function toggleSkillUpdateHistory() { 3611 const panel = document.getElementById('skill-update-history'); 3612 if (!panel) return; 3613 if (panel.hidden) { 3614 panel.hidden = false; 3615 loadSkillUpdateHistory(); 3616 } else { 3617 panel.hidden = true; 3618 } 3619} 3620 3621// Close the popover when clicking outside it. 3622document.addEventListener('click', (e) => { 3623 const panel = document.getElementById('skill-update-history'); 3624 if (!panel || panel.hidden) return; 3625 const btn = document.getElementById('skill-update-history-btn'); 3626 if (panel.contains(e.target) || (btn && btn.contains(e.target))) return; 3627 panel.hidden = true; 3628}); 3629 3630async function requestSkillUpdate() { 3631 if (!adminToken) return; 3632 if (Date.now() < skillUpdateBusyUntil) return; 3633 skillUpdateBusyUntil = Date.now() + 3000; 3634 setSkillUpdateUi('requested', 'Solicitandoâ¦'); 3635 try { 3636 const res = await fetch(`${ANALYTICS_API_BASE}/admin/ai_support/skill_update/request`, { 3637 method: 'POST', 3638 headers: { 'Authorization': `Bearer ${adminToken}`, 'Content-Type': 'application/json' }, 3639 body: '{}', 3640 }); 3641 if (res.status === 401) { handleTokenExpired(); return; } 3642 if (!res.ok) throw new Error('request failed (' + res.status + ')'); 3643 renderSkillUpdateStatus(await res.json()); 3644 } catch (e) { 3645 console.error('Skill update request error:', e); 3646 setSkillUpdateUi('error', 'Falha ao solicitar. Tente novamente.'); 3647 } 3648} 3649 3650async function loadSupportConversations() { 3651 if (!adminToken) return; 3652 const listEl = document.getElementById('support-conv-list'); 3653 try { 3654 const res = await fetchWithRetry(`${ANALYTICS_API_BASE}
3654/admin/support_conversations`, { 3655 headers: { 'Authorization': `Bearer ${adminToken}` }, 3656 signal: AbortSignal.timeout(ANALYTICS_FETCH_TIMEOUT_MS), 3657 }); 3658 if (res.status === 401) { handleTokenExpired(); return; } 3659 if (!res.ok) throw new Error('Failed to fetch conversations'); 3660 const data = await res.json(); 3661 renderSupportConversations(data.conversations || [], Number(data.server_now) || Date.now()); 3662 renderPingReportAlerts(data.ping_report_alerts || []); 3663 } catch (e) { 3664 console.error('Support conversations error:', e); 3665 if (listEl && !listEl.querySelector('.support-conv-item')) { 3666 listEl.innerHTML = '<p class="support-empty">Erro ao carregar conversas.</p>'; 3667 } 3668 } 3669} 3670 3671/** True when the AI worker is mid-run on a conversation row (fresh lock only). */ 3672function supportConvProcessing(c, serverNow) { 3673 if (!c || c.ai_state !== 'processing') return false; 3674 const locked = Number(c.ai_locked_at) || 0; 3675 if (locked > 0 && serverNow > 0 && (serverNow - locked) > SUPPORT_AI_LOCK_TTL_MS) return false; 3676 return true; 3677} 3678 3679const SUPPORT_TYPING_DOTS_HTML = '<span class="support-conv-typing" title="IA respondendoâ¦"><span class="support-typing-dot"></span><span class="support-typing-dot"></span><span class="support-typing-dot"></span></span>'; 3680
3681function renderSupportConversations(conversations, serverNow) { 3682 const listEl = document.getElementById('support-conv-list'); 3683 const badge = document.getElementById('support-unread-badge'); 3684 if (!listEl) return; 3685 3686 supportReportFlags = {}; 3687 conversations.forEach(c => { supportReportFlags[c.user_id] = c.ping_report_flag || ''; }); 3688 renderReportCheckedButton(); 3689 3690 let totalUnread = 0; 3691 conversations.forEach(c => { totalUnread += (c.unread_by_agent || 0); }); 3692 if (badge) { 3693 if (totalUnread > 0) { badge.style.display = ''; badge.textContent = String(totalUnread); } 3694 else { badge.style.display = 'none'; } 3695 } 3696 3697 if (!conversations.length) { 3698 listEl.innerHTML = '<p class="support-empty">Nenhuma conversa ainda.</p>'; 3699 return; 3700 } 3701 3702 listEl.innerHTML = conversations.map(c => { 3703 const name = c.user_name && c.user_name.trim() 3704 ? c.user_name 3705 : (c.email && c.email.trim() ? c.email : c.user_id.slice(0, 8)); 3706 const unread = (c.unread_by_agent || 0) > 0; 3707 const aiProcessing = supportConvProcessing(c, serverNow); 3708 // The AI handed this conversation to a human and parked itself; flag it 3709 // with a big red dot until the AI switch is turned back on (ai_state â idle). 3710 const aiEscalated = c.ai_state === 'escalated'; 3711 const activeCls = c.user_id === supportSelectedUserId ? ' active' : ''; 3712 const deviceTag = formatSupportDeviceId(c.device_id); 3713 const isMileagePro = !!c.is_mileage_pro; 3714 const isPro = !!c.is_pro || isMileagePro; 3715 const badgeHtml = isPro 3716 ? supportPlanBadgeHtml('support-pro-badge support-pro-badge--conv', isMileagePro) 3717 : ''; 3718 const reportFlagTitles = { 3719 pending: 'Reporte de corrida aguardando análise', 3720 urgent: 'Reporte de corrida com análise urgente (últimos 7 dias)', 3721 action: 'Reporte de corrida que pede correção ou ação (últimos 7 dias)', 3722 open: 'Reporte de corrida nos últimos 7 dias', 3723 }; 3724 const reportPending = c.ping_report_flag === 'pending' && !aiEscalated; 3725 const reportFlagHtml = reportFlagTitles[c.ping_report_flag] 3726 ? `<span class="support-conv-report-flag ${escapeSupportHtml(c.ping_report_flag)}" title="${reportFlagTitles[c.ping_report_flag]}">ð©</span>` 3727 : ''; 3728 const deviceMeta = deviceTag || isPro 3729 ? `<span class="support-conv-meta">${deviceTag ? `<span class="support-conv-device" title="device_id">${escapeSupportHtml(deviceTag)}</span>` : ''}${badgeHtml}</span>` 3730 : ''; 3731 return ` 3732 <div class="support-conv-item${activeCls}${aiEscalated ? ' escalated' : ''}${reportPending ? ' report-pending' : ''}" data-user="${escapeSupportHtml(c.user_id)}" data-name="${escapeSupportHtml(name)}" data-email="${escapeSupportHtml(c.email || '')}" data-device="${escapeSupportHtml(c.device_id || '')}" data-pro="${isPro ? '1' : '0'}" data-mileage-pro="${isMileagePro ? '1' : '0'}"> 3733 <div class="support-conv-top"> 3734 ${aiEscalated ? '<span class="support-conv-escalated-dot" title="IA escalou para um humano â aguardando atendimento"></span>' : ''} 3735 ${reportPending ? '<span class="support-conv-pending-dot" title="Reporte de corrida aguardando análise"></span>' : ''} 3736 <span class="support-conv-name">${escapeSupportHtml(name)}</span> 3737 ${reportFlagHtml} 3738 ${deviceMeta} 3739 <span class="support-conv-time">${formatSupportTime(c.updated_at)}</span> 3740 </div> 3741 <div class="support-conv-top"> 3742 <span class="support-conv-preview">${escapeSupportHtml(c.last_message || '')}</span> 3743 ${aiProcessing ? SUPPORT_TYPING_DOTS_HTML : ''} 3744 ${unread ? '<span class="support-conv-dot"></span>' : ''} 3745 </div> 3746 </div>`; 3747 }).join(''); 3748 3749 listEl.querySelectorAll('.support-conv-item').forEach(item => { 3750 item.addEventListener('click', () => { 3751 selectSupportConversation( 3752 item.dataset.user, 3753 item.dataset.name, 3754 item.dataset.email, 3755 item.dataset.device, 3756 item.dataset.pro === '1', 3757 item.dataset.mileagePro === '1', 3758 ); 3759 }); 3760 }); 3761} 3762 3763function selectSupportConversation(userId, name, email, deviceId, isPro, isMileagePro) { 3764 supportSelectedUserId = userId; 3765 supportSelectedName = name;
3766 supportSelectedIsPro = !!isPro; 3767 supportSelectedIsMileagePro = !!isMileagePro; 3768 supportLastMessageCount = 0; 3769 supportThreadSignature = ''; 3770 supportFeedbackOpenMsgId = null; 3771 supportInstructionOpenMsgId = null; 3772 supportTeachOpenMsgId = null; 3773 supportAiState = 'idle'; 3774 supportAiLockedAt = 0; 3775 document.querySelectorAll('.support-conv-item').forEach(i => { 3776 i.classList.toggle('active', i.dataset.user === userId); 3777 }); 3778 const titleEl = document.getElementById('support-thread-title'); 3779 const subEl = document.getElementById('support-thread-sub'); 3780 const composer = document.getElementById('support-composer'); 3781 if (titleEl) titleEl.textContent = name; 3782 if (subEl) { 3783 const deviceTag = formatSupportDeviceId(deviceId); 3784 const textParts = [email || userId, deviceTag].filter(Boolean); 3785 const proHtml = supportSelectedIsPro 3786 ? supportPlanBadgeHtml('support-pro-badge support-pro-badge--thread', supportSelectedIsMileagePro) 3787 : ''; 3788 subEl.innerHTML = `${escapeSupportHtml(textParts.join(' · '))}${proHtml ? ` ${proHtml}` : ''}`; 3789 } 3790 if (composer) composer.style.display = 'flex'; 3791 supportThreadMessages = []; 3792 supportThreadReports = {}; 3793 const cursorBtn = document.getElementById('support-cursor-btn'); 3794 if (cursorBtn) cursorBtn.hidden = false; 3795 renderReportCheckedButton(); 3796 // Phone layout: swap from the conversation list to a full-screen thread view. 3797 document.getElementById('admin-page-chat')?.classList.add('thread-open'); 3798 loadSupportMessages(userId, false); 3799} 3800 3801async function loadSupportMessages(userId, silent) { 3802 if (!adminToken) return; 3803 const threadEl = document.getElementById('support-thread'); 3804 if (!silent && threadEl) threadEl.innerHTML = '<p class="support-empty">Carregandoâ¦</p>'; 3805 try { 3806 const res = await fetchWithRetry( 3807 `${ANALYTICS_API_BASE}/admin/support_messages?user_id=${encodeURIComponent(userId)}`, 3808 { 3809 headers: { 'Authorization': `Bearer ${adminToken}` }, 3810 signal: AbortSignal.timeout(ANALYTICS_FETCH_TIMEOUT_MS), 3811 } 3812 ); 3813 if (res.status === 401) { handleTokenExpired(); return; } 3814 if (!res.ok) throw new Error('Failed to fetch messages'); 3815 const data = await res.json(); 3816 // Only re-render if a different conversation is still selected. 3817 if (supportSelectedUserId !== userId) return; 3818 supportAiState = data.ai_state || 'idle'; 3819 supportAiLockedAt = Number(data.ai_locked_at) || 0; 3820 supportThreadServerNow = Number(data.server_now) || Date.now(); 3821 renderAiToggle(supportAiState); 3822 renderSupportThread(data.messages || []); 3823 } catch (e) { 3824 console.error('Support messages error:', e); 3825 if (!silent && threadEl) threadEl.innerHTML = '<p class="support-empty">Erro ao carregar mensagens.</p>'; 3826 } 3827} 3828 3829// ââ Per-conversation AI on/off switch (thread header) ââââââââââââââââââââââââ 3830// The switch mirrors ai_state: ON while the AI is armed/active (idle|processing), 3831// OFF while a human is handling it or it was escalated (human|escalated). Toggling 3832// re-arms (idle) or parks it (human) for the selected user. 3833let supportAiToggleBusyUntil = 0; 3834function aiStateIsOn(aiState) { 3835 return aiState === 'idle' || aiState === 'processing'; 3836} 3837 3838function renderAiToggle(aiState) { 3839 const wrap = document.getElementById('support-ai-toggle'); 3840 const input = document.getElementById('support-ai-toggle-input'); 3841 const text = document.getElementById('support-ai-toggle-text'); 3842 if (!wrap || !input) return; 3843 if (!supportSelectedUserId) { wrap.hidden = true; return; } 3844 wrap.hidden = false; 3845 // Don't fight the operator's click while the toggle request is in flight. 3846 if (Date.now() < supportAiToggleBusyUntil) return; 3847 const on = aiStateIsOn(aiState); 3848 input.checked = on; 3849 wrap.classList.toggle('off', !on); 3850 if (text) text.textContent = on ? 'IA automática' : (aiState === 'escalated' ? 'IA (escalado)' : 'IA desligada'); 3851} 3852 3853function renderReportCheckedButton() { 3854 const btn = document.getElementById('support-report-checked-btn'); 3855 if (!btn) return; 3856 const pending = !!(supportSelectedUserId && supportReportFlags[supportSelectedUserId] === 'pending'); 3857 btn.hidden = !pending; 3858 if (!pending) btn.disabled = false;
3859} 3860 3861async function dismissReportHighlight() { 3862 const userId = supportSelectedUserId; 3863 const btn = document.getElementById('support-report-checked-btn'); 3864 if (!adminToken || !userId || !btn || btn.hidden) return; 3865 btn.disabled = true; 3866 try { 3867 const res = await fetch(`${ANALYTICS_API_BASE}/admin/ping_reports/dismiss_highlight`, { 3868 method: 'POST', 3869 headers: { 'Authorization': `Bearer ${adminToken}`, 'Content-Type': 'application/json' }, 3870 body: JSON.stringify({ user_id: userId }), 3871 }); 3872 if (res.status === 401) { handleTokenExpired(); return; } 3873 if (!res.ok) throw new Error('dismiss failed (' + res.status + ')'); 3874 supportReportFlags[userId] = ''; 3875 const item = document.querySelector(`.support-conv-item[data-user="${CSS.escape(userId)}"]`); 3876 if (item) { 3877 item.classList.remove('report-pending'); 3878 item.querySelector('.support-conv-pending-dot')?.remove(); 3879 } 3880 renderReportCheckedButton(); 3881 loadSupportConversations(); 3882 } catch (e) { 3883 console.error('Dismiss report highlight error:', e); 3884 btn.disabled = false; 3885 } 3886} 3887 3888async function setUserAiEnabled(enabled) { 3889 const userId = supportSelectedUserId; 3890 if (!adminToken || !userId) return; 3891 supportAiToggleBusyUntil = Date.now() + 2500; 3892 // Optimistic: reflect the intended state immediately. 3893 supportAiState = enabled ? 'idle' : 'human'; 3894 const wrap = document.getElementById('support-ai-toggle'); 3895 const text = document.getElementById('support-ai-toggle-text'); 3896 if (wrap) wrap.classList.toggle('off', !enabled); 3897 if (text) text.textContent = enabled ? 'IA automática' : 'IA desligada'; 3898 try { 3899 const res = await fetch(`${ANALYTICS_API_BASE}/admin/ai_support/ai_toggle`, { 3900 method: 'POST', 3901 headers: { 'Authorization': `Bearer ${adminToken}`, 'Content-Type': 'application/json' }, 3902 body: JSON.stringify({ user_id: userId, enabled }), 3903 }); 3904 if (res.status === 401) { handleTokenExpired(); return; } 3905 if (!res.ok) throw new Error('toggle failed (' + res.status + ')'); 3906 const data = await res.json(); 3907 if (supportSelectedUserId === userId && data.ai_state) supportAiState = data.ai_state; 3908 // Re-enabling the switch clears the escalated state; refresh the list so 3909 // the big red dot disappears immediately instead of on the next poll. 3910 loadSupportConversations(); 3911 } catch (e) { 3912 console.error('AI toggle error:', e); 3913 // Revert on failure. 3914 supportAiToggleBusyUntil = 0; 3915 renderAiToggle(supportAiState); 3916 } finally { 3917 supportAiToggleBusyUntil = 0; 3918 } 3919} 3920 3921/** True while the AI worker is mid-run on the open thread (and the lock is fresh). */ 3922function supportAiIsProcessing() { 3923 if (supportAiState !== 'processing') return false; 3924 if (supportAiLockedAt > 0 && supportThreadServerNow > 0 3925 && (supportThreadServerNow - supportAiLockedAt) > SUPPORT_AI_LOCK_TTL_MS) { 3926 return false; // worker died mid-run â don't hang the spinner forever 3927 } 3928 return true; 3929} 3930 3931/** The "add a rating" control shown under every AI bubble (always interactive). */
3932function renderStarControl(msgId) { 3933 const starsHtml = [1, 2, 3, 4, 5].map(n => 3934 `<button type="button" class="support-star" data-msg-id="${escapeSupportHtml(msgId)}" data-star="${n}" aria-label="${n}/5">â</button>` 3935 ).join(''); 3936 return ` 3937 <div class="support-rating" data-msg-id="${escapeSupportHtml(msgId)}"> 3938 <span class="support-rating-hint">Avaliar:</span> 3939 <div class="support-stars">${starsHtml}</div> 3940 </div>`; 3941} 3942 3943/** 3944 * The history of ratings left on one AI bubble (oldest first). Each row shows the 3945 * stars, the comment, when it was left, and whether the nightly skill-update job 3946 * has already consumed it ("aplicado ao skill") or it's still pending. 3947 */ 3948function renderFeedbackHistory(feedback) { 3949 // Only star ratings render under an AI bubble; teaching notes (kind='user_note') 3950 // belong to user bubbles and are rendered by renderTeachHistory. 3951 const ratings = (feedback || []).filter(f => (f.kind || 'rating') === 'rating'); 3952 if (!ratings.length) return ''; 3953 const rows = ratings.map(f => { 3954 const stars = Math.max(0, Math.min(5, Number(f.stars) || 0)); 3955 const starStr = 'â '.repeat(stars) + 'â'.repeat(5 - stars); 3956 const hasComment = (f.comment || '').trim().length > 0; 3957 const comment = hasComment 3958 ? `<span class="support-fb-comment">${linkifySupportText(f.comment)}</span>` 3959 : '<span class="support-fb-comment support-fb-nocomment">â sem comentário</span>'; 3960 // Only sub-5â feedback WITH a comment is actionable for the skill-update; 3961 // everything else is just recorded (it will never be "consumed"). 3962 const actionable = stars > 0 && stars < 5 && hasComment; 3963 let badge; 3964 if (f.applied) { 3965 const when = f.applied_at ? ' · ' + formatSupportTime(f.applied_at) : ''; 3966 const ver = f.applied_skill_version ? ` (${String(f.applied_skill_version).replace(/^.*@/, '')})` : ''; 3967 badge = `<span class="support-fb-badge applied" title="Consumido pelo skill-update${f.applied_skill_version ? ' â skill ' + escapeSupportHtml(f.applied_skill_version) : ''}">â aplicado ao skill${when}${ver}</span>`; 3968 } else if (actionable) { 3969 badge = '<span class="support-fb-badge pending" title="Será considerado no próximo skill-update (botão ou noturno à s 04:30)">â³ pendente</span>'; 3970 } else { 3971 badge = '<span class="support-fb-badge recorded" title="Registrado, mas não alimenta o skill: só avaliações de 1â4â com comentário são aplicadas">⢠registrado</span>'; 3972 } 3973 return ` 3974 <div class="support-fb-entry"> 3975 <span class="support-fb-stars" title="${stars}/5">${starStr}</span> 3976 ${comment} 3977 <span class="support-fb-when">${formatSupportTime(f.created_at)}</span> 3978 ${badge} 3979 </div>`; 3980 }).join(''); 3981 return `<div class="support-fb-history">${rows}</div>`; 3982} 3983 3984/** 3985 * The end user's own ð/ð on an incoming reply (AI or agent), mirrored read-only 3986 * from `support_user_reactions`. It sits next to the operator's star rating so we 3987 * can see what the driver thought of the answer. A ð on an AI reply is the only 3988 * one that feeds the skill-update loop (as a weak, distinct-user-gated signal); 3989 * here it's purely informational. 3990 */ 3991function renderUserReaction(reaction) { 3992 if (reaction !== 'up' && reaction !== 'down') return ''; 3993 const up = reaction === 'up'; 3994 const label = up ? 'Usuário curtiu a resposta' : 'Usuário não curtiu a resposta'; 3995 return `<span class="support-user-reaction ${up ? 'up' : 'down'}" title="${label}">${up ? 'ð' : 'ð'} ${label}</span>`; 3996} 3997 3998/** The "teach the AI from this message" control shown under every USER bubble. */ 3999function renderTeachControl(msgId) { 4000 return ` 4001 <div class="support-teach" data-msg-id="${escapeSupportHtml(msgId)}"> 4002 <button type="button" class="support-teach-btn" data-msg-id="${escapeSupportHtml(msgId)}">ð Ensinar a IA com esta mensagem</button> 4003 </div>`; 4004} 4005 4006/** 4007 * History of teaching notes left on a USER message (kind='user_note'), each with 4008 * its pending/applied badge â so the admin sees what they taught and whether the 4009 * skill-update job has already folded it in. 4010 */ 4011function renderTeachHistory(feedback) { 4012 const notes = (feedback || []).filter(f => f.kind === 'user_note'); 4013 if (!notes.length) return ''; 4014 const rows = notes.map(f => { 4015 const comment = `<span class="support-fb-comment">${linkifySupportText(f.comment || '')}</span>`;
4016 let badge; 4017 if (f.applied) { 4018 const when = f.applied_at ? ' · ' + formatSupportTime(f.applied_at) : ''; 4019 const ver = f.applied_skill_version ? ` (${String(f.applied_skill_version).replace(/^.*@/, '')})` : ''; 4020 badge = `<span class="support-fb-badge applied" title="Consumido pelo skill-update${f.applied_skill_version ? ' â skill ' + escapeSupportHtml(f.applied_skill_version) : ''}">â aprendido pela IA${when}${ver}</span>`; 4021 } else { 4022 badge = '<span class="support-fb-badge pending" title="Será considerado no próximo skill-update (disparado agora, ou noturno à s 04:30)">â³ pendente</span>'; 4023 } 4024 return ` 4025 <div class="support-fb-entry"> 4026 <span class="support-teach-icon" title="Sua orientação para a IA">ð</span> 4027 ${comment} 4028 <span class="support-fb-when">${formatSupportTime(f.created_at)}</span> 4029 ${badge} 4030 </div>`; 4031 }).join(''); 4032 return `<div class="support-fb-history support-teach-history">${rows}</div>`; 4033} 4034
4035function renderSupportThread(messages) { 4036 const threadEl = document.getElementById('support-thread'); 4037 if (!threadEl) return; 4038 4039 supportThreadMessages = messages; 4040 supportThreadReports = {}; 4041 messages.forEach(m => { 4042 if (m.ping_report && m.ping_report.report_id) supportThreadReports[m.ping_report.report_id] = m.ping_report; 4043 }); 4044 4045 if (!messages.length) { 4046 supportThreadSignature = ''; 4047 threadEl.innerHTML = '<p class="support-empty">Sem mensagens ainda.</p>'; 4048 return; 4049 } 4050 4051 // Don't rebuild the DOM out from under an operator who's typing a feedback 4052 // comment (only while the box is actually still present â otherwise unstick). 4053 if (supportFeedbackOpenMsgId) { 4054 if (threadEl.querySelector('.support-feedback-box')) return; 4055 supportFeedbackOpenMsgId = null; 4056 } 4057 // Same guard for an open "reply to the AI escalation note" instruction box. 4058 if (supportInstructionOpenMsgId) { 4059 if (threadEl.querySelector('.support-instruction-box')) return; 4060 supportInstructionOpenMsgId = null; 4061 } 4062 // Same guard for an open "teach the AI from this user message" box. 4063 if (supportTeachOpenMsgId) { 4064 if (threadEl.querySelector('.support-teach-box')) return; 4065 supportTeachOpenMsgId = null; 4066 } 4067 4068 const processing = supportAiIsProcessing(); 4069 4070 // Skip re-rendering when nothing changed â otherwise the 4s poll would 4071 // rebuild the DOM and kill any audio/video the agent is playing mid-stream. 4072 // The signature folds in: the transcript flag (an audio note's transcription 4073 // lands async on an existing id), the feedback history (each entry's id + 4074 // applied flag, so a new rating OR the nightly job consuming one repaints), 4075 // and the processing flag (so the typing spinner appears/clears). 4076 const signature = messages.map(m => { 4077 const fb = (m.feedback || []).map(f => `${f.id}${f.applied ? 'a' : 'p'}`).join('.'); 4078 const pr = m.ping_report ? `${m.ping_report.status}${m.ping_report.chat_verdict || ''}` : ''; 4079 return `${m.id}:${m.transcription ? 1 : 0}:${fb}:${m.user_reaction || ''}:${pr}`; 4080 }).join('|') + `#${processing ? 1 : 0}:${supportAiState}`; 4081 if (signature === supportThreadSignature) return; 4082 supportThreadSignature = signature; 4083 4084 const wasAtBottom = threadEl.scrollHeight - threadEl.scrollTop - threadEl.clientHeight < 80; 4085 4086 let html = messages.map(m => { 4087 const sender = (m.sender_type || 'user'); 4088 // ADMIN-ONLY internal notes (the AI's escalation reason + the admin's 4089 // instruction reply). The end user never sees these â render them as a 4090 // distinct system note, and give the AI's note a "reply & re-activate" 4091 // affordance. 4092 if (sender === 'internal_ai' || sender === 'internal_admin') { 4093 return renderInternalNote(m, sender); 4094 } 4095 if (sender === 'internal_report') { 4096 return renderPingReportAnalysisNote(m); 4097 } 4098 let body; 4099 switch (m.type) { 4100 case 'image': 4101 body = `<img src="${escapeSupportHtml(m.media_url)}" alt="imagem" loading="lazy" class="support-zoomable" tabindex="0" role="button" aria-label="Abrir imagem em tela cheia">`; 4102 break; 4103 case 'video': 4104 body = `<video controls preload="metadata" src="${escapeSupportHtml(m.media_url)}"></video>`; 4105 break; 4106 case 'audio': { 4107 let audioHtml = `<audio controls preload="none" src="${escapeSupportHtml(m.media_url)}"></audio>`; 4108 if (m.transcription && m.transcription.trim()) { 4109 audioHtml += `<div class="support-audio-transcript">${linkifySupportText(m.transcription)}</div>`; 4110 } 4111 body = audioHtml; 4112 break; 4113 } 4114 default: 4115 body = linkifySupportText(m.text || ''); 4116 } 4117 // The driver's own ð/ð on this reply (only ever set on incoming bubbles). 4118 const userReaction = sender !== 'user' ? renderUserReaction(m.user_reaction) : ''; 4119 const rating = sender === 'ai' 4120 ? renderStarControl(m.id) + renderFeedbackHistory(m.feedback || []) 4121 : ''; 4122 // Teach the AI from a user message/attachment (feeds the skill-update loop). 4123 const teaching = sender === 'user' 4124 ? renderTeachHistory(m.feedback || []) + renderTeachControl(m.id) 4125 : ''; 4126 const reportPanel = m.type === 'ping_report' && m.ping_report ? renderPingReportPanel(m.ping_report) : ''; 4127 return ` 4128 <div class="support-msg ${escapeSupportHtml(sender)}${reportPanel ? ' ping-report' : ''}" data-msg-id="${escapeSupportHtml(m.id)}"> 4129 <div class="support-bubble">${body}</div> 4130 ${reportPanel}
4131 <span class="support-msg-time">${formatSupportTime(m.created_at)}</span> 4132 ${userReaction} 4133 ${rating} 4134 ${teaching} 4135 </div>`; 4136 }).join(''); 4137 4138 if (processing) { 4139 html += ` 4140 <div class="support-msg ai support-typing-msg"> 4141 <div class="support-bubble support-typing" role="status" aria-label="Assistente digitando"> 4142 <span class="support-typing-dot"></span> 4143 <span class="support-typing-dot"></span> 4144 <span class="support-typing-dot"></span> 4145 </div> 4146 </div>`; 4147 } 4148 4149 threadEl.innerHTML = html; 4150 4151 threadEl.querySelectorAll('.support-star').forEach(btn => { 4152 btn.addEventListener('click', () => onSupportStarClick(btn.dataset.msgId, Number(btn.dataset.star))); 4153 }); 4154 threadEl.querySelectorAll('.support-instruct-btn').forEach(btn => { 4155 btn.addEventListener('click', () => openInstructionBox(btn.dataset.msgId)); 4156 }); 4157 threadEl.querySelectorAll('.support-teach-btn').forEach(btn => { 4158 btn.addEventListener('click', () => openTeachBox(btn.dataset.msgId)); 4159 }); 4160 wirePingReportActions(threadEl); 4161 4162 if (wasAtBottom || messages.length !== supportLastMessageCount) { 4163 threadEl.scrollTop = threadEl.scrollHeight; 4164 } 4165 supportLastMessageCount = messages.length; 4166} 4167 4168/** 4169 * Renders an ADMIN-ONLY internal note inside the thread. `internal_ai` is the 4170 * AI's private explanation of WHY it escalated (with a "reply & re-activate" 4171 * button); `internal_admin` is the admin's instruction reply. Neither is ever 4172 * shown to the end user (they live only in MySQL / this dashboard). 4173 */ 4174function renderInternalNote(m, sender) { 4175 const isAi = sender === 'internal_ai'; 4176 const label = isAi ? 'Motivo da escalação da IA' : 'Sua instrução para a IA'; 4177 const body = linkifySupportText(m.text || ''); 4178 // Only the AI's escalation note gets the reply/re-activate affordance. 4179 const action = isAi 4180 ? `<button type="button" class="support-instruct-btn" data-msg-id="${escapeSupportHtml(m.id)}">â©ï¸ Responder e reativar IA</button>` 4181 : ''; 4182 return ` 4183 <div class="support-msg internal ${isAi ? 'internal-ai' : 'internal-admin'}" data-msg-id="${escapeSupportHtml(m.id)}"> 4184 <div class="support-internal-note"> 4185 <div class="support-internal-head"> 4186 <span class="support-internal-lock" title="Só você (admin) vê esta mensagem">ð</span> 4187 ${escapeSupportHtml(label)} · <span class="support-internal-only">só você vê</span> 4188 </div> 4189 <div class="support-internal-body">${body}</div> 4190 ${action} 4191 </div> 4192 <span class="support-msg-time">${formatSupportTime(m.created_at)}</span> 4193 </div>`; 4194} 4195 4196function supportInternalNoteEl(msgId) { 4197 return Array.from(document.querySelectorAll('.support-msg.internal')).find(el => el.dataset.msgId === msgId) || null; 4198} 4199 4200// ââ "Reportar corrida" ping reports âââââââââââââââââââââââââââââââââââââââââ 4201// A report is a `ping_report` user message carrying `ping_report` (the stored 4202// ping + chat verdict + analysis); its analysis also lands as an admin-only 4203// `internal_report` note carrying the same object. 4204 4205let supportThreadMessages = []; // last rendered thread (for the Cursor hand-off prompt) 4206let supportThreadReports = {}; // report_id -> report, from the last rendered thread 4207 4208const RIDE_APP_LABELS = { 4209 'com.ubercab.driver': 'Uber', 4210 'com.app99.driver': '99', 4211 'com.app99.driver|food': '99 Food', 4212 'sinet.startup.inDriver': 'InDriver', 4213 'br.com.ifood.driver.app': 'iFood', 4214 'com.sankuai.sailor.courier': 'Keeta', 4215 'com.lyft.android.driver': 'Lyft', 4216 'com.doordash.driverapp': 'DoorDash', 4217 'com.walmart.sparkdriver': 'Spark', 4218 'com.grubhub.driver': 'Grubhub', 4219 'com.yazam.empower.driver': 'Empower', 4220}; 4221function rideAppLabel(pkg) { return RIDE_APP_LABELS[pkg] || pkg || 'app desconhecido'; } 4222 4223const PING_REPORT_VERDICT_LABELS = {
4224 user_misunderstanding: 'Entendimento do motorista', 4225 needs_review: 'Precisa de revisão', 4226 reading_error: 'Erro de leitura', 4227 calculation_error: 'Erro de cálculo', 4228 platform_change: 'Mudança no app da corrida', 4229 settings_issue: 'Configuração do motorista', 4230 feature_request: 'Pedido de funcionalidade', 4231 inconclusive: 'Inconclusivo', 4232}; 4233const PING_REPORT_SEVERITY_LABELS = { low: 'baixa', medium: 'média', high: 'alta', critical: 'crÃtica' }; 4234const PING_REPORT_STATUS_LABELS = { 4235 pending: 'Análise na fila', 4236 processing: 'Analisandoâ¦', 4237 analyzed: 'Analisado', 4238 failed: 'Análise falhou', 4239}; 4240const PING_COLOR_LABELS = { 2: 'verde', 1: 'amarelo', 0: 'vermelho' }; 4241 4242function pingReportChip(text, cls) { 4243 return `<span class="ping-report-chip${cls ? ' ' + cls : ''}">${escapeSupportHtml(text)}</span>`; 4244} 4245 4246function clipText(value, max) { 4247 const t = String(value || '').trim(); 4248 return t.length > max ? `${t.slice(0, max)}â¦` : t; 4249} 4250 4251function formatReportNumber(value) { 4252 const n = Number(value); 4253 if (value === null || value === undefined || value === '' || !Number.isFinite(n)) return 'â'; 4254 return n.toLocaleString('pt-BR', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); 4255} 4256 4257/** Key numbers of the reported card, as the ping was stored. */ 4258function renderPingReportMetrics(ping, country) { 4259 if (!ping) return '<div class="ping-report-empty">Ping indisponÃvel.</div>'; 4260 const money = country === 'US' ? '$' : (country === 'BR' ? 'R$' : ''); 4261 const unit = country === 'US' ? 'mi' : 'km'; 4262 const color = v => PING_COLOR_LABELS[v] !== undefined ? ` (${PING_COLOR_LABELS[v]})` : ''; 4263 const rows = [ 4264 ['Preço', `${money} ${formatReportNumber(ping.main_price)}`.trim()], 4265 [`${money}/${unit}`, `${formatReportNumber(ping.gain_per_km)}${color(ping.distance_color)} · metas ${formatReportNumber(ping.threshold_km_start)}â${formatReportNumber(ping.threshold_km_end)}`], 4266 [`${money}/h`, `${formatReportNumber(ping.gain_per_hour)}${color(ping.hour_color)} · metas ${formatReportNumber(ping.threshold_hr_start)}â${formatReportNumber(ping.threshold_hr_end)}`], 4267 ['Nota', `${formatReportNumber(ping.rider_score)}${color(ping.score_color)}`], 4268 ['Embarque', `${formatReportNumber(ping.pick_rider_dist_km)} ${unit} · ${formatReportNumber(ping.pick_rider_dur_min)} min`], 4269 ['Viagem', `${formatReportNumber(ping.trip_distance_km)} ${unit} · ${formatReportNumber(ping.trip_duration_min)} min`], 4270 ['Borda', (PING_COLOR_LABELS[ping.outer_stroke_color] || 'â') + (Number(ping.is_surge_mode) === 1 ? ' · metas de dinâmica' : '')], 4271 ]; 4272 return `<div class="ping-report-metrics">${rows.map(([k, v]) => 4273 `<span class="ping-report-metric-key">${escapeSupportHtml(k)}</span><span class="ping-report-metric-val">${escapeSupportHtml(v)}</span>` 4274 ).join('')}</div>`; 4275} 4276 4277function renderPingReportActions(reportId) { 4278 const id = escapeSupportHtml(String(reportId)); 4279 return ` 4280 <div class="ping-report-actions"> 4281 <button type="button" class="ping-report-btn ping-report-cursor-btn" data-report-id="${id}" title="Abre o Cursor com um prompt que carrega esta conversa e este reporte">Abrir no Cursor</button> 4282 <button type="button" class="ping-report-btn ping-report-copy-btn" data-report-id="${id}" title="Copia o prompt para colar numa nova conversa do Cursor">Copiar prompt</button> 4283 </div>`; 4284} 4285 4286/** Panel under the driver's report bubble: the stored ping, the chat's call and the analysis state. */ 4287function renderPingReportPanel(r) { 4288 const chips = []; 4289 if (r.chat_verdict) { 4290 chips.push(pingReportChip(`Chat: ${PING_REPORT_VERDICT_LABELS[r.chat_verdict] || r.chat_verdict}`, 4291 r.chat_verdict === 'user_misunderstanding' ? 'ok' : 'warn')); 4292 } else { 4293 chips.push(pingReportChip('Chat: sem veredito ainda', 'muted')); 4294 } 4295 const a = r.analysis; 4296 if (r.status === 'analyzed' && a) { 4297 chips.push(pingReportChip(`Análise: ${PING_REPORT_VERDICT_LABELS[a.verdict] || a.verdict}`, r.action_required ? 'warn' : 'ok')); 4298 } else { 4299 chips.push(pingReportChip(PING_REPORT_STATUS_LABELS[r.status] || r.status || 'Análise na fila', r.status === 'failed' ? 'danger' : 'muted')); 4300 } 4301 if (r.urgent) chips.push(pingReportChip('URGENTE', 'danger')); 4302 if (r.platform_change_suspected) chips.push(pingReportChip('PossÃvel mudança no app da corrida', 'danger')); 4303 if (!r.ping_verified) chips.push(pingReportChip('Ping já expirado no servidor â cópia do app', 'muted')); 4304 4305 const meta = [rideAppLabel(r.app_package_name), r.country, r.app_version ? `app ${r.app_version}` : ''] 4306 .filter(Boolean).join(' · '); 4307 const chatNote = r.chat_note 4308 ? `<div class="ping-report-note"><strong>Nota do chat:</strong> ${linkifySupportText(r.chat_note)}</div>` 4309 : ''; 4310 const ocr = r.ocr_lines 4311 ? `<details class="ping-report-ocr"><summary>Leitura OCR</summary><pre>${escapeSupportHtml(r.ocr_lines)}</pre></details>` 4312 : ''; 4313 const shot = r.screenshot_url 4314 ? `<a class="ping-report-shot" href="${escapeSupportHtml(r.screenshot_url)}" target="_blank" rel="noopener">Ver print da oferta</a>` 4315 : ''; 4316 return ` 4317 <div class="ping-report-panel"> 4318 <div class="ping-report-head">ð© Reporte #${escapeSupportHtml(String(r.report_id))} · ${escapeSupportHtml(meta)}</div> 4319 <div class="ping-report-chips">
4319${chips.join('')}</div> 4320 ${renderPingReportMetrics(r.ping, r.country)} 4321 ${chatNote} 4322 ${ocr} 4323 ${shot} 4324 ${renderPingReportActions(r.report_id)} 4325 </div>`; 4326} 4327 4328/** The admin-only analysis note, rendered from the structured result when present. */ 4329function renderPingReportAnalysisNote(m) { 4330 const r = m.ping_report; 4331 const a = r && r.analysis; 4332 let body; 4333 if (a && typeof a === 'object') { 4334 const chips = [ 4335 pingReportChip(PING_REPORT_VERDICT_LABELS[a.verdict] || a.verdict || 'sem veredito', r.action_required ? 'warn' : 'ok'), 4336 pingReportChip(`severidade ${PING_REPORT_SEVERITY_LABELS[a.severity] || a.severity || 'â'}`, 4337 a.severity === 'critical' || a.severity === 'high' ? 'danger' : 'muted'), 4338 ]; 4339 if (r.urgent) chips.push(pingReportChip('URGENTE', 'danger')); 4340 if (r.platform_change_suspected) chips.push(pingReportChip('PossÃvel mudança no app da corrida', 'danger')); 4341 if (r.action_required) chips.push(pingReportChip('Requer ação', 'warn')); 4342 const actions = Array.isArray(a.recommended_actions) && a.recommended_actions.length 4343 ? `<div class="ping-report-section"><strong>Ações recomendadas</strong><ul>${a.recommended_actions.map(x => `<li>${linkifySupportText(x)}</li>`).join('')}</ul></div>` 4344 : ''; 4345 const fix = a.suggested_fix && a.suggested_fix.description 4346 ? `<div class="ping-report-section"><strong>Correção sugerida</strong><div>${linkifySupportText(a.suggested_fix.description)}</div>${ 4347 Array.isArray(a.suggested_fix.files) && a.suggested_fix.files.length 4348 ? `<div class="ping-report-files">${a.suggested_fix.files.map(f => `<code>${escapeSupportHtml(f)}</code>`).join(' ')}</div>` : '' 4349 }${a.suggested_fix.test_hint ? `<div class="ping-report-muted">Teste: ${linkifySupportText(a.suggested_fix.test_hint)}</div>` : ''}</div>` 4350 : ''; 4351 const findings = a.findings 4352 ? `<details class="ping-report-ocr" open><summary>Achados</summary><div class="support-internal-body">${linkifySupportText(a.findings)}</div></details>` 4353 : ''; 4354 const similar = Array.isArray(a.similar_report_ids) && a.similar_report_ids.length 4355 ? `<div class="ping-report-muted">Reportes parecidos: ${a.similar_report_ids.map(id => '#' + escapeSupportHtml(String(id))).join(', ')}</div>` 4356 : ''; 4357 const model = r.analysis_model ? `<div class="ping-report-muted">Modelo: ${escapeSupportHtml(r.analysis_model)}</div>` : ''; 4358 body = ` 4359 <div class="ping-report-chips">${chips.join('')}</div> 4360 <div class="support-internal-body ping-report-summary">${linkifySupportText(a.summary || '')}</div> 4361 ${findings}${actions}${fix}${similar}${model}`; 4362 } else { 4363 body = `<div class="support-internal-body">${linkifySupportText(m.text || '')}</div>`; 4364 } 4365 const title = r 4366 ? `Análise do reporte #${r.report_id} · ${rideAppLabel(r.app_package_name)}` 4367 : 'Análise de reporte'; 4368 return ` 4369 <div class="support-msg internal internal-report${r && r.urgent ? ' urgent' : ''}" data-msg-id="${escapeSupportHtml(m.id)}"> 4370 <div class="support-internal-note"> 4371 <div class="support-internal-head"> 4372 <span class="support-internal-lock" title="Só você (admin) vê esta mensagem">ð</span> 4373 ${escapeSupportHtml(title)} · <span class="support-internal-only">só você vê</span> 4374 </div> 4375 ${body} 4376 ${r ? renderPingReportActions(r.report_id) : ''} 4377 </div> 4378 <span class="support-msg-time">${formatSupportTime(m.created_at)}</span> 4379 </div>`; 4380} 4381 4382// ââ Cursor hand-off âââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 4383// Cursor's prompt deeplink pre-fills a chat (it never runs by itself); the whole 4384// URL is capped at 10,000 chars after encoding. 4385const CURSOR_PROMPT_DEEPLINK = 'cursor://anysphere.cursor-deeplink/prompt'; 4386const CURSOR_PROMPT_MAX_URL = 9500; 4387 4388function buildCursorHandoffPrompt(userId, report) { 4389 const lines = [ 4390 'Use a skill `.cursor/skills/support-thread-handoff/SKILL.md` para retomar esta conversa do chat de suporte do JetMAX.', 4391 '', 4392 `- user_id: ${userId}`, 4393 ]; 4394 if (supportSelectedName) lines.push(`- Motorista: ${supportSelectedName}`); 4395 if (report) { 4396 const meta = [rideAppLabel(report.app_package_name), report.country, report.app_version ? `app ${report.app_version}` : ''] 4397 .filter(Boolean).join(' · '); 4398 lines.push(`- report_id: ${report.report_id} (tabela ping_reports) â ${meta}`); 4399 lines.push(`- Mensagem do motorista: "${clipText(report.user_message, 500)}"`); 4400 if (report.chat_verdict) { 4401 lines.push(`- Veredito do chat: ${PING_REPORT_VERDICT_LABELS[report.chat_verdict] || report.chat_verdict}${report.chat_note ? ` â ${clipText(report.chat_note, 400)}` : ''}`); 4402 } 4403 const a = report.analysis; 4404 if (a && typeof a === 'object') { 4405 const flags = [report.urgent ? 'URGENTE' : '', report.platform_change_suspected ? 'possÃvel mudança no app da corrida' : ''] 4406 .filter(Boolean).join(', '); 4407 lines.push(`- Análise automática: ${PING_REPORT_VERDICT_LABELS[a.verdict] || a.verdict}
4407, severidade ${PING_REPORT_SEVERITY_LABELS[a.severity] || a.severity}${flags ? ` (${flags})` : ''}`); 4408 if (a.summary) lines.push(` ${clipText(a.summary, 500)}`); 4409 if (a.suggested_fix && a.suggested_fix.description) { 4410 const files = Array.isArray(a.suggested_fix.files) ? a.suggested_fix.files.join(', ') : ''; 4411 lines.push(`- Correção sugerida: ${clipText(a.suggested_fix.description, 600)}${files ? ` (${files})` : ''}`); 4412 } 4413 } else { 4414 lines.push(`- Análise automática: ${PING_REPORT_STATUS_LABELS[report.status] || report.status || 'na fila'}`); 4415 } 4416 lines.push('', 'Objetivo: confirme a análise deste reporte e, se houver algo a corrigir, implemente a correção com um teste de regressão.'); 4417 } else { 4418 const reportIds = Object.keys(supportThreadReports); 4419 if (reportIds.length) lines.push(`- Reportes de corrida na conversa: ${reportIds.map(id => '#' + id).join(', ')}`); 4420 const recent = supportThreadMessages 4421 .filter(m => ['user', 'ai', 'agent'].includes(m.sender_type)) 4422 .slice(-6) 4423 .map(m => { 4424 const who = m.sender_type === 'user' ? 'Motorista' : (m.sender_type === 'ai' ? 'IA' : 'Agente'); 4425 const text = m.type === 'audio' ? (m.transcription || '[áudio]') : (m.type === 'image' ? '[imagem]' : (m.text || '')); 4426 return ` ${who}: ${clipText(String(text).replace(/\s+/g, ' '), 240)}`; 4427 }); 4428 if (recent.length) lines.push('- Ãltimas mensagens:', ...recent); 4429 lines.push('', 'Objetivo: resuma onde o caso está e proponha o próximo passo.'); 4430 } 4431 return lines.join('\n'); 4432} 4433 4434function cursorPromptUrl(prompt) { 4435 const build = text => { 4436 const u = new URL(CURSOR_PROMPT_DEEPLINK); 4437 u.searchParams.set('text', text); 4438 return u.toString(); 4439 }; 4440 let text = prompt; 4441 let url = build(text); 4442 while (url.length > CURSOR_PROMPT_MAX_URL && text.length > 400) { 4443 text = `${text.slice(0, Math.floor(text.length * 0.8))}â¦`; 4444 url = build(text); 4445 } 4446 return url; 4447} 4448 4449function openCursorPrompt(prompt) { 4450 const a = document.createElement('a'); 4451 a.href = cursorPromptUrl(prompt); 4452 a.rel = 'noopener'; 4453 document.body.appendChild(a); 4454 a.click(); 4455 a.remove(); 4456} 4457 4458async function copyCursorPrompt(prompt, btn) { 4459 try { 4460 await navigator.clipboard.writeText(prompt); 4461 if (btn) { 4462 const original = btn.textContent; 4463 btn.textContent = 'Copiado!'; 4464 setTimeout(() => { btn.textContent = original; }, 1500); 4465 } 4466 } catch (e) { 4467 console.error('Clipboard error:', e); 4468 window.prompt('Copie o prompt:', prompt); 4469 } 4470} 4471 4472function wirePingReportActions(threadEl) { 4473 threadEl.querySelectorAll('.ping-report-cursor-btn').forEach(btn => { 4474 btn.addEventListener('click', () => { 4475 const r = supportThreadReports[btn.dataset.reportId]; 4476 if (supportSelectedUserId) openCursorPrompt(buildCursorHandoffPrompt(supportSelectedUserId, r)); 4477 }); 4478 }); 4479 threadEl.querySelectorAll('.ping-report-copy-btn').forEach(btn => { 4480 btn.addEventListener('click', () => { 4481 const r = supportThreadReports[btn.dataset.reportId]; 4482 if (supportSelectedUserId) copyCursorPrompt(buildCursorHandoffPrompt(supportSelectedUserId, r), btn); 4483 }); 4484 }); 4485} 4486 4487/** Urgent report findings of the last day, over the chat (e.g. a ride app changed its offer screen). */ 4488function renderPingReportAlerts(alerts) { 4489 const el = document.getElementById('support-report-alerts'); 4490 if (!el) return; 4491 if (!alerts || !alerts.length) { 4492 el.hidden = true; 4493 el.innerHTML = ''; 4494 return; 4495 } 4496 el.hidden = false; 4497 el.innerHTML = alerts.slice(0, 5).map(a => ` 4498 <button type="button" class="support-report-alert" data-user="${escapeSupportHtml(a.user_id)}"> 4499 <span class="support-report-alert-tag">${a.platform_change_suspected ? 'â ï¸ Mudança no app da corrida' : 'â ï¸ Reporte urgente'}</span>
4500 <span class="support-report-alert-app">${escapeSupportHtml(rideAppLabel(a.app_package_name))}${a.country ? ' · ' + escapeSupportHtml(a.country) : ''}</span> 4501 <span class="support-report-alert-text">${escapeSupportHtml(clipText(a.summary, 220))}</span> 4502 <span class="support-report-alert-time">${formatSupportTime(a.analyzed_at)}</span> 4503 </button>`).join('') + 4504 (alerts.length > 5 ? `<div class="support-report-alert-more">+${alerts.length - 5} alertas nas últimas 24 h</div>` : ''); 4505 el.querySelectorAll('.support-report-alert').forEach(btn => { 4506 btn.addEventListener('click', () => { 4507 const item = document.querySelector(`.support-conv-item[data-user="${CSS.escape(btn.dataset.user)}"]`); 4508 if (item) item.click(); 4509 else selectSupportConversation(btn.dataset.user, btn.dataset.user.slice(0, 8), '', '', false, false); 4510 }); 4511 }); 4512} 4513 4514/** 4515 * Reveals an inline "instruct the AI & re-activate it" box under an AI escalation 4516 * note. Sending it re-arms the AI with a one-shot directive so it answers the 4517 * user right away, following the admin's guidance. 4518 */ 4519function openInstructionBox(msgId) { 4520 const noteEl = supportInternalNoteEl(msgId); 4521 if (!noteEl) return; 4522 supportInstructionOpenMsgId = msgId; 4523 const trigger = noteEl.querySelector('.support-instruct-btn'); 4524 if (trigger) trigger.style.display = 'none'; 4525 4526 let box = noteEl.querySelector('.support-instruction-box'); 4527 if (!box) { 4528 box = document.createElement('div'); 4529 box.className = 'support-instruction-box'; 4530 box.innerHTML = ` 4531 <textarea class="support-instruction-input" rows="2" placeholder="Instrução para a IA (só você vê) â ela volta a atender o usuário seguindo isto e, se a orientação for generalizável, aprende para os próximos casosâ¦" maxlength="2000"></textarea> 4532 <div class="support-instruction-actions"> 4533 <button type="button" class="support-instruction-send">Enviar e reativar IA</button> 4534 <button type="button" class="support-instruction-cancel">Cancelar</button> 4535 </div>`; 4536 noteEl.querySelector('.support-internal-note').appendChild(box); 4537 } 4538 const input = box.querySelector('.support-instruction-input'); 4539 input.value = supportInstructionDrafts[msgId] || ''; 4540 input.oninput = () => { supportInstructionDrafts[msgId] = input.value; }; 4541 const submit = () => sendAiInstruction(msgId, input.value); 4542 box.querySelector('.support-instruction-send').onclick = submit; 4543 box.querySelector('.support-instruction-cancel').onclick = () => closeInstructionBox(msgId); 4544 input.onkeydown = (e) => { 4545 // Enter sends; Shift+Enter for a newline; Esc cancels. 4546 if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); submit(); } 4547 else if (e.key === 'Escape') { e.preventDefault(); closeInstructionBox(msgId); } 4548 }; 4549 input.focus(); 4550} 4551 4552function closeInstructionBox(msgId) { 4553 if (supportInstructionOpenMsgId === msgId) supportInstructionOpenMsgId = null; 4554 delete supportInstructionDrafts[msgId]; 4555 supportThreadSignature = ''; // force a clean repaint 4556 if (supportSelectedUserId) loadSupportMessages(supportSelectedUserId, true); 4557} 4558 4559async function sendAiInstruction(msgId, text) { 4560 const instruction = (text || '').trim(); 4561 if (!adminToken || !supportSelectedUserId || !instruction) return; 4562 const noteEl = supportInternalNoteEl(msgId); 4563 if (noteEl) noteEl.classList.add('sending'); 4564 try { 4565 const res = await fetch(`${ANALYTICS_API_BASE}/admin/ai_support/instruct`, { 4566 method: 'POST', 4567 headers: { 'Authorization': `Bearer ${adminToken}`, 'Content-Type': 'application/json' }, 4568 body: JSON.stringify({ user_id: supportSelectedUserId, text: instruction }), 4569 }); 4570 if (res.status === 401) { handleTokenExpired(); return; } 4571 if (!res.ok) throw new Error('instruct failed (' + res.status + ')'); 4572 supportInstructionOpenMsgId = null; 4573 delete supportInstructionDrafts[msgId]; 4574 supportThreadSignature = ''; // ensure the new note + re-armed state repaint 4575 // Optimistically reflect the re-armed state, then refresh the thread and the 4576 // conversation list (clears the escalated red dot immediately). 4577 supportAiState = 'idle'; 4578 supportAiToggleBusyUntil = 0; 4579 renderAiToggle('idle'); 4580 if (supportSelectedUserId) await loadSupportMessages(supportSelectedUserId, true); 4581 loadSupportConversations(); 4582 } catch (e) { 4583 console.error('AI instruct error:', e); 4584 if (noteEl) noteEl.classList.remove('sending'); 4585 alert('Falha ao enviar a instrução. Tente novamente.'); 4586 } 4587} 4588 4589function supportRatingEl(msgId) {
4590 return Array.from(document.querySelectorAll('.support-rating')).find(el => el.dataset.msgId === msgId) || null; 4591} 4592 4593/** Star click: 5â submits immediately; <5â opens a comment box first. */ 4594function onSupportStarClick(msgId, star) { 4595 if (!msgId || !(star >= 1 && star <= 5)) return; 4596 if (star === 5) { 4597 submitFeedback(msgId, 5, ''); 4598 return; 4599 } 4600 openFeedbackComment(msgId, star); 4601} 4602 4603/** Reveals an inline "why wasn't it perfect?" comment box under a bubble. */ 4604function openFeedbackComment(msgId, star) { 4605 const ratingEl = supportRatingEl(msgId); 4606 if (!ratingEl) return; 4607 supportFeedbackOpenMsgId = msgId; 4608 4609 ratingEl.querySelectorAll('.support-star').forEach(b => { 4610 const n = Number(b.dataset.star); 4611 const on = n <= star; 4612 b.classList.toggle('on', on); 4613 b.textContent = on ? 'â ' : 'â'; 4614 }); 4615 4616 let box = ratingEl.querySelector('.support-feedback-box'); 4617 if (!box) { 4618 box = document.createElement('div'); 4619 box.className = 'support-feedback-box'; 4620 box.innerHTML = ` 4621 <input type="text" class="support-feedback-input" placeholder="O que faltou para ser perfeito?" maxlength="2000"> 4622 <button type="button" class="support-feedback-send">Enviar</button> 4623 <button type="button" class="support-feedback-cancel" title="Cancelar" aria-label="Cancelar">â</button>`; 4624 ratingEl.appendChild(box); 4625 } 4626 const input = box.querySelector('.support-feedback-input'); 4627 input.value = supportFeedbackDrafts[msgId] || ''; 4628 input.oninput = () => { supportFeedbackDrafts[msgId] = input.value; }; 4629 const submit = () => submitFeedback(msgId, star, input.value); 4630 box.querySelector('.support-feedback-send').onclick = submit; 4631 box.querySelector('.support-feedback-cancel').onclick = () => closeFeedbackComment(msgId); 4632 input.onkeydown = (e) => { 4633 if (e.key === 'Enter') { e.preventDefault(); submit(); } 4634 else if (e.key === 'Escape') { e.preventDefault(); closeFeedbackComment(msgId); } 4635 }; 4636 input.focus(); 4637} 4638 4639function closeFeedbackComment(msgId) { 4640 if (supportFeedbackOpenMsgId === msgId) supportFeedbackOpenMsgId = null; 4641 delete supportFeedbackDrafts[msgId]; 4642 supportThreadSignature = ''; // force a clean repaint 4643 if (supportSelectedUserId) loadSupportMessages(supportSelectedUserId, true); 4644} 4645 4646async function submitFeedback(msgId, stars, comment) { 4647 if (!adminToken || !msgId) return; 4648 const ratingEl = supportRatingEl(msgId); 4649 if (ratingEl) ratingEl.classList.add('sending'); 4650 try { 4651 const res = await fetch(`${ANALYTICS_API_BASE}/admin/ai_support/feedback`, { 4652 method: 'POST', 4653 headers: { 4654 'Authorization': `Bearer ${adminToken}`, 4655 'Content-Type': 'application/json', 4656 }, 4657 body: JSON.stringify({ message_id: msgId, stars: stars, comment: (comment || '').trim() }), 4658 }); 4659 if (res.status === 401) { handleTokenExpired(); return; } 4660 if (!res.ok) throw new Error('feedback failed (' + res.status + ')'); 4661 supportFeedbackOpenMsgId = null; 4662 delete supportFeedbackDrafts[msgId]; 4663 supportThreadSignature = ''; // ensure the "Avaliado" state repaints 4664 if (supportSelectedUserId) await loadSupportMessages(supportSelectedUserId, true); 4665 } catch (e) { 4666 console.error('Support feedback error:', e); 4667 if (ratingEl) ratingEl.classList.remove('sending'); 4668 alert('Falha ao enviar a avaliação. Tente novamente.'); 4669 } 4670} 4671 4672function supportMsgEl(msgId) { 4673 return Array.from(document.querySelectorAll('.support-msg')).find(el => el.dataset.msgId === msgId) || null; 4674} 4675 4676/** 4677 * Reveals an inline "teach the AI from this user message" box under a user 4678 * bubble. Sending it stores a teaching note (kind='user_note') as skill-update 4679 * evidence and triggers a skill update, so the AI learns to handle that kind of 4680 * message better. The note is internal â the user never sees it. 4681 */ 4682function openTeachBox(msgId) { 4683 const msgEl = supportMsgEl(msgId); 4684 if (!msgEl) return; 4685 supportTeachOpenMsgId = msgId;
4686 const trigger = msgEl.querySelector('.support-teach-btn'); 4687 if (trigger) trigger.style.display = 'none'; 4688 4689 let box = msgEl.querySelector('.support-teach-box'); 4690 if (!box) { 4691 box = document.createElement('div'); 4692 box.className = 'support-teach-box'; 4693 box.innerHTML = ` 4694 <textarea class="support-teach-input" rows="2" placeholder="O que a IA deve aprender com esta mensagem do usuário? (só você vê) â ex.: quando o usuário mandar um print assim, responda Xâ¦" maxlength="2000"></textarea> 4695 <div class="support-teach-actions"> 4696 <button type="button" class="support-teach-send">Ensinar a IA</button> 4697 <button type="button" class="support-teach-cancel">Cancelar</button> 4698 </div>`; 4699 msgEl.querySelector('.support-teach').appendChild(box); 4700 } 4701 const input = box.querySelector('.support-teach-input'); 4702 input.value = supportTeachDrafts[msgId] || ''; 4703 input.oninput = () => { supportTeachDrafts[msgId] = input.value; }; 4704 const submit = () => submitTeachNote(msgId, input.value); 4705 box.querySelector('.support-teach-send').onclick = submit; 4706 box.querySelector('.support-teach-cancel').onclick = () => closeTeachBox(msgId); 4707 input.onkeydown = (e) => { 4708 // Enter sends; Shift+Enter for a newline; Esc cancels. 4709 if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); submit(); } 4710 else if (e.key === 'Escape') { e.preventDefault(); closeTeachBox(msgId); } 4711 }; 4712 input.focus(); 4713} 4714 4715function closeTeachBox(msgId) { 4716 if (supportTeachOpenMsgId === msgId) supportTeachOpenMsgId = null; 4717 delete supportTeachDrafts[msgId]; 4718 supportThreadSignature = ''; // force a clean repaint 4719 if (supportSelectedUserId) loadSupportMessages(supportSelectedUserId, true); 4720} 4721 4722async function submitTeachNote(msgId, note) { 4723 const text = (note || '').trim(); 4724 if (!adminToken || !msgId || !text) return; 4725 const msgEl = supportMsgEl(msgId); 4726 if (msgEl) msgEl.classList.add('teaching'); 4727 try { 4728 const res = await fetch(`${ANALYTICS_API_BASE}/admin/ai_support/teach`, { 4729 method: 'POST', 4730 headers: { 'Authorization': `Bearer ${adminToken}`, 'Content-Type': 'application/json' }, 4731 body: JSON.stringify({ message_id: msgId, note: text }), 4732 }); 4733 if (res.status === 401) { handleTokenExpired(); return; } 4734 if (!res.ok) throw new Error('teach failed (' + res.status + ')'); 4735 supportTeachOpenMsgId = null; 4736 delete supportTeachDrafts[msgId]; 4737 supportThreadSignature = ''; // ensure the new note + pending badge repaint 4738 if (supportSelectedUserId) await loadSupportMessages(supportSelectedUserId, true); 4739 } catch (e) { 4740 console.error('AI teach error:', e); 4741 if (msgEl) msgEl.classList.remove('teaching'); 4742 alert('Falha ao enviar a orientação. Tente novamente.'); 4743 } 4744} 4745 4746async function sendSupportReply() { 4747 if (!adminToken || !supportSelectedUserId) return; 4748 const input = document.getElementById('support-input'); 4749 const sendBtn = document.getElementById('support-send-btn'); 4750 const text = (input.value || '').trim(); 4751 if (!text) return; 4752 4753 input.value = ''; 4754 if (sendBtn) sendBtn.disabled = true; 4755 try { 4756 const res = await fetchWithRetry(`${ANALYTICS_API_BASE}/admin/support_send_message`, { 4757 method: 'POST', 4758 headers: { 4759 'Authorization': `Bearer ${adminToken}`, 4760 'Content-Type': 'application/json', 4761 }, 4762 body: JSON.stringify({ 4763 user_id: supportSelectedUserId, 4764 type: 'text', 4765 text: text, 4766 sender_type: 'agent', 4767 }), 4768 }); 4769 if (res.status === 401) { handleTokenExpired(); return; } 4770 if (!res.ok) throw new Error('Failed to send'); 4771 await loadSupportMessages(supportSelectedUserId, true); 4772 } catch (e) { 4773 console.error('Support send error:', e); 4774 if (input) input.value = text; // restore on failure 4775 } finally { 4776 if (sendBtn) sendBtn.disabled = false;
4777 if (input) input.focus(); 4778 } 4779} 4780 4781// --- Media attachments (image / video / audio) --- 4782 4783function supportTypeFromMime(mime) { 4784 if (!mime) return null; 4785 if (mime.startsWith('image/')) return 'image'; 4786 if (mime.startsWith('video/')) return 'video'; 4787 if (mime.startsWith('audio/')) return 'audio'; 4788 return null; 4789} 4790 4791function supportExtFor(file, mime) { 4792 const fromName = (file && file.name && file.name.includes('.')) 4793 ? file.name.split('.').pop().toLowerCase() 4794 : ''; 4795 if (fromName) return fromName.replace(/[^a-z0-9]/g, '').slice(0, 8); 4796 const sub = (mime || '').split('/')[1] || 'bin'; 4797 return sub.split(';')[0].replace(/[^a-z0-9]/g, '').slice(0, 8) || 'bin'; 4798} 4799 4800async function uploadSupportMedia(blob, type, mime, fileName, durationMs) { 4801 if (!adminToken || !supportSelectedUserId) return; 4802 const subEl = document.getElementById('support-thread-sub'); 4803 const prevSub = subEl ? subEl.textContent : ''; 4804 if (subEl) subEl.textContent = 'Enviando arquivoâ¦'; 4805 4806 const ext = supportExtFor({ name: fileName }, mime); 4807 const params = new URLSearchParams({ 4808 user_id: supportSelectedUserId, 4809 type: type, 4810 ext: ext, 4811 content_type: mime || 'application/octet-stream', 4812 duration_ms: String(Math.round(durationMs || 0)), 4813 }); 4814 4815 try { 4816 const res = await fetch(`${ANALYTICS_API_BASE}/admin/support_upload_media?${params.toString()}`, { 4817 method: 'POST', 4818 headers: { 'Authorization': `Bearer ${adminToken}` }, 4819 body: blob, 4820 }); 4821 if (res.status === 401) { handleTokenExpired(); return; } 4822 if (!res.ok) throw new Error('Upload failed (' + res.status + ')'); 4823 await loadSupportMessages(supportSelectedUserId, true); 4824 } catch (e) { 4825 console.error('Support media upload error:', e); 4826 alert('Falha ao enviar o arquivo. Tente novamente.'); 4827 } finally { 4828 if (subEl) subEl.textContent = prevSub; 4829 } 4830} 4831 4832function handleSupportFile(file) { 4833 if (!file) return; 4834 if (!supportSelectedUserId) { alert('Selecione uma conversa primeiro.'); return; } 4835 const type = supportTypeFromMime(file.type); 4836 if (!type) { alert('Tipo de arquivo não suportado. Envie imagem, vÃdeo ou áudio.'); return; } 4837 uploadSupportMedia(file, type, file.type, file.name, 0); 4838} 4839 4840// --- Audio recording (MediaRecorder) --- 4841 4842let supportMediaRecorder = null;
4843let supportAudioChunks = []; 4844let supportRecStartMs = 0; 4845let supportRecTimer = null; 4846 4847function supportAudioMime() { 4848 const candidates = ['audio/webm;codecs=opus', 'audio/webm', 'audio/mp4', 'audio/ogg']; 4849 for (const c of candidates) { 4850 if (window.MediaRecorder && MediaRecorder.isTypeSupported(c)) return c; 4851 } 4852 return ''; 4853} 4854 4855async function toggleSupportRecording() { 4856 if (supportMediaRecorder && supportMediaRecorder.state === 'recording') { 4857 supportMediaRecorder.stop(); 4858 return; 4859 } 4860 if (!supportSelectedUserId) { alert('Selecione uma conversa primeiro.'); return; } 4861 if (!navigator.mediaDevices || !window.MediaRecorder) { 4862 alert('Gravação de áudio não suportada neste navegador.'); 4863 return; 4864 } 4865 try { 4866 const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); 4867 const mime = supportAudioMime(); 4868 supportMediaRecorder = mime ? new MediaRecorder(stream, { mimeType: mime }) : new MediaRecorder(stream); 4869 supportAudioChunks
4869= []; 4870 supportRecStartMs = Date.now(); 4871 4872 supportMediaRecorder.ondataavailable = (e) => { 4873 if (e.data && e.data.size > 0) supportAudioChunks.push(e.data); 4874 }; 4875 supportMediaRecorder.onstop = () => { 4876 const durationMs = Date.now() - supportRecStartMs; 4877 const actualMime = supportMediaRecorder.mimeType || mime || 'audio/webm'; 4878 const blob = new Blob(supportAudioChunks, { type: actualMime }); 4879 stream.getTracks().forEach(t => t.stop()); 4880 setSupportRecordingUi(false); 4881 if (durationMs > 600 && blob.size > 0) { 4882 const ext = actualMime.includes('mp4') ? 'm4a' : (actualMime.includes('ogg') ? 'ogg' : 'webm'); 4883 uploadSupportMedia(blob, 'audio', actualMime, 'voice.' + ext, durationMs); 4884 } 4885 }; 4886 supportMediaRecorder.start(); 4887 setSupportRecordingUi(true); 4888 } catch (e) { 4889 console.error('Mic error:', e); 4890 alert('Não foi possÃvel acessar o microfone.'); 4891 } 4892} 4893 4894function setSupportRecordingUi(recording) { 4895 const micBtn = document.getElementById('support-mic-btn'); 4896 const indicator = document.getElementById('support-rec-indicator'); 4897 const input = document.getElementById('support-input'); 4898 if (micBtn) micBtn.classList.toggle('recording', recording); 4899 if (indicator) indicator.style.display = recording ? 'flex' : 'none'; 4900 if (input) input.style.display = recording ? 'none' : ''; 4901 if (recording) { 4902 const timeEl = document.getElementById('support-rec-time'); 4903 supportRecTimer = setInterval(() => { 4904 const s = Math.floor((Date.now() - supportRecStartMs) / 1000); 4905 if (timeEl) timeEl.textContent = Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0'); 4906 }, 200); 4907 } else if (supportRecTimer) { 4908 clearInterval(supportRecTimer); 4909 supportRecTimer = null; 4910 } 4911} 4912 4913(function wireSupportChat() { 4914 const composer = document.getElementById('support-composer'); 4915 if (composer) { 4916 composer.addEventListener('submit', (e) => { 4917 e.preventDefault(); 4918 sendSupportReply(); 4919 }); 4920 } 4921 const refreshBtn = document.getElementById('chat-refresh-btn'); 4922 if (refreshBtn) { 4923 refreshBtn.addEventListener('click', () => { 4924 loadSupportConversations(); 4925 if (supportSelectedUserId) loadSupportMessages(supportSelectedUserId, false); 4926 }); 4927 } 4928 // Phone layout: back arrow returns from the thread view to the conversation list. 4929 const backBtn = document.getElementById('support-thread-back'); 4930 if (backBtn) { 4931 backBtn.addEventListener('click', () => { 4932 document.getElementById('admin-page-chat')?.classList.remove('thread-open'); 4933 }); 4934 } 4935 const aiToggleInput = document.getElementById('support-ai-toggle-input'); 4936 if (aiToggleInput) aiToggleInput.addEventListener('change', () => setUserAiEnabled(aiToggleInput.checked)); 4937 const checkedBtn = document.getElementById('support-report-checked-btn'); 4938 if (checkedBtn) checkedBtn.addEventListener('click', dismissReportHighlight); 4939 const cursorBtn = document.getElementById('support-cursor-btn'); 4940 if (cursorBtn) { 4941 cursorBtn.addEventListener('click', (e) => { 4942 if (!supportSelectedUserId) return; 4943 const prompt = buildCursorHandoffPrompt(supportSelectedUserId, null); 4944 if (e.shiftKey) copyCursorPrompt(prompt, cursorBtn.querySelector('span')); 4945 else openCursorPrompt(prompt); 4946 }); 4947 } 4948 4949 const skillBtn = document.getElementById('skill-update-btn'); 4950 if (skillBtn) skillBtn.addEventListener('click', requestSkillUpdate); 4951 const skillHistBtn = document.getElementById('skill-update-history-btn'); 4952 if (skillHistBtn) skillHistBtn.addEventListener('click', (e) => { e.stopPropagation(); toggleSkillUpdateHistory(); }); 4953 4954 // Attach button â file picker 4955 const attachBtn = document.getElementById('support-attach-btn'); 4956 const fileInput = document.getElementById('support-file-input'); 4957 if (attachBtn && fileInput) { 4958 attachBtn.addEventListener('click', () => fileInput.click()); 4959 fileInput.addEventListener('change', () => { 4960 if (fileInput.files && fileInput.files[0]) handleSupportFile(fileInput.files[0]); 4961 fileInput.value = ''; 4962 }); 4963 } 4964 4965 // Mic button â record audio 4966 const micBtn = document.getElementById('support-mic-btn'); 4967 if (micBtn) micBtn.addEventListener('click', toggleSupportRecording); 4968 4969 // Drag & drop onto the thread 4970 const thread = document.getElementById('support-thread'); 4971 if (thread) { 4972 let dragDepth = 0; 4973 thread.addEventListener('dragenter', (e) => { 4974 e.preventDefault(); 4975 if (!supportSelectedUserId) return; 4976 dragDepth++; 4977 thread.classList.add('drag-over'); 4978 }); 4979 thread.addEventListener('dragover', (e) => { e.preventDefault(); }); 4980 thread.addEventListener('dragleave', (e) => { 4981 e.preventDefault(); 4982 dragDepth = Math.max(0, dragDepth - 1); 4983 if (dragDepth === 0) thread.classList.remove('drag-over'); 4984 }); 4985 thread.addEventListener('drop', (e) => { 4986 e.preventDefault(); 4987 dragDepth = 0; 4988 thread.classList.remove('drag-over'); 4989 const files = e.dataTransfer && e.dataTransfer.files; 4990 if (files && files.length) { 4991 for (let i = 0; i < files.length; i++) handleSupportFile(files[i]); 4992 } 4993 }); 4994 } 4995})(); 4996 4997// ââ Fullscreen image lightbox (pinch / wheel / double-tap zoom + pan) ââââââââââ 4998// Opens support-chat images in an in-tab fullscreen overlay that supports 4999// pinch-to-zoom on touch devices, wheel-zoom on desktop, double-tap/click to 5000// toggle zoom, and drag/one-finger panning while zoomed. 5001(function () { 5002 if (window.__supportImageLightboxReady) return; 5003 window.__supportImageLightboxReady = true; 5004 5005 const MIN_SCALE = 1; 5006 const MAX_SCALE = 6; 5007 5008 const style = document.createElement('style'); 5009 style.textContent = ` 5010 .support-zoomable { cursor: zoom-in; }
5011 #support-lightbox { 5012 position: fixed; 5013 inset: 0; 5014 z-index: 100000; 5015 display: none; 5016 background: rgba(0, 0, 0, 0.92); 5017 touch-action: none; 5018 overflow: hidden; 5019 -webkit-user-select: none; 5020 user-select: none; 5021 } 5022 #support-lightbox.open { display: block; } 5023 #support-lightbox .support-lightbox-img { 5024 position: absolute; 5025 top: 50%; 5026 left: 50%; 5027 max-width: 100vw; 5028 max-height: 100vh; 5029 transform-origin: 0 0; 5030 will-change: transform; 5031 -webkit-user-drag: none; 5032 user-drag: none; 5033 touch-action: none; 5034 } 5035 #support-lightbox .support-lightbox-close { 5036 position: absolute; 5037 top: max(12px, env(safe-area-inset-top)); 5038 right: max(12px, env(safe-area-inset-right)); 5039 width: 44px; 5040 height: 44px; 5041 border-radius: 50%; 5042 border: none; 5043 background: rgba(0, 0, 0, 0.45); 5044 color: #fff; 5045 font-size: 26px; 5046 line-height: 44px; 5047 text-align: center; 5048 cursor: pointer; 5049 z-index: 2; 5050 padding: 0; 5051 transition: background 0.15s; 5052 } 5053 #support-lightbox .support-lightbox-close:hover { background: rgba(255, 255, 255, 0.22); } 5054 #support-lightbox .support-lightbox-hint { 5055 position: absolute; 5056 bottom: max(16px, env(safe-area-inset-bottom)); 5057 left: 0; 5058 right: 0; 5059 text-align: center; 5060 color: rgba(255, 255, 255, 0.55); 5061 font-size: 12px; 5062 font-family: system-ui, sans-serif; 5063 pointer-events: none; 5064 transition: opacity 0.4s; 5065 } 5066 `; 5067 document.head.appendChild(style); 5068 5069 const overlay = document.createElement('div'); 5070 overlay.id = 'support-lightbox'; 5071 overlay.innerHTML = ` 5072 <button type="button" class="support-lightbox-close" aria-label="Fechar">×</button> 5073 <img class="support-lightbox-img" alt="imagem em tela cheia" draggable="false"> 5074 <div class="support-lightbox-hint">Use os dedos para dar zoom · toque duplo para ampliar</div> 5075 `; 5076 document.body.appendChild(overlay); 5077 5078 const img = overlay.querySelector('.support-lightbox-img'); 5079 const closeBtn = overlay.querySelector('.support-lightbox-close'); 5080 const hint = overlay.querySelector('.support-lightbox-hint'); 5081 5082 // Transform state (translate is applied around the image center anchor). 5083 let scale = 1, tx = 0, ty = 0; 5084 // Active pointers for pinch / pan. 5085 const pointers = new Map(); 5086 let pinchStartDist = 0, pinchStartScale = 1; 5087 let pinchMidStart = { x: 0, y: 0 }; 5088 let panStart = null; 5089 let lastTapTime = 0; 5090 5091 function apply() { 5092 img.style.transform = `translate(-50%, -50%) translate(${tx}px, ${ty}px) scale(${scale})`; 5093 } 5094 5095 function clampPan() { 5096 // Keep the image roughly within view: allowed drift grows with zoom. 5097 const rect = img.getBoundingClientRect(); 5098 const maxX = Math.max(0, (rect.width - window.innerWidth) / 2 + 40); 5099 const maxY = Math.max(0, (rect.height - window.innerHeight) / 2 + 40); 5100 tx = Math.max(-maxX, Math.min(maxX, tx)); 5101 ty = Math.max(-maxY, Math.min(maxY, ty)); 5102 } 5103 5104 function reset() { 5105 scale = 1; tx = 0; ty = 0; 5106 apply(); 5107 } 5108 5109 function open(src) { 5110 img.src = src; 5111 overlay.classList.add('open'); 5112 document.body.style.overflow = 'hidden'; 5113 reset(); 5114 hint.style.opacity = '1'; 5115 setTimeout(() => { hint.style.opacity = '0'; }, 2600); 5116 } 5117 5118 function close() { 5119 overlay.classList.remove('open'); 5120 document.body.style.overflow = ''; 5121 pointers.clear(); 5122 panStart = null; 5123 img.src = ''; 5124 } 5125 5126 // Zoom toward a viewport point (px, py), keeping that point stationary. 5127 function zoomTo(newScale, px, py) { 5128 newScale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, newScale)); 5129 const cx = window.innerWidth / 2; 5130 const cy = window.innerHeight / 2; 5131 // Image-space offset of the focal point before scaling. 5132 const ix = (px - cx - tx) / scale; 5133 const iy = (py - cy - ty) / scale; 5134 scale = newScale; 5135 tx = px - cx - ix * scale; 5136 ty = py - cy - iy * scale; 5137 if (scale <= MIN_SCALE + 0.001) { tx = 0; ty = 0; } 5138 clampPan(); 5139 apply(); 5140 } 5141
5142 // Open on click of any support chat image (event delegation). 5143 document.addEventListener('click', function (e) { 5144 const target = e.target.closest && e.target.closest('img.support-zoomable'); 5145 if (!target || !target.src) return; 5146 e.preventDefault(); 5147 e.stopPropagation(); 5148 open(target.src); 5149 }); 5150 document.addEventListener('keydown', function (e) { 5151 if (e.key === 'Enter' || e.key === ' ') { 5152 const el = document.activeElement; 5153 if (el && el.classList && el.classList.contains('support-zoomable') && el.src) { 5154 e.preventDefault(); 5155 open(el.src); 5156 } 5157 } 5158 if (e.key === 'Escape' && overlay.classList.contains('open')) close(); 5159 }); 5160 5161 closeBtn.addEventListener('click', close); 5162 overlay.addEventListener('click', function (e) { 5163 // Clicking the backdrop (not the image) while un-zoomed closes. 5164 if (e.target === overlay && scale <= MIN_SCALE + 0.001) close(); 5165 }); 5166 5167 // Desktop wheel zoom. 5168 overlay.addEventListener('wheel', function (e) { 5169 e.preventDefault(); 5170 const factor = e.deltaY < 0 ? 1.15 : 1 / 1.15; 5171 zoomTo(scale * factor, e.clientX, e.clientY); 5172 }, { passive: false }); 5173 5174 function dist(a, b) { return Math.hypot(a.x - b.x, a.y - b.y); } 5175 function mid(a, b) { return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }; } 5176 5177 overlay.addEventListener('pointerdown', function (e) { 5178 if (e.pointerType === 'mouse' && e.button !== 0) return; 5179 overlay.setPointerCapture(e.pointerId); 5180 pointers.set(e.pointerId, { x: e.clientX, y: e.clientY }); 5181 5182 if (pointers.size === 2) { 5183 const pts = Array.from(pointers.values()); 5184 pinchStartDist = dist(pts[0], pts[1]); 5185 pinchStartScale = scale; 5186 pinchMidStart = mid(pts[0], pts[1]); 5187 panStart = null; 5188 } else if (pointers.size === 1) { 5189 // Double-tap / double-click to toggle zoom. 5190 const now = Date.now(); 5191 if (now - lastTapTime < 300) { 5192 lastTapTime = 0; 5193 if (scale > MIN_SCALE + 0.001) reset(); 5194 else zoomTo(2.5, e.clientX, e.clientY); 5195 return; 5196 } 5197 lastTapTime = now; 5198 panStart = { x: e.clientX, y: e.clientY, tx, ty }; 5199 } 5200 }); 5201 5202 overlay.addEventListener('pointermove', function (e) { 5203 if (!pointers.has(e.pointerId)) return; 5204 pointers.set(e.pointerId, { x: e.clientX, y: e.clientY }); 5205 5206 if (pointers.size === 2) { 5207 const pts = Array.from(pointers.values()); 5208 const d = dist(pts[0], pts[1]); 5209 if (pinchStartDist > 0) { 5210 const m = mid(pts[0], pts[1]); 5211 zoomTo(pinchStartScale * (d / pinchStartDist), m.x, m.y); 5212 } 5213 } else if (pointers.size === 1 && panStart && scale > MIN_SCALE + 0.001) { 5214 tx = panStart.tx + (e.clientX - panStart.x); 5215 ty = panStart.ty + (e.clientY - panStart.y); 5216 clampPan(); 5217 apply(); 5218 } 5219 }); 5220 5221 function endPointer(e) { 5222 if (pointers.has(e.pointerId)) pointers.delete(e.pointerId); 5223 if (pointers.size < 2) pinchStartDist = 0; 5224 if (pointers.size === 1) { 5225 const p = Array.from(pointers.values())[0]; 5226 panStart = { x: p.x, y: p.y, tx, ty }; 5227 } else if (pointers.size === 0) { 5228 panStart = null; 5229 } 5230 } 5231 overlay.addEventListener('pointerup', endPointer); 5232 overlay.addEventListener('pointercancel', endPointer); 5233})(); 5234 5235// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 5236// Reminders (Lembretes) 5237// 5238// The catalog of dialogs the app raises at app open. Everything here is scoped 5239// to ONE market: BR and US are separate rows in `reminders` with their own 5240// switch, copy, buttons and stats, so `remindersCountry` is part of every 5241// request and every save. Editing Brazil never touches the US. 5242// 5243// The vocabularies (signals, actions, art, navigate targets) come from the 5244// server with the list, so the dropdowns cannot drift away from what the app 5245// actually understands. 5246// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 5247 5248let remindersLoaded = false;
5249let remindersCountry = 'BR'; 5250let remindersVocabulary = null; 5251/** Working copy of the market's catalog. Edits live here until "Salvar". */ 5252let remindersDraft = []; 5253/** Keys whose editor is expanded, so a refresh does not collapse everything. */ 5254const remindersExpanded = new Set(); 5255 5256const REMINDER_SHIFT_PHASE_LABELS = { 5257 SHIFT_START: 'InÃcio do turno', 5258 SHIFT_MIDDLE: 'Meio do turno', 5259 SHIFT_END: 'Fim do turno', 5260 IDLE: 'Fora do turno', 5261}; 5262 5263const REMINDER_INTEREST_LABELS = { 5264 CHERRY_PICK: 'Semáforo', 5265 MILEAGE: 'Quilometragem', 5266 SECRET_CAMERA: 'Câmera', 5267 DANGER_ZONE: 'Risco', 5268 ACCOUNT: 'Conta', 5269 SUBSCRIPTION: 'Assinatura', 5270}; 5271 5272const REMINDER_SIGNAL_LABELS = { 5273 tracked_drives: 'Corridas registradas', 5274 classified_drives: 'Corridas classificadas', 5275 unclassified_drives: 'Corridas sem classificar', 5276 vehicles: 'VeÃculos cadastrados', 5277 report_contacts: 'Contatos de relatório', 5278 is_signed_in: 'Tem conta (0/1)', 5279 is_entitled: 'Tem acesso (0/1)', 5280 is_free_period: 'Está no teste grátis (0/1)', 5281 days_since_install: 'Dias desde a instalação', 5282 cherry_pick_pings: 'Pings do Semáforo', 5283 cherry_pick_active: 'Semáforo ativo (0/1)', 5284 mileage_tracking_on: 'Quilometragem ligada (0/1)', 5285 visited_cherry_pick_config: 'Abriu config. do Semáforo (0/1)', 5286 used_secret_camera: 'Já usou a Câmera (0/1)', 5287 used_danger_zone: 'Já usou o Risco (0/1)', 5288 app_opens: 'Aberturas do app', 5289 work_hours_on: 'Classificação automática ligada (0/1)', 5290}; 5291 5292// Reminder copy is published per MARKET but read per LANGUAGE, and the two are 5293// independent â a driver in the US may have the app in Spanish. The row's own 5294// title/message is the market's language and the fallback; everything below is 5295// the translation editor. 5296const REMINDER_FALLBACK_LANGUAGES = ['pt-BR', 'pt-PT', 'en', 'es', 'fr']; 5297 5298/** The language a market's own copy is already written in, so the editor does 5299 * not ask anyone to translate a reminder into the language it is in. */ 5300const REMINDER_BASE_LANGUAGE = { BR: 'pt-BR', US: 'en' }; 5301 5302const REMINDER_LANGUAGE_LABELS = { 5303 'pt-BR': 'Português (BR)', 5304 'pt-PT': 'Português (PT)', 5305 en: 'Inglês', 5306 es: 'Espanhol', 5307 fr: 'Francês', 5308}; 5309 5310function reminderEscape(value) { 5311 return String(value ?? '') 5312 .replace(/&/g, '&') 5313 .replace(/</g, '<') 5314 .replace(/>/g, '>') 5315 .replace(/"/g, '"') 5316 .replace(/'/g, '''); 5317} 5318 5319function reminderStatusMessage(text, isError) { 5320 const el = document.getElementById('reminders-status'); 5321 if (!el) return; 5322 el.textContent = text || ''; 5323 el.style.color = isError ? '#f87171' : '#4ade80'; 5324} 5325 5326/** "1000/50 (5%)" â the same cell the server renders, computed here so the 5327 * list stays correct after a local edit without another round trip. */ 5328function formatReminderStats(stats) { 5329 const impressions = Number(stats?.impressions) || 0; 5330 const clicks = Number(stats?.clicks) || 0; 5331 if (impressions <= 0) return `0/${clicks} (0%)`; 5332 const pct = (clicks * 100) / impressions; 5333 const rendered = Number.isInteger(pct) ? String(pct) : pct.toFixed(1); 5334 return `${impressions}/${clicks} (${rendered}%)`; 5335} 5336 5337/** Colours the stats pill once there is enough evidence to mean anything. */ 5338function reminderStatsClass(stats) { 5339 const impressions = Number(stats?.impressions) || 0; 5340 const clicks = Number(stats?.clicks) || 0; 5341 if (impressions < 50) return ''; 5342 const rate = clicks / impressions; 5343 if (rate >= 0.15) return ' is-good'; 5344 if (rate < 0.03) return ' is-poor'; 5345 return ''; 5346} 5347 5348function reminderOptions(values, selected, labels) { 5349 return (values || []).map(value => { 5350 const label = (labels && labels[value]) || (value === '' ? 'â nenhuma â' : value); 5351 const isSelected = String(value) === String(selected ?? '') ? ' selected' : ''; 5352 return `<option value="${reminderEscape(value)}"${isSelected}>${reminderEscape(label)}</option>`; 5353 }).join(''); 5354} 5355 5356function reminderChips(name, values, selected, labels) { 5357 const chosen = new Set(selected || []); 5358 return (values || []).map(value => ` 5359 <label class="reminder-chip"> 5360 <input type="checkbox" data-field="${name}" value="${reminderEscape(value)}"${chosen.has(value) ? ' checked' : ''}>
5361 <span>${reminderEscape((labels && labels[value]) || value)}</span> 5362 </label>`).join(''); 5363} 5364 5365function reminderConditionRow(condition) { 5366 const vocab = remindersVocabulary || {}; 5367 return ` 5368 <div class="reminder-row" data-row="condition"> 5369 <div class="input-group"> 5370 <label>Sinal</label> 5371 <select data-field="condition-signal">${reminderOptions(vocab.signals, condition.signal, REMINDER_SIGNAL_LABELS)}</select> 5372 </div> 5373 <div class="input-group" style="flex:0 0 90px;"> 5374 <label>Op.</label> 5375 <select data-field="condition-op">${reminderOptions(vocab.operators, condition.op)}</select> 5376 </div> 5377 <div class="input-group" style="flex:0 0 110px;"> 5378 <label>Valor</label> 5379 <input type="number" step="any" data-field="condition-value" value="${reminderEscape(condition.value ?? 0)}"> 5380 </div> 5381 <button type="button" class="reminder-btn reminder-btn-danger reminder-btn-icon" data-action="remove-row" title="Remover">â</button> 5382 </div>`; 5383} 5384 5385/** Languages a reminder can be translated into, minus the market's own. */ 5386function reminderTranslatableLanguages() { 5387 const all = (remindersVocabulary || {}).languages || REMINDER_FALLBACK_LANGUAGES; 5388 const base = REMINDER_BASE_LANGUAGE[remindersCountry]; 5389 return all.filter(tag => tag !== base); 5390} 5391 5392/** Per-language label inputs for one button, collapsed out of the way. */ 5393function reminderCtaTranslationsHtml(cta) { 5394 const translations = cta.translations || {}; 5395 const rows = reminderTranslatableLanguages().map(tag => ` 5396 <div class="input-group" style="flex:0 0 150px;"> 5397 <label>${reminderEscape(REMINDER_LANGUAGE_LABELS[tag] || tag)}</label> 5398 <input type="text" data-field="cta-translation" data-lang="${reminderEscape(tag)}" 5399 value="${reminderEscape(translations[tag] || '')}"> 5400 </div>`).join(''); 5401 return ` 5402 <details class="reminder-translations"> 5403 <summary>Traduções do botão</summary> 5404 <div class="reminder-row" data-row="cta-translations">${rows}</div> 5405 </details>`; 5406} 5407 5408function reminderCtaRow(cta) { 5409 const vocab = remindersVocabulary || {}; 5410 const isNavigate = (cta.action || 'navigate') === 'navigate'; 5411 // A navigate CTA must name a destination MainActivity actually routes, so 5412 // it gets a closed list; a web CTA takes a free-text URL. 5413 const valueField = isNavigate 5414 ? `<select data-field="cta-value">${reminderOptions(vocab.navigate_targets, cta.value)}</select>` 5415 : `<input type="text" data-field="cta-value" placeholder="https://â¦" value="${reminderEscape(cta.value || '')}">`; 5416 return ` 5417 <div data-row="cta" data-cta-id="${reminderEscape(cta.id || '')}"> 5418 <div class="reminder-row"> 5419 <div class="input-group" style="flex:1.2;"> 5420 <label>Texto do botão</label> 5421 <input type="text" data-field="cta-label" value="${reminderEscape(cta.label || '')}"> 5422 </div> 5423 <div class="input-group" style="flex:0 0 120px;"> 5424 <label>Ação</label> 5425 <select data-field="cta-action">${reminderOptions(vocab.actions, cta.action || 'navigate')}</select> 5426 </div> 5427 <div class="input-group" style="flex:1.4;" data-cta-value-cell> 5428 <label>Destino</label> 5429 ${cta.action === 'dismiss' ? '<input type="text" data-field="cta-value" value="" disabled placeholder="fecha o diálogo">' : valueField} 5430 </div> 5431 <div class="input-group" style="flex:0 0 110px;"> 5432 <label>Estilo</label> 5433 <select data-field="cta-style">${reminderOptions(vocab.styles, cta.style || 'filled')}</select> 5434 </div> 5435 <div class="input-group" style="flex:0 0 56px;"> 5436 <label>Cor</label> 5437 <input type="color" data-field="cta-color" value="${reminderEscape(cta.color || '#0062CC')}"> 5438 </div> 5439 <button type="button" class="reminder-btn reminder-btn-danger reminder-btn-icon" data-action="remove-row" title="Remover">â</button> 5440 </div> 5441 ${reminderCtaTranslationsHtml(cta)} 5442 </div>`; 5443} 5444 5445/** Title + message per language, one collapsible block each. */ 5446function reminderTranslationsHtml(r) { 5447 const translations = r.translations || {}; 5448 const base = REMINDER_BASE_LANGUAGE[remindersCountry] || remindersCountry; 5449 const blocks = reminderTranslatableLanguages().map(tag => { 5450 const t = translations[tag] || {}; 5451 const filled = (t.title || '').trim() && (t.message || '').trim(); 5452 return ` 5453 <details class="reminder-translations" data-row="translation" data-lang="${reminderEscape(tag)}"${filled ? '' : ''}> 5454 <summary>${reminderEscape(REMINDER_LANGUAGE_LABELS[tag] || tag)}${filled ? ' â' : ''}</summary> 5455 <div class="input-group"> 5456 <label>TÃtulo</label> 5457 <input type="text" data-field="translation-title" value="${reminderEscape(t.title || '')}"> 5458 </div> 5459 <div class="input-group"> 5460 <label>Mensagem</label> 5461 <textarea data-field="translation-message">${reminderEscape(t.message || '')}</textarea> 5462 </div> 5463 </details>`; 5464 }).join(''); 5465 5466 return ` 5467 <div class="reminder-subhead"><h4>Traduções</h4></div> 5468 <p class="reminder-hint">O texto acima é o do mercado (${reminderEscape(base)}) e vale para quem não tem tradução. Cada idioma abaixo é lido por quem escolheu esse idioma no app â mercado e idioma são independentes. TÃtulo <strong>e</strong> mensagem: pela metade, o idioma é descartado.</p> 5469 <div data-list="translations">${blocks}</div>`; 5470} 5471 5472function reminderCardHtml(row) { 5473 const r = row.reminder || {}; 5474 const vocab = remindersVocabulary || {}; 5475 const stats = row.stats || {}; 5476 const isOpen = remindersExpanded.has(r.key); 5477 const ctaClicks = Object.entries(stats.cta_clicks || {}); 5478 5479 return ` 5480 <section class="reminder-card${r.enabled ? '' : ' is-disabled'}" data-key="${reminderEscape(r.key)}"${row.is_new ? ' data-new="1"' : ''}> 5481 <div class="reminder-head" data-action="toggle-body"> 5482 <div class="reminder-head-main"> 5483 <div class="reminder-head-title">
5483${reminderEscape(r.title || '(sem tÃtulo)')}</div> 5484 <div class="reminder-head-key">${reminderEscape(r.key)}${r.one_off ? ' · uma vez só' : ''}</div> 5485 </div> 5486 <span class="reminder-stats${reminderStatsClass(stats)}" title="Exibições / cliques (taxa). Dispositivos de desenvolvimento não entram.">${formatReminderStats(stats)}</span> 5487 <label class="reminder-switch" onclick="event.stopPropagation();"> 5488 <input type="checkbox" data-field="enabled"${r.enabled ? ' checked' : ''}> 5489 <span>${r.enabled ? 'ON' : 'OFF'}</span> 5490 </label> 5491 <label class="reminder-force" onclick="event.stopPropagation();" 5492 title="Mostra este lembrete na próxima abertura do app em um aparelho de desenvolvimento, ignorando público, momento e ritmo. Só um por mercado; clique de novo para desmarcar."> 5493 <input type="checkbox" data-field="force_dev"${r.force_dev ? ' checked' : ''}> 5494 <span class="reminder-force-dot"></span> 5495 <span class="reminder-force-label">FORCE DEV</span> 5496 </label> 5497 </div> 5498 5499 <div class="reminder-body"${isOpen ? '' : ' hidden'}> 5500 <div class="input-group"> 5501 <label>Chave (identidade â as estatÃsticas são contadas por ela)</label> 5502 <input type="text" data-field="key" value="${reminderEscape(r.key)}"${row.is_new ? '' : ' disabled'}> 5503 </div> 5504 <div class="input-group"> 5505 <label>TÃtulo</label> 5506 <input type="text" data-field="title" value="${reminderEscape(r.title || '')}"> 5507 </div> 5508 <div class="input-group"> 5509 <label>Mensagem</label> 5510 <textarea data-field="message">${reminderEscape(r.message || '')}</textarea> 5511 </div> 5512 5513 <div class="reminder-grid-3"> 5514 <div class="input-group"> 5515 <label>Arte</label> 5516 <select data-field="art">${reminderOptions(vocab.arts, r.art || '')}</select> 5517 </div> 5518 <div class="input-group"> 5519 <label>Prioridade (0â100)</label> 5520 <input type="number" min="0" max="100" data-field="priority" value="${reminderEscape(r.priority ?? 50)}"> 5521 </div> 5522 <div class="input-group"> 5523 <label>Mostrar uma vez só</label> 5524 <label class="reminder-switch" style="margin-top:4px;"> 5525 <input type="checkbox" data-field="one_off"${r.one_off ? ' checked' : ''}> 5526 <span>uma vez</span> 5527 </label> 5528 </div> 5529 </div> 5530 5531 ${reminderTranslationsHtml(r)} 5532 5533 <div class="reminder-subhead"><h4>Quando mostrar</h4></div> 5534 <p class="reminder-hint">Nenhum momento marcado = qualquer momento. Meio do turno é o pior momento para interromper.</p> 5535 <div class="reminder-chips">${reminderChips('shift_phases', vocab.shift_phases, r.shift_phases, REMINDER_SHIFT_PHASE_LABELS)}</div> 5536 5537 <div class="reminder-subhead"><h4>Sobre quais recursos</h4></div> 5538 <p class="reminder-hint">Usado para rankear: um lembrete do Semáforo ganha do de quilometragem para quem vive no Semáforo.</p> 5539 <div class="reminder-chips">${reminderChips('interests', vocab.interests, r.interests, REMINDER_INTEREST_LABELS)}</div> 5540 5541 <div class="reminder-subhead"><h4>Para quem (todas as condições precisam ser verdadeiras)</h4> 5542 <button type="button" class="reminder-btn" data-action="add-condition">+ Condição</button> 5543 </div> 5544 <div data-list="conditions">${(r.conditions || []).map(reminderConditionRow).join('')}</div> 5545 5546 <div class="reminder-subhead"><h4>Botões</h4> 5547 <button type="button" class="reminder-btn" data-action="add-cta">+ Botão</button> 5548 </div> 5549 <p class="reminder-hint">O âXâ de fechar está sempre lá â estes são os botões extras, na ordem em que aparecem.</p> 5550 <div data-list="ctas">${(r.ctas || []).map(reminderCtaRow).join('')}</div> 5551 ${ctaClicks.length ? `<div class="reminder-cta-stats">Cliques por botão: ${ctaClicks.map(([id, n]) => `${reminderEscape(id)} ${n}`).join(' · ')}</div>` : ''} 5552 5553 <div class="reminder-subhead"><h4>
5553Limites</h4></div> 5554 <div class="reminder-grid-2"> 5555 <div class="input-group"> 5556 <label>Horas entre exibições deste lembrete</label> 5557 <input type="number" min="0" data-field="min_hours_between" value="${reminderEscape(r.min_hours_between ?? 0)}"> 5558 </div> 5559 <div class="input-group"> 5560 <label>Máx. de exibições por usuário (0 = sem limite)</label> 5561 <input type="number" min="0" data-field="max_impressions" value="${reminderEscape(r.max_impressions ?? 0)}"> 5562 </div> 5563 <div class="input-group"> 5564 <label>Versão mÃnima do app (0 = qualquer)</label> 5565 <input type="number" min="0" data-field="min_app_version" value="${reminderEscape(r.min_app_version ?? 0)}"> 5566 </div> 5567 <div class="input-group"> 5568 <label>Versão máxima do app (0 = qualquer)</label> 5569 <input type="number" min="0" data-field="max_app_version" value="${reminderEscape(r.max_app_version ?? 0)}"> 5570 </div> 5571 </div> 5572 5573 <div class="reminder-actions"> 5574 <button type="button" class="reminder-btn reminder-btn-primary" data-action="save">Salvar</button> 5575 <span class="reminder-spacer"></span> 5576 <button type="button" class="reminder-btn reminder-btn-danger" data-action="delete">Excluir</button> 5577 </div> 5578 </div> 5579 </section>`; 5580} 5581 5582function renderReminders() { 5583 const listEl = document.getElementById('reminders-list'); 5584 if (!listEl) return; 5585 if (!remindersDraft.length) { 5586 listEl.innerHTML = `<p class="reminders-empty">Nenhum lembrete para ${remindersCountry}. Crie o primeiro.</p>`; 5587 return; 5588 } 5589 listEl.innerHTML = remindersDraft.map(reminderCardHtml).join(''); 5590} 5591 5592/** Reads one card's editor back into a reminder payload. */ 5593function readReminderCard(card) { 5594 const field = name => card.querySelector(`[data-field="${name}"]`); 5595 const text = name => (field(name)?.value ?? '').trim(); 5596 const num = (name, fallback = 0) => { 5597 const raw = Number(field(name)?.value); 5598 return Number.isFinite(raw) ? raw : fallback; 5599 }; 5600 const checked = name => !!field(name)?.checked; 5601 const chips = name => Array.from( 5602 card.querySelectorAll(`input[data-field="${name}"]:checked`) 5603 ).map(el => el.value); 5604 5605 const conditions = Array.from(card.querySelectorAll('[data-row="condition"]')).map(row => ({ 5606 signal: row.querySelector('[data-field="condition-signal"]')?.value || '', 5607 op: row.querySelector('[data-field="condition-op"]')?.value || '>=', 5608 value: Number(row.querySelector('[data-field="condition-value"]')?.value) || 0, 5609 })).filter(c => c.signal); 5610 5611 // Title + message per language. An entry missing either half is dropped 5612 // rather than sent: the app would fall back field by field and draw a 5613 // Spanish title over an English message. 5614 const translations = {}; 5615 card.querySelectorAll('[data-row="translation"]').forEach(block => { 5616 const tag = block.dataset.lang; 5617 const title = (block.querySelector('[data-field="translation-title"]')?.value || '').trim(); 5618 const message = (block.querySelector('[data-field="translation-message"]')?.value || '').trim(); 5619 if (tag && title && message) translations[tag] = { title, message }; 5620 }); 5621 5622 const ctaTranslations = row => { 5623 const out = {}; 5624 row.querySelectorAll('[data-field="cta-translation"]').forEach(input => { 5625 const label = (input.value || '').trim(); 5626 if (input.dataset.lang && label) out[input.dataset.lang] = label; 5627 }); 5628 return out; 5629 }; 5630 5631 const ctas = Array.from(card.querySelectorAll('[data-row="cta"]')).map((row, index) => { 5632 const label = (row.querySelector('[data-field="cta-label"]')?.value || '').trim(); 5633 const action = row.querySelector('[data-field="cta-action"]')?.value || 'navigate'; 5634 return { 5635 // Ids are what the per-button click counters are keyed by, so an 5636 // existing button keeps the one it was saved with; only new rows 5637 // get a fresh one. 5638 id: row.dataset.ctaId || `cta_${index}`, 5639 label, 5640 translations: ctaTranslations(row), 5641 action, 5642 value: action === 'dismiss' 5643 ? '' 5644 : (row.querySelector('[data-field="cta-value"]')?.value || '').trim(), 5645 style: row.querySelector('[data-field="cta-style"]')?.value || 'filled', 5646 color: row.querySelector('[data-field="cta-color"]')?.value || '', 5647 }; 5648 }).filter(c => c.label); 5649 5650 return { 5651 key: text('key'), 5652 enabled: checked('enabled'), 5653 countries: [remindersCountry], 5654 one_off: checked('one_off'), 5655 force_dev: checked('force_dev'), 5656 priority: num('priority', 50), 5657 title: text('title'), 5658 message: text('message'), 5659 translations, 5660 art: text('art'), 5661 shift_phases: chips('shift_phases'), 5662 interests: chips('interests'), 5663 min_hours_between: num('min_hours_between'), 5664 max_impressions: num('max_impressions'), 5665 min_app_version: num('min_app_version'), 5666 max_app_version: num('max_app_version'), 5667 conditions, 5668 ctas, 5669 }; 5670} 5671 5672async function fetchReminders() { 5673 const refreshBtn = document.getElementById('reminders-refresh-btn'); 5674 const listEl = document.getElementById('reminders-list'); 5675 if (refreshBtn) refreshBtn.classList.add('spinning'); 5676 if (listEl) listEl.innerHTML = '<p class="reminders-empty">Carregandoâ¦</p>'; 5677 5678 try { 5679 const res = await fetchWithRetry( 5680 `${ANALYTICS_API_BASE}/admin/reminders?country=${encodeURIComponent(remindersCountry)}`, 5681 { headers: { 'Authorization': `Bearer ${adminToken}` } } 5682 ); 5683 if (res.status === 401) { handleTokenExpired(); return; } 5684 if (!res.ok) throw new Error(`HTTP ${res.status}`); 5685 const data = await res.json(); 5686 5687 remindersVocabulary = data.vocabulary || remindersVocabulary; 5688 remindersDraft = data.rows || []; 5689 remindersLoaded = true; 5690 5691 const versionEl = document.getElementById('reminders-version'); 5692 if (versionEl) versionEl.textContent = `catálogo v${data.version ?? 0}`; 5693 5694 const pacing = data.pacing || {}; 5695 const setPacing = (id, value) => { 5696 const el = document.getElementById(id); 5697 if (el) el.value = value ?? ''; 5698 }; 5699 setPacing('reminders-pacing-gap', pacing.min_hours_between_any); 5700 setPacing('reminders-pacing-week', pacing.max_per_week); 5701 setPacing('reminders-pacing-install', pacing.min_hours_after_install); 5702 5703 renderReminders(); 5704 reminderStatusMessage(''); 5705 } catch (e) { 5706 console.error('Reminders fetch error:', e); 5707 if (listEl) listEl.innerHTML = '<p class="reminders-empty" style="color:#f87171;">Erro ao carregar os lembretes.</p>'; 5708 } finally { 5709 if (refreshBtn) refreshBtn.classList.remove('spinning'); 5710 } 5711} 5712 5713async function saveReminder(card) { 5714 const reminder = readReminderCard(card); 5715 if (!reminder.key) { reminderStatusMessage('A chave é obrigatória.', true); return; } 5716 if (!reminder.title || !reminder.message) { 5717 reminderStatusMessage('TÃtulo e mensagem são obrigatórios â o app não mostra um lembrete sem eles.', true); 5718 return; 5719 } 5720 5721 try { 5722 const res = await fetch(`${ANALYTICS_API_BASE}/admin/reminders/save`, { 5723 method: 'POST', 5724 headers: { 5725 'Content-Type': 'application/json', 5726 'Authorization': `Bearer ${adminToken}`, 5727 }, 5728 body: JSON.stringify({ country: remindersCountry, reminder }), 5729 }); 5730 if (res.status === 401) { handleTokenExpired(); return; } 5731 const data = await res.json(); 5732 if (!data.success) throw new Error('save rejected'); 5733 reminderStatusMessage(`"${reminder.key}" salvo para ${remindersCountry}.`, false); 5734 // Keep whatever the editor was doing: a save from the open form stays 5735 // open because the key is already in the set; a save from the header 5736 // ON / FORCE DEV switches must not force the body open. 5737 await fetchReminders(); 5738 } catch (e) { 5739 console.error('Reminder save error:', e); 5740 reminderStatusMessage('Erro ao salvar. Tente novamente.', true); 5741 } 5742} 5743 5744async function deleteReminder(key) { 5745 if (!key) return; 5746 if (!confirm(`Excluir "${key}" do mercado ${remindersCountry}?\n\nAs estatÃsticas são preservadas.`)) return; 5747 try { 5748 const res = await fetch(`${ANALYTICS_API_BASE}/admin/reminders/delete`, { 5749 method: 'POST', 5750 headers: { 5751 'Content-Type': 'application/json', 5752 'Authorization': `Bearer ${adminToken}`, 5753 }, 5754 body: JSON.stringify({ country: remindersCountry, key }), 5755 }); 5756 if (res.status === 401) { handleTokenExpired(); return; } 5757 const data = await res.json(); 5758 if (!data.success) throw new Error('delete rejected'); 5759 remindersExpanded.delete(key); 5760 reminderStatusMessage(`"${key}" excluÃdo de ${remindersCountry}.`, false); 5761 await fetchReminders(); 5762 } catch (e) { 5763 console.error('Reminder delete error:', e); 5764 reminderStatusMessage('Erro ao excluir.', true); 5765 } 5766} 5767 5768async function saveReminderPacing() { 5769 const num = id => Number(document.getElementById(id)?.value) || 0; 5770 try { 5771 const res = await fetch(`${ANALYTICS_API_BASE}/admin/reminders/pacing`, { 5772 method: 'POST', 5773 headers: { 5774 'Content-Type': 'application/json', 5775 'Authorization': `Bearer ${adminToken}`, 5776 }, 5777 body: JSON.stringify({ 5778 country: remindersCountry, 5779 pacing: { 5780 min_hours_between_any: num('reminders-pacing-gap'), 5781 max_per_week: num('reminders-pacing-week'), 5782 min_hours_after_install: num('reminders-pacing-install'), 5783 }, 5784 }), 5785 }); 5786 if (res.status === 401) { handleTokenExpired(); return; } 5787 const data = await res.json(); 5788 if (!data.success) throw new Error('pacing rejected'); 5789 reminderStatusMessage(`Ritmo salvo para ${remindersCountry}.`, false); 5790 } catch (e) { 5791 console.error('Reminder pacing save error:', e); 5792 reminderStatusMessage('Erro ao salvar o ritmo.', true); 5793 } 5794} 5795 5796function addReminderDraft() {
5797 const key = (prompt('Chave do novo lembrete (ex.: add_vehicle_for_reports):') || '').trim(); 5798 if (!key) return; 5799 if (remindersDraft.some(row => row.reminder?.key === key)) { 5800 reminderStatusMessage('Já existe um lembrete com essa chave neste mercado.', true); 5801 return; 5802 } 5803 remindersDraft.unshift({ 5804 is_new: true, 5805 country: remindersCountry, 5806 stats: {}, 5807 reminder: { 5808 key, 5809 // New reminders arrive OFF: nothing reaches a driver until someone 5810 // has written the copy and deliberately switched it on. 5811 enabled: false, 5812 countries: [remindersCountry], 5813 one_off: false, 5814 force_dev: false, 5815 priority: 50, 5816 title: '', 5817 message: '', 5818 translations: {}, 5819 art: '', 5820 shift_phases: ['SHIFT_END', 'IDLE'], 5821 interests: [], 5822 min_hours_between: 168, 5823 max_impressions: 3, 5824 min_app_version: 0, 5825 max_app_version: 0, 5826 conditions: [], 5827 ctas: [{ 5828 id: 'later', label: 'Agora não', translations: {}, action: 'dismiss', 5829 value: '', style: 'text', color: '#9E9E9E', 5830 }], 5831 }, 5832 }); 5833 remindersExpanded.add(key); 5834 renderReminders(); 5835} 5836 5837// --- Reminders wiring --- 5838const remindersRefreshBtn = document.getElementById('reminders-refresh-btn'); 5839if (remindersRefreshBtn) { 5840 remindersRefreshBtn.addEventListener('click', () => fetchReminders()); 5841} 5842 5843const remindersAddBtn = document.getElementById('reminders-add-btn'); 5844if (remindersAddBtn) { 5845 remindersAddBtn.addEventListener('click', addReminderDraft); 5846} 5847 5848const remindersPacingSaveBtn = document.getElementById('reminders-pacing-save'); 5849if (remindersPacingSaveBtn) { 5850 remindersPacingSaveBtn.addEventListener('click', saveReminderPacing); 5851} 5852 5853document.querySelectorAll('#reminders-country-filter input[name="reminders-country"]').forEach(input => { 5854 input.addEventListener('change', () => { 5855 if (!input.checked) return; 5856 remindersCountry = input.value; 5857 // Each market is its own catalog; nothing from the previous one carries. 5858 remindersExpanded.clear(); 5859 fetchReminders(); 5860 }); 5861}); 5862 5863// One delegated listener for the whole list â the cards are re-rendered on 5864// every save, so per-card listeners would have to be re-bound each time. 5865const remindersListEl = document.getElementById('reminders-list'); 5866if (remindersListEl) { 5867 remindersListEl.addEventListener('click', event => { 5868 const card = event.target.closest('.reminder-card'); 5869 if (!card) return; 5870 const key = card.dataset.key; 5871 5872 const actionEl = event.target.closest('[data-action]'); 5873 const action = actionEl?.dataset.action; 5874 5875 if (action === 'remove-row') { 5876 // [data-row] rather than .reminder-row: a CTA is a wrapper holding 5877 // the row AND its collapsed per-language labels, and removing only 5878 // the row would strand the translations. 5879 actionEl.closest('[data-row]')?.remove(); 5880 return; 5881 } 5882 if (action === 'add-condition') { 5883 card.querySelector('[data-list="conditions"]') 5884 ?.insertAdjacentHTML('beforeend', reminderConditionRow({ signal: '', op: '>=', value: 0 })); 5885 return; 5886 } 5887 if (action === 'add-cta') { 5888 const list = card.querySelector('[data-list="ctas"]'); 5889 const index = list?.querySelectorAll('[data-row="cta"]').length ?? 0; 5890 list?.insertAdjacentHTML('beforeend', reminderCtaRow({ 5891 id: `cta_${index}`, label: '', translations: {}, action: 'navigate', 5892 value: '', style: 'filled', color: '#0062CC', 5893 })); 5894 return; 5895 } 5896 if (action === 'save') { saveReminder(card); return; } 5897 if (action === 'delete') { deleteReminder(key); return; } 5898 5899 if (event.target.closest('[data-action="toggle-body"]')) { 5900 // The ON switch and FORCE DEV radio live in the same head that 5901 // toggles the editor. A click on either must not count as "open 5902 // this reminder" â the labels already stopPropagation, but the 5903 // save that follows replaces the card and a leftover click can 5904 // still land on the new head. 5905 if (event.target.closest('label, input, button, select, textarea')) return; 5906 const body = card.querySelector('.reminder-body'); 5907 if (!body) return; 5908 body.hidden = !body.hidden; 5909 if (body.hidden) remindersExpanded.delete(key); else remindersExpanded.add(key); 5910 } 5911 }); 5912 5913 // The ON/OFF switch saves on the spot rather than waiting for "Salvar": 5914 // a kill-switch you have to remember to confirm is not a kill-switch. The 5915 // collapsed body is still in the DOM, so the save carries the whole 5916 // reminder exactly as the server last returned it. 5917 remindersListEl.addEventListener('change', event => { 5918 if (event.target.dataset?.field !== 'enabled') return; 5919 const card = event.target.closest('.reminder-card'); 5920 if (!card) return; 5921 const isOn = event.target.checked; 5922 card.classList.toggle('is-disabled', !isOn); 5923 const label = event.target.parentElement?.querySelector('span'); 5924 if (label) label.textContent = isOn ? 'ON' : 'OFF'; 5925 saveReminder(card); 5926 }); 5927 5928 // FORCE DEV behaves like a radio that can be emptied: turning one on turns 5929 // every other one off, and clicking the same one again leaves nothing 5930 // forced. Cleared here for the eye and again on the server for the truth, 5931 // since the save below re-fetches the list anyway. 5932 remindersListEl.addEventListener('change', event => { 5933 if (event.target.dataset?.field !== 'force_dev') return; 5934 const card = event.target.closest('.reminder-card'); 5935 if (!card) return; 5936 const isOn = event.target.checked; 5937 5938 remindersListEl.querySelectorAll('[data-field="force_dev"]').forEach(input => { 5939 input.checked = input === event.target && isOn; 5940 }); 5941 5942 saveReminder(card); 5943 }); 5944 5945 // Switching a CTA between "navigate" and "web" swaps its destination 5946 // control: a closed list of routable targets versus a free-text URL. 5947 remindersListEl.addEventListener('change', event => { 5948 if (event.target.dataset?.field !== 'cta-action') return;
5949 const row = event.target.closest('[data-row="cta"]'); 5950 const cell = row?.querySelector('[data-cta-value-cell]'); 5951 if (!cell) return; 5952 const action = event.target.value; 5953 const vocab = remindersVocabulary || {}; 5954 if (action === 'dismiss') { 5955 cell.innerHTML = '<label>Destino</label><input type="text" data-field="cta-value" value="" disabled placeholder="fecha o diálogo">'; 5956 } else if (action === 'web') { 5957 cell.innerHTML = '<label>Destino</label><input type="text" data-field="cta-value" placeholder="https://â¦" value="">'; 5958 } else { 5959 cell.innerHTML = `<label>Destino</label><select data-field="cta-value">${reminderOptions(vocab.navigate_targets, '')}</select>`; 5960 } 5961 }); 5962}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.