PageSourceSearch

https://www.jetmax.app/script.js?v=20260918d

js jetmax.app collected 2026-09-24 14:31:19 UTC 270,584 bytes, 5,962 lines download raw bytes

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, '&amp;')
3050        .replace(/</g, '&lt;')
3051        .replace(/>/g, '&gt;')
3052        .replace(/"/g, '&quot;')
3053        .replace(/'/g, '&#39;');
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">&times;</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, '&amp;')
5313        .replace(/</g, '&lt;')
5314        .replace(/>/g, '&gt;')
5315        .replace(/"/g, '&quot;')
5316        .replace(/'/g, '&#39;');
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.