PageSourceSearch

https://www.consensuspulse.com/js/app.js?v=20260727d

js consensuspulse.com collected 2026-10-02 00:49:02 UTC 559,609 bytes, 10,315 lines download raw bytes

1// ===== MÓDULOS EXTRAÍDOS PARA js/core/ =====
2// Os módulos abaixo foram extraídos para arquivos separados.
3// Eles são carregados ANTES deste arquivo via <script> no HTML.
4//
5//   core/state.js          — G, OB
6//   core/i18n.js           — i18n, t(), setLang(), setSector(), applyI18n(), updatePlaceholder()
7//   core/supabase.js       — SUPABASE_URL, getSupabase()
8//   core/prompt-client.js  — getPrompt(), getPromptRaw(), preloadPrompts()
9//   core/api-keys.js       — provisionApiKeys()
10//   core/ai-client.js      — callAISecure(), setProvider(), EMITTER_MARKETS, loadEmitterMarkets()
11
12// === SPLIT PROMPTS — Migração progressiva ===
13// Quando true, usa 4 prompts por módulo (analysis-dashboard, analysis-crisis,
14// analysis-profile, analysis-benchmark) em vez do monolito analysis-main.
15// Flip para true após inserir os 4 novos prompts no DB e validar.
16var _USE_SPLIT_PROMPTS = true;
17
18// === DASHBOARD EM 2 ONDAS ===
19// Quando true, o dashboard é quebrado em 2 chamadas: onda A (analysis-dashboard,
20// KPIs/temas/gráfico — rápida) + onda B (analysis-dashboard-insights, análise
21// estratégica — pesada, por último). Fallback seguro: se o prompt de insights
22// não existir no DB, mantém insights na onda A (1 chamada, comportamento antigo).
23var _USE_DASHBOARD_TIERS = true;
24
25// === USER CHIP NA SIDEBAR (avatar com iniciais + nome) ===
26// Popula #nav-user-avatar (2 iniciais) e #nav-user-plan-name (nome) a partir do
27// full_name (auth user_metadata) com fallback pro prefixo do email. O nome trunca
28// com ellipsis no CSS (.cp-user-name) pra nunca quebrar o layout do menu.
29function _cpInitials(s) {
30  s = String(s == null ? '' : s).trim();
31  if (!s) return '··';
32  var parts = s.split(/\s+/).filter(Boolean);
33  if (parts.length >= 2) return (parts[0].charAt(0) + parts[1].charAt(0)).toUpperCase();
34  return s.substring(0, 2).toUpperCase();
35}
36async function cpUpdateUserChip() {
37  try {
38    var nameEl = document.getElementById('nav-user-plan-name');
39    var avEl = document.getElementById('nav-user-avatar');
40    if (!nameEl && !avEl) return;
41    var email = (localStorage.getItem('cp_user_email') || '').trim();
42    var displayName = email ? email.split('@')[0] : 'Minha Conta';
43    try {
44      var supa = (typeof getSupabase === 'function') ? await getSupabase() : null;
45      if (supa && supa.auth && supa.auth.getUser) {
46        var r = await supa.auth.getUser();
47        var fn = r && r.data && r.data.user && r.data.user.user_metadata && r.data.user.user_metadata.full_name;
48        if (fn && String(fn).trim()) displayName = String(fn).trim();
49      }
50    } catch (_e) {}
51    if (nameEl) nameEl.textContent = displayName;
52    if (avEl) avEl.textContent = _cpInitials(displayName || email);
53  } catch (_e) {}
54}
55window.cpUpdateUserChip = cpUpdateUserChip;
56if (typeof document !== 'undefined') {
57  if (document.readyState === 'loading') {
58    document.addEventListener('DOMContentLoaded', function () { cpUpdateUserChip(); });
59  } else {
60    cpUpdateUserChip();
61  }
62}
63
64// === STATUS POR MÓDULO (loading inicial dos 4 basic) ===
65// Apenas in-memory. UI da tela de loading observa via CustomEvent.
66// Não persistido — premium/oracle agora rodam sob demanda via CTA.
67function _setModuleStatus(slug, status) {
68  if (!slug) return;
69  try {
70    window.dispatchEvent(new CustomEvent('cp:module-status', { detail: { slug, status } }));
71  } catch (_e) { /* legacy browser sem CustomEvent: ignora */ }
72  console.log(`[Pipeline] ${slug} → ${status}`);
73}
74
75// Salva resultado parcial de um módulo em analysis_history via RPC.
76// Usado pelos 4 prompts basic do callClaude e também por cada _retry*.
77var _SLUG_TO_FIELD = {
78  'analysis-dashboard': 'dashboard',
79  'analysis-crisis':    'crisis',
80  'analysis-profile':   'profile',
81  'analysis-benchmark': 'benchmark',
82  'analysis-premium':   'premium',
83  'analysis-social':    'social',
84  'analysis-markets':   'markets',
85  'analysis-oracle':    'oracle',
86};
87// Espera curta pelo cp_active_analysis (setado por saveAnalysisToSupabase APÓS o
88// dashboard). O background (crise/perfil/benchmark/insights) pode terminar antes —
89// damos uma janela (~6s) p/ o id aparecer em vez de perder a persistência.
90// startAnalysis agora LIMPA essa chave ao iniciar (senão o poll devolvia na hora o id
91// da análise ANTERIOR e o background gravava na cidade errada).
92async function _waitAnalysisId() {
93  let id = sessionStorage.getItem('cp_active_analysis');
94  for (let i = 0; !id && i < 20; i++) {
95    await new Promise(function (r) { setTimeout(r, 300); });
96    id = sessionStorage.getItem('cp_active_analysis');
97  }
98  return id;
99}
100
101// Grava UM campo flat dentro de analysis_data (merge_analysis_field_safe). Usa
102// getSupabase() (window.supabase NUNCA é atribuído neste app — bug histórico que
103// fazia o background não persistir e os dados sumirem no refresh).
104//
105// `dest` é o destino da análise que ORIGINOU o resultado (G.dest capturado no início
106// do background, já deduplicado — é o mesmo valor gravado em analysis_history.destination
107// por saveAnalysisToSupabase). O servidor recusa a escrita se o destino não bater,
108// o que fecha a contaminação entre municípios mesmo se o id resolvido estiver errado.
109async function _persistField(field, value, dest) {
110  try {
111    const supa = (typeof getSupabase === 'function') ? await getSupabase() : null;
112    if (!supa) return;
113    const analysisId = await _waitAnalysisId();
114    if (!analysisId) {
115      console.warn('[Pipeline] _persistField(' + field + ') pulado: nenhuma análise ativa');
116      return;
117    }
118    const { data: gravou, error } = await supa.rpc('merge_analysis_field_safe', {
119      p_analysis_id: analysisId,
120      p_field: field,
121      p_value: JSON.stringify(value),
122      p_destination: dest || null,
123    });
124    if (error) {
125      console.warn('[Pipeline] _persistField(' + field + ') falhou: ' + error.message);
126      return;
127    }
128    if (gravou === false) {
129      console.warn('[Pipeline] _persistField(' + field + ') RECUSADO pelo servidor — '
130        + 'destino "' + (dest || '—') + '" não confere com a análise ' + analysisId
131        + ' (escrita cruzada entre análises evitada)');
132    }
133  } catch (e) {
134    console.warn('[Pipeline] _persistField(' + field + ') falhou: ' + (e && e.message));
135  }
136}
137
138// Persiste o resultado de um módulo do background. Grava FLAT — cada campo no nível
139// raiz de analysis_data (igual aos *-retry e ao que os renderers leem:
140// d.crisis_indicators, d.turista_origens, d.concorrentes...). NÃO aninhar em
141// {crisis:...} senão renderCrisis/renderProfile/renderBenchmark não acham no reload.
142async function _persistModuleResult(slug, result, dest) {
143  if (!result || typeof result !== 'object') return;
144  const keys = Object.keys(result);
145  for (let i = 0; i < keys.length; i++) {
146    const k = keys[i];
147    if (k.charAt(0) === '_') continue; // pula _exchangeRates etc
148    await _persistField(k, result[k], dest);
149  }
150}
151
152// Helper: check if current user is bypass (via flag no banco, carregada no login)
153// Remove partes duplicadas de destino (ex: "Osorio, RS, Brasil, RS, Brasil" → "Osorio, RS, Brasil")
154function _deduplicateDest(dest) {
155  if (!dest) return dest;
156  var parts = dest.split(', ');
157  var seen = [];
158  for (var i = 0; i < parts.length; i++) {
159    if (seen.indexOf(parts[i]) === -1) seen.push(parts[i]);
160  }
161  return seen.join(', ');
162}
163
164// ===== MODULE RETRY SYSTEM =====
165// Feature flag — desligar = botões de retry somem, módulos sem dados mostram mensagem estática
166window._ENABLE_MODULE_RETRY = true;
167
168// Mensagem padrao de erro de modulo — sem jargao tecnico, mesma string em todos.
169// Funções (não const) porque dependem de t()/G.lang no momento da exibição, não do
170// idioma no instante em que o script carregou.
171function _MODULE_ERROR_DESCRIPTION() { return t('module_error_desc'); }
172function _MODULE_ERROR_BUTTON_TEXT() { return t('module_error_retry_btn'); }
173
174// ===== LOTE 3 (Apify/TripAdvisor) — teto de espera dos módulos básicos =====
175// crise/perfil/benchmark rodam DEPOIS do Lote 3 porque dependem de reviews/IG/Maps.
176// O normal é ~2min (medido no healthcheck diário). O aviso marca "está demorando" e o
177// teto encerra a espera silenciosa — sem desistir do Apify: a retomada roda os 3 quando
178// o dado chegar. Ver _bgLoadBasics.
179var _LOTE3_AVISO_MS = 120000;   // 2 min — a partir daqui, log de espera
180// 6 min (3x o normal medido). Já foi 10 min, teto MAIOR que a sessão que ele deveria
181// proteger: a jornada do healthcheck vive ~7 min e o usuário raramente deixa a aba aberta
182// mais que isso, então o teto nunca disparava e a análise seguia sendo salva incompleta
183// em silêncio — exatamente o caso de 10/08 e 16/08. Disparar cedo é barato: se o Lote 3
184// chegar depois, a retomada roda os 3 e limpa a marca.
185var _LOTE3_TETO_MS  = 360000;
186
187// Motivos possíveis de um bloco básico não ter sido gerado. Ficam no dado (não só no
188// console) porque quem lê é o usuário, um dia depois, num refresh.
189var _MODULE_LOTE3_REASON = 'Os dados pesados do destino (avaliações, Instagram e Google Maps) '
190  + 'não chegaram a tempo nesta análise, então este bloco não foi gerado.';
191var _MODULE_STEP_REASON = 'A geração deste bloco falhou nesta análise.';
192
193// Módulos básicos que a análise NÃO conseguiu preencher, gravado FLAT em
194// analysis_data.modulos_incompletos (+ o motivo). Sem isso o módulo abre num "sem dados"
195// mudo no dia seguinte — indistinguível de destino que realmente não tem dado. Lista
196// vazia = ciclo fechado (inclusive por retomada tardia), limpa a marca.
197async function _bgMarcarIncompletos(pages, dest, motivo) {
198  var lista = Array.isArray(pages) ? pages : [];
199  var d = window._lastAnalysisData || {};
200  var antes = Array.isArray(d.modulos_incompletos) ? d.modulos_incompletos 
200: [];
201  if (!antes.length && !lista.length) return;   // nada a marcar nem a limpar
202  d.modulos_incompletos = lista;
203  d.modulos_incompletos_motivo = lista.length ? (motivo || _MODULE_LOTE3_REASON) : '';
204  window._lastAnalysisData = d;
205  if (lista.length) {
206    console.error('[BG] análise salva SEM os blocos: ' + lista.join(', ')
207      + ' — os módulos mostram o motivo e o botão de gerar novamente');
208    lista.forEach(function (p) {
209      if (window._bgLoading) window._bgLoading[p] = false;
210      try {
211        if (p === 'crisis'    && typeof renderCrisis    === 'function') renderCrisis(d);
212        if (p === 'profile'   && typeof renderProfile   === 'function') renderProfile(d);
213        if (p === 'benchmark' && typeof renderBenchmark === 'function') renderBenchmark(d);
214      } catch (_e) {}
215    });
216  }
217  if (typeof _persistField === 'function') {
218    await _persistField('modulos_incompletos', lista, dest);
219    await _persistField('modulos_incompletos_motivo', d.modulos_incompletos_motivo, dest);
220  }
221}
222
223// Texto extra do estado de erro quando ESTE módulo ficou de fora da análise. Retorna ''
224// quando o módulo está vazio por outro motivo (aí vale só a copy padrão).
225function _moduleIncompleteReason(d, page) {
226  var lista = (d && Array.isArray(d.modulos_incompletos)) ? d.modulos_incompletos : [];
227  if (lista.indexOf(page) < 0) return '';
228  return (d && d.modulos_incompletos_motivo) || _MODULE_LOTE3_REASON;
229}
230
231/**
232 * Contrato de shell de módulo: `.dash-content` > [título] > `.module-body`.
233 * Esconde o corpo do módulo (`.module-body`) e insere um nó de estado
234 * (CTA/erro/loading) logo ACIMA dele — assim o título/cabeçalho da página
235 * (que fica fora do `.module-body`) permanece visível e todo o conteúdo solto
236 * (dentro do `.module-body`) some junto. Fallback legado, para módulos ainda
237 * não migrados ao contrato (ex: heatmap, viral): esconde `.card`/`.grid-2` e
238 * insere o nó após o `.section-title` filho-direto.
239 */
240function _insertModuleState(container, node) {
241  var body = container.querySelector(':scope > .module-body');
242  if (body) {
243    body.style.display = 'none';
244    container.insertBefore(node, body);
245    return;
246  }
247  container.querySelectorAll('.card, .grid-2').forEach(function (c) { c.style.display = 'none'; });
248  var firstTitle = container.querySelector(':scope > .section-title');
249  if (firstTitle) {
250    if (firstTitle.nextSibling) container.insertBefore(node, firstTitle.nextSibling);
251    else container.appendChild(node);
252  } else {
253    container.insertBefore(node, container.firstChild);
254  }
255}
256
257/**
258 * Restaura o corpo do módulo escondido por _insertModuleState (par de _hideModuleError).
259 */
260function _restoreModuleBody(container) {
261  var body = container.querySelector(':scope > .module-body');
262  if (body) { body.style.display = ''; return; }
263  container.querySelectorAll('.card, .grid-2').forEach(function (c) { c.style.display = ''; });
264}
265
266/**
267 * Mostra estado de erro padronizado em um módulo.
268 * @param {string} containerId - ID da page (ex: 'heatmap', 'crisis', 'emissores')
269 * @param {object} config - { icon, title, buttonStyle, retryFn }
270 *   description e buttonText sao FIXOS (padronizados) — nao aceitar override.
271 */
272function _showModuleError(containerId, config) {
273  var container = document.querySelector('#page-' + containerId + ' .dash-content')
274                || document.getElementById('page-' + containerId);
275  if (!container) return;
276  // Remove CTA / loading / erro anterior
277  ['cta-state','loading-state','error-state'].forEach(function(suffix){
278    var el = document.getElementById(containerId + '-' + suffix);
279    if (el) el.remove();
280  });
281  var errDiv = document.createElement('div');
282  errDiv.id = containerId + '-error-state';
283  errDiv.style.cssText = 'text-align:center;padding:40px 20px;';
284  var btnHtml = '';
285  if (window._ENABLE_MODULE_RETRY && config.retryFn) {
286    btnHtml = '<button id="' + containerId + '-retry-btn" style="padding:10px 24px;border-radius:10px;border:none;color:#fff;font-family:Syne,sans-serif;font-size:13px;font-weight:700;cursor:pointer;transition:all .2s;' + (config.buttonStyle || 'background:linear-gradient(135deg,var(--accent),var(--blue))') + ';">' + _MODULE_ERROR_BUTTON_TEXT() + '</button>';
287  }
288  // `reason` explica POR QUE faltou (ex.: Lote 3 não chegou). Só aparece quem passa —
289  // a copy padrão continua igual em todos os módulos.
290  var reasonHtml = config.reason
291    ? '<div style="font-size:12px;color:var(--text3);margin:-12px auto 20px;max-width:400px;padding:9px 12px;border-radius:8px;background:rgba(245,158,11,0.08);border:1px solid rgba(245,158,11,0.25);line-height:1.45;">'
292      + config.reason + '</div>'
293    : '';
294  errDiv.innerHTML = '<div style="font-size:48px;margin-bottom:16px;">' + (config.icon || '⚠️') + '</div>'
295    + '<div style="font-size:16px;font-weight:700;color:var(--text);margin-bottom:8px;">' + (config.title || t('module_error_default_title')) + '</div>'
296    + '<div style="font-size:13px;color:var(--text3);margin-bottom:20px;max-width:400px;margin-left:auto;margin-right:auto;">' + _MODULE_ERROR_DESCRIPTION() + '</div>'
297    + reasonHtml
298    + btnHtml;
299  _insertModuleState(container, errDiv);
300  if (window._ENABLE_MODULE_RETRY && config.retryFn) {
301    document.getElementById(containerId + '-retry-btn').onclick = config.retryFn;
302  }
303}
304
305/**
306 * Remove estado de erro/loading/CTA e restaura cards do módulo.
307 */
308function _hideModuleError(containerId) {
309  ['error-state','loading-state','cta-state'].forEach(function(suffix){
310    var el = document.getElementById(containerId + '-' + suffix);
311    if (el) el.remove();
312  });
313  var container = document.querySelector('#page-' + containerId + ' .dash-content')
314                || document.getElementById('page-' + containerId);
315  if (container) _restoreModuleBody(container);
316  // Módulo voltou a exibir dados → garante o botão padrão "Refazer análise".
317  // Ponto único: todo renderer/retry chama _hideModuleError ao mostrar dados
318  // (exceto dashboard, que injeta no fim do próprio renderDashboard).
319  if (typeof _ensureRedoButton === 'function') _ensureRedoButton(containerId);
320}
321
322/**
323 * Mostra um spinner de loading padronizado dentro de um módulo (esconde o corpo
324 * via _insertModuleState). Usado pelo dashboard no 1º load e pelo auto-load dos
325 * módulos básicos (crisis/profile) ao abrir o menu. Mesmo visual do loading do CTA.
326 */
327function _showModuleLoading(containerId, label) {
328  var container = document.querySelector('#page-' + containerId + ' .dash-content')
329                || document.getElementById('page-' + containerId);
330  if (!container) return;
331  ['cta-state','loading-state','error-state'].forEach(function (s) {
332    var el = document.getElementById(containerId + '-' + s); if (el) el.remove();
333  });
334  var box = document.createElement('div');
335  box.id = containerId + '-loading-state';
336  box.style.cssText = 'text-align:center;padding:60px 20px;';
337  box.innerHTML =
338    '<div style="display:inline-block;width:42px;height:42px;border:3px solid var(--surface);border-top-color:var(--accent);border-radius:50%;animation:cp-spin 1s linear infinite;margin-bottom:18px;"></div>' +
339    '<div style="font-size:15px;font-weight:600;color:var(--text);margin-bottom:6px;">' + t('module_loading_verb') + ' ' + (label || '') + '...</div>' +
340    '<div style="font-size:12px;color:var(--text3);">
340' + t('module_loading_hint') + '</div>';
341  _insertModuleState(container, box);
342}
343
344// === MODULE CTA — "Gere este módulo" sob demanda ===
345// Aplicado nas pages dos planos premium e oracle. Quando o módulo não tem
346// dados (porque o usuário ainda não pediu pra rodar), mostra um CTA com
347// ícone + título + descrição + botão. O botão chama a função _retry*
348// correspondente, que faz todo o ciclo (loading inline → analysis → render).
349// Não há persistência: cada refresh volta pro CTA até o user clicar.
350// title/description/ctaText são CHAVES de i18n (não texto literal): este objeto é
351// avaliado 1x no carregamento do script, antes do usuário escolher o idioma no
352// onboarding — texto literal aqui ficaria congelado em português pra sempre. A
353// tradução de fato acontece em _showModuleCTA(), via t(cfg.titleKey) etc, no
354// momento em que o card é exibido (idioma já definido).
355var MODULE_CTA_BY_PAGE = {
356  // PREMIUM (prompt analysis-premium → 5 derivados)
357  heatmap: {
358    icon: '🗺️', titleKey: 'cta_title_heatmap', descKey: 'cta_desc_heatmap',
359    ctaTextKey: 'cta_btn_heatmap', retryFn: function () { return _retryHeatmap(); },
360  },
361  influence: {
362    icon: '🌡️', titleKey: 'cta_title_influence', descKey: 'cta_desc_influence',
363    ctaTextKey: 'cta_btn_influence', retryFn: function () { return _retryInfluence(); },
364  },
365  viral: {
366    icon: '🚀', titleKey: 'cta_title_viral', descKey: 'cta_desc_viral',
367    ctaTextKey: 'cta_btn_viral', retryFn: function () { return _retryViral(); },
368  },
369  // simulator e campaign NÃO têm CTA: são ferramentas standalone (form/loader próprios),
370  // independem da análise básica — renderizam direto. (ver docs/MODULES-MAP.md, 2 famílias)
371  // SOCIAL (prompt analysis-social)
372  social: {
373    icon: '📱', titleKey: 'cta_title_social', descKey: 'cta_desc_social',
374    ctaTextKey: 'cta_btn_social', retryFn: function () { return _retrySocial(); },
375  },
376  // emissores removido em 2026-07-27: o módulo não usa CTA genérico. Sem painel, ele
377  // abre a tela de cadastro de mercados (_emissoresRenderCadastro), que já dispara a
378  // geração ao salvar. Um CTA "Mapear mercados emissores" sem mercados escolhidos era
379  // um beco sem saída.
380  // ORACLE: a Mesa Redonda é ferramenta de chat/debate standalone (família 3 — render
381  // direto via loadComponent). NÃO tem CTA: renderOracle nunca esconde a Mesa.
382  felicidade: {
383    icon: '😊', titleKey: 'cta_title_felicidade', descKey: 'cta_desc_felicidade',
384    ctaTextKey: 'cta_btn_felicidade', retryFn: function () { return _retryFelicidade(); },
385  },
386  can: {
387    icon: '🧭', titleKey: 'cta_title_can', descKey: 'cta_desc_can',
388    ctaTextKey: 'cta_btn_can', retryFn: function () { return _retryCan(); },
389  },
390  sinc: {
391    icon: '⚡', titleKey: 'cta_title_sinc', descKey: 'cta_desc_sinc',
392    ctaTextKey: 'cta_btn_sinc', retryFn: function () { return _retryOracle(); },
393  },
394  // gemeo, projecao, calendario e publicmax NÃO têm CTA: são módulos standalone
395  // (HTML/dados próprios via fetch/loader), independem da análise — renderizam direto.
396  // (ver docs/MODULES-MAP.md — Família 2: ferramentas standalone)
397};
398
399// Flag global que controla se um pageId já teve seu CTA clicado nesta sessão.
400// Quando true, _showModuleCTA retorna false → renderer cai no _showModuleError
401// (erro real com "Tentar novamente"). Limpo em resetToOnboarding.
402window._ctaAttempted = window._ctaAttempted || {};
403
404// Mapa pageId → função de renderer correspondente. Usado pra forçar re-render
405// após CTA→retry, garantindo que sucesso/falha vire UI correta.
406var _PAGE_TO_RENDERER = {
407  heatmap:    function (d) { if (typeof renderHeatmap === 'function')   renderHeatmap(d); },
408  influence:  function (d) { if (typeof renderInfluence === 'function') renderInfluence(d); },
409  viral:      function (d) { if (typeof renderViral === 'function')     renderViral(d); },
410  social:     function (d) { if (typeof renderSocial === 'function')    renderSocial(d); },
411  emissores:  function (d) { if (typeof renderEmissores === 'function') renderEmissores(d); },
412  // oracle removido: Mesa Redonda é standalone (sem CTA/retry) — ver MODULE_CTA_BY_PAGE.
413  felicidade: function (d) { if (typeof renderFelicidade === 'function') renderFelicidade(d); },
414  can:        function (d) { if (typeof renderCan === 'function')       renderCan(d); },
415  sinc:       function (d) { if (typeof renderSincronicidade === 'function') render
415Sincronicidade(d); },
416};
417
418/**
419 * Renderiza CTA customizado num módulo. Retorna true se conseguiu renderizar,
420 * false se não há mapping pra esse pageId OU se o CTA já foi clicado uma vez
421 * (caller deve cair no _showModuleError como fallback de erro real).
422 */
423function _showModuleCTA(pageId) {
424  var cfg = MODULE_CTA_BY_PAGE[pageId];
425  if (!cfg) return false;
426  // Se já tentou clicar uma vez → não mostra CTA de novo, deixa erro real aparecer
427  if (window._ctaAttempted[pageId]) return false;
428  var container = document.querySelector('#page-' + pageId + ' .dash-content')
429                || document.getElementById('page-' + pageId);
430  if (!container) return false;
431  // Remove qualquer estado anterior (erro/loading/cta) — só esse fica.
432  ['cta-state','loading-state','error-state'].forEach(function (suffix) {
433    var el = document.getElementById(pageId + '-' + suffix);
434    if (el) el.remove();
435  });
436  var box = document.createElement('div');
437  box.id = pageId + '-cta-state';
438  box.style.cssText = 'text-align:center;padding:60px 20px;';
439  box.innerHTML =
440    '<div style="font-size:54px;margin-bottom:18px;">' + cfg.icon + '</div>' +
441    '<div style="font-size:18px;font-weight:700;color:var(--text);margin-bottom:10px;">' + t(cfg.titleKey) + '</div>' +
442    '<div style="font-size:13px;color:var(--text3);margin:0 auto 26px;max-width:500px;line-height:1.5;">' + (cfg.descKey ? t(cfg.descKey) : '') + '</div>' +
443    '<button id="' + pageId + '-cta-btn" style="padding:12px 28px;border-radius:10px;border:none;background:linear-gradient(135deg,var(--accent),var(--blue));color:#fff;font-family:Syne,sans-serif;font-size:14px;font-weight:700;cursor:pointer;transition:transform .15s ease,box-shadow .15s ease;">' + t(cfg.ctaTextKey) + '</button>';
444
445  // Esconde o corpo do módulo (.module-body) e insere o CTA logo acima dele,
446  // preservando o título/cabeçalho da página. Fallback legado: .card/.grid-2.
447  _insertModuleState(container, box);
448
449  var btn = document.getElementById(pageId + '-cta-btn');
450  if (btn) {
451    btn.onmouseenter = function () { btn.style.transform = 'translateY(-1px)'; btn.style.boxShadow = '0 6px 18px rgba(0,0,0,0.2)'; };
452    btn.onmouseleave = function () { btn.style.transform = ''; btn.style.boxShadow = ''; };
453    btn.onclick = async function () {
454      // Marca que o CTA já foi tentado — próxima vez que o renderer falhar,
455      // _showModuleCTA retorna false e o fluxo cai no _showModuleError real.
456      window._ctaAttempted[pageId] = true;
457      // Transforma o box em loading inline (mantém o usuário no contexto)
458      box.id = pageId + '-loading-state';
459      box.innerHTML =
460        '<div style="display:inline-block;width:42px;height:42px;border:3px solid var(--surface);border-top-color:var(--accent);border-radius:50%;animation:cp-spin 1s linear infinite;margin-bottom:18px;"></div>' +
461        '<div style="font-size:15px;font-weight:600;color:var(--text);margin-bottom:6px;">' + t('module_loading_verb') + ' ' + t(cfg.titleKey) + '...</div>' +
462        '<div style="font-size:12px;color:var(--text3);">
462' + t('module_loading_hint') + '</div>';
463      try {
464        await cfg.retryFn();
465      } catch (e) {
466        console.error('[CTA] retryFn falhou:', e);
467      }
468      // Após retry: força re-render do módulo. Renderer decide entre dados,
469      // erro real (com botão "Tentar novamente") ou — se houve sucesso —
470      // já removeu o loading via _hideModuleError.
471      var renderFn = _PAGE_TO_RENDERER[pageId];
472      if (renderFn) {
473        try { renderFn(window._lastAnalysisData || {}); } catch (e) { console.error('[CTA] render pós-retry falhou:', e); }
474      } else {
475        // Sem renderer mapeado: pelo menos remove o loading-state
476        var el = document.getElementById(pageId + '-loading-state');
477        if (el) el.remove();
478      }
479    };
480  }
481  return true;
482}
483
484// === Render dos módulos BÁSICOS ao navegar ===
485// A carga real é feita em BACKGROUND no pipeline (crise/perfil/benchmark com os
486// prompts COMPLETOS). Os renderers são "bg-aware": mostram spinner enquanto
487// window._bgLoading[page] é true, os dados quando chegam, ou um erro se o
488// background terminou sem dados. Aqui só despachamos o render ao abrir a página.
489var BASIC_AUTOLOAD = {
490  crisis:    function (d) { if (typeof renderCrisis === 'function')    renderCrisis(d); },
491  profile:   function (d) { if (typeof renderProfile === 'function')   renderProfile(d); },
492  benchmark: function (d) { if (typeof renderBenchmark === 'function') renderBenchmark(d); },
493};
494
495function _ensureBasicModule(pageId) {
496  var fn = BASIC_AUTOLOAD[pageId];
497  if (fn) fn(window._lastAnalysisData || {});
498}
499
500/**
501 * Entra no painel (dashboard) com o spinner de módulo padrão, SEM tela de loading
502 * cheia. Idempotente — chamado na saída do onboarding e no início de startAnalysis.
503 */
504// === UPSELL NAV (premium/oracle) — estado "carregando" durante o load do basic ===
505// Mostra os módulos premium/oracle VISÍVEIS porém desabilitados enquanto o dashboard
506// carrega, com tooltip "Carregando dados básicos…". Liberados quando o dashboard tem
507// dados (enforceMenuByPlan reaplica o lock real por plano — cadeado/clique).
508var _UPSELL_NAV_IDS = ['nav-heatmap','nav-influence','nav-viral','nav-simulator','nav-social','nav-campaign','nav-emissores','nav-felicidade','nav-gemeo','nav-can','nav-oracle-main','nav-sinc','nav-compass'];
509// Páginas liberadas mesmo enquanto o dashboard carrega (básicos + histórico + nova análise).
510// Tudo que não estiver aqui é premium/oracle/standalone → bloqueado durante o load.
511var _UPSELL_BASIC_OK = { dashboard:1, crisis:1, profile:1, benchmark:1, history:1, input:1, '':1 };
512
513// Toast curto no canto superior direito (aviso ao tentar abrir módulo durante o load).
514function _showUpsellToast(msg) {
515  msg = msg || 'Aguardando dados do dashboard…';
516  var t = document.getElementById('cp-upsell-toast');
517  if (!t) {
518    t = document.createElement('div');
519    t.id = 'cp-upsell-toast';
520    t.style.cssText = 'position:fixed;top:18px;right:18px;z-index:99999;background:#10161f;color:#e2e8f0;font-size:13px;font-weight:600;padding:12px 16px;border-radius:10px;border:1px solid rgba(255,255,255,.08);box-shadow:0 8px 28px rgba(0,0,0,.4);max-width:280px;display:flex;align-items:center;gap:8px;opacity:0;transform:translateY(-8px);transition:opacity .2s ease,transform .2s ease;pointer-events:none;';
521    document.body.appendChild(t);
522  }
523  t.innerHTML = '<span style="font-size:15px;">⏳</span><span>' + msg + '</span>';
524  // mostra (próximo frame p/ a transição valer) e auto-esconde
525  requestAnimationFrame(function () { t.style.opacity = '1'; t.style.transform = 'translateY(0)'; });
526  clearTimeout(window._cpUpsellToastTimer);
527  window._cpUpsellToastTimer = setTimeout(function () { t.style.opacity = '0'; t.style.transform = 'translateY(-8px)'; }, 2400);
528}
529window._showUpsellToast = _showUpsellToast;
530
531// Instala (1x) os guardas globais de navegação durante o load do dashboard.
532// Keyed em window._dashboardLoading — NÃO depende de onclick por elemento (que o
533// enforceMenuByPlan reescreve ao popular o auth cache). Captura no capture-phase:
534// preventDefault no clique cancela a ativação do <a> ANTES do evento navigate
535// (Navigation API) disparar → bloqueia de verdade.
536function _ensureUpsellNavGuard() {
537  if (window._cpUpsellGuardInstalled) return;
538  window._cpUpsellGuardInstalled = true;
539  function _segFromHref(href) {
540    if (!href || href.indexOf('/app') !== 0) return null;
541    return href.replace('/app', '').replace(/^\//, '').split('/')[0] || '';
542  }
543  document.addEventListener('click', function (e) {
544    if (!window._dashboardLoading) return;
545    var link = (e.target && e.target.closest) ? e.target.closest('a[href]') : null;
546    if (!link) return;
547    var seg = _segFromHref(link.getAttribute('href'));
548    if (seg === null || _UPSELL_BASIC_OK[seg]) return;
548   // fora de /app ou básico → deixa passar
549    e.preventDefault();
550    e.stopImmediatePropagation();
551    _showUpsellToast();
552  }, true);
553  document.addEventListener('mouseover', function (e) {
554    if (!window._dashboardLoading) return;
555    var el = (e.target && e.target.closest) ? e.target.closest('.cp-upsell-loading') : null;
556    if (el) _showUpsellToast();
557  });
558}
559
560function _ensureUpsellCSS() {
561  if (document.getElementById('cp-upsell-css')) return;
562  var st = document.createElement('style');
563  st.id = 'cp-upsell-css';
564  st.textContent =
565    '.nav-item.cp-upsell-loading{opacity:.55;cursor:progress;position:relative;}' +
566    '.nav-item.cp-upsell-loading .nav-lock{display:none!important;}' +
567    '.nav-item.cp-upsell-loading::after{content:attr(data-cp-tip);position:absolute;left:calc(100% - 4px);top:50%;transform:translateY(-50%);margin-left:10px;white-space:nowrap;background:#10161f;color:#cbd5e1;font-size:11px;font-weight:600;padding:6px 10px;border-radius:7px;border:1px solid rgba(255,255,255,.08);box-shadow:0 6px 18px rgba(0,0,0,.35);opacity:0;visibility:hidden;transition:opacity .15s ease;z-index:1000;pointer-events:none;}' +
568    '.nav-item.cp-upsell-loading:hover::after{opacity:1;visibility:visible;}';
569  (document.head || document.documentElement).appendChild(st);
570}
571
572function _setUpsellNavLoading(on) {
573  // Flag global = fonte da verdade do bloqueio (guardas globais leem isto).
574  window._dashboardLoading = !!on;
575  if (on) _ensureUpsellNavGuard();
576  _UPSELL_NAV_IDS.forEach(function (id) {
577    var el = document.getElementById(id);
578    if (!el) return;
579    if (on) {
580      el.style.display = 'flex';                 // visível, porém travado (visual)
581      el.classList.add('cp-upsell-loading');
582      el.setAttribute('data-cp-tip', '⏳ Carregando dados básicos…');
583      // Defesa extra além do guarda global (caso enforceMenuByPlan reescreva isto,
584      // o capture-phase listener ainda bloqueia pelo flag).
585      el.onclick = function (e) { e.preventDefault(); e.stopPropagation(); _showUpsellToast(); };
586    } else {
587      el.classList.remove('cp-upsell-loading');
588      el.removeAttribute('data-cp-tip');
589      el.onclick = null;                         // enforceMenuByPlan reaplica o estado real
590    }
591  });
592}
593
594async function _enterDashboardLoading() {
595  document.querySelectorAll('.ob-page').forEach(function (p) { p.classList.remove('active'); });
596  document.body.classList.remove('loading-screen', 'onboarding');
597  var prevNav = window._routerNavigating;
598  window._routerNavigating = true;   // força o caminho direto do goPage (carrega o componente + ativa a página)
599  try { await goPage('dashboard'); } finally { window._routerNavigating = prevNav; }
600  ['dashboard','crisis','profile','benchmark'].forEach(function (p) {
601    var el = document.getElementById('nav-' + p); if (el) el.style.display = 'flex';
602  });
603  // Premium/Oracle entram VISÍVEIS porém travados ("carregando dados básicos") até
604  // o dashboard ter dados — liberados em startAnalysis após o callClaude retornar.
605  _ensureUpsellCSS();
606  _setUpsellNavLoading(true);
607  // Skeleton-first: NÃO esconde o painel inteiro. Mostra a estrutura com placeholders
608  // (zera dados da análise anterior p/ não vazar) e dispara o clima JÁ em paralelo —
609  // primeiro dado real na tela em ~1-2s, sem esperar a IA nem o Apify.
610  _resetDashboardSkeleton();
611  try { if (typeof fetchWeatherData === 'function') fetchWeatherData(); } catch (_eW) {}
612}
613
614/**
615 * Zera os cards do dashboard para o estado de esqueleto (placeholders) e marca o
616 * bloco de insights como "carregando". Evita exibir dados da análise anterior
617 * durante o 1º load (substitui o antigo _showModuleLoading que escondia tudo).
618 */
619function _resetDashboardSkeleton() {
620  // Spinner inline reutilizável (usa a keyframe cp-spin já existente no CSS).
621  var spin = function (sz) {
622    sz = sz || 20;
623    return '<span style="display:inline-block;width:' + sz + 'px;height:' + sz + 'px;border:2px solid var(--surface);border-top-color:var(--accent);border-radius:50%;animation:cp-spin 1s linear infinite;vertical-align:middle;"></span>';
624  };
625  // KPIs: spinner no valor + tag neutra "carregando" (em vez de "--", que parecia erro)
626  ['kpi-score','kpi-trend','kpi-sent','kpi-opp'].forEach(function (id) {
627    var e = document.getElementById(id); if (e) e.innerHTML = spin(20);
628  });
629  ['kpi-score-tag','kpi-trend-tag','kpi-sent-tag'].forEach(function (id) {
630    var e = document.getElementById(id); if (e) { e.textContent = 'carregando…'; e.className = 'kpi-tag tag-muted'; }
631  });
632  // Nuvem de temas
633  var tc = document.getElementById('theme-cloud');
634  if (tc) tc.innerHTML = '<span style="display:inline-flex;align-items:center;gap:8px;color:var(--text3);font-size:13px;">' + spin(14) + 'Carregando temas…</span>';
635  // Gráfico de tendência: overlay de loading sobre o wrap do SVG (removido em renderTrend).
636  // A LEGENDA também tem que ser limpa: ela carrega os NOMES das cidades e, sem isso,
637  // ficava exibindo a cidade da análise anterior embaixo de um gráfico "carregando",
638  // dando a impressão de que a análise estava pegando outro município.
639  var _tleg = document.getElementById('trend-legend');
640  if (_tleg) _tleg.innerHTML = '';
641  var svg = document.getElementById('trend-svg');
642  if (svg) {
643    svg.innerHTML = '';
644    var wrap = svg.parentElement;
645    if (wrap && !document.getElementById('trend-loading')) {
646      wrap.style.position = 'relative';
647      wrap.style.minHeight = '150px';
648      var tl = document.createElement('div');
649      tl.id = 'trend-loading';
650      tl.style.cssText = 'position:absolute;inset:0;display:flex;align-items:center;justify-content:center;gap:10px;color:var(--text3);font-size:13px;';
651      tl.innerHTML = spin(18) + 'Carregando tendência de interesse…';
652      wrap.appendChild(tl);
653    }
654  }
655  // Donut de sentimento
656  var dpct = document.getElementById('d-pct'); if (dpct) dpct.innerHTML = spin(16);
657  var sl = document.getElementById('sent-legend');
658  if (sl) sl.innerHTML = '<div style="display:flex;align-items:center;gap:8px;color:var(--text3);font-size:12px;">' + spin(12) + 'Carregando…</div>';
659  // Banner de crise escondido durante o load
660  var banner = document.getElementById('main-crisis-banner'); if (banner) banner.classList.add('hidden');
661  // Insights
662  var mi = document.getElementById('main-insights');
663  if (mi) mi.innerHTML = '<span style="display:inline-flex;align-items:center;gap:8px;color:var(--text3);font-size:13px;">' + spin(14) + 'Analisando indicadores…</span>';
664}
665
666/**
667 * Escapa aspas duplas LITERAIS dentro de um valor string. O modelo escreve coisas como
668 *   "analise": "A solução é bundling: "Inverno es Temporada Alta" deve ser o alvo"
669 * e essas aspas fecham a string antes da hora — JSON.parse morre no meio do documento,
670 * com a resposta inteira e completa em mãos. Percorre caractere a caractere mantendo o
671 * estado dentro/fora de string: uma " só ENCERRA o valor se o próximo caractere
672 * não-branco for , } ] ou : — caso contrário é aspas literal e vira \\".
673 * Observado em emissores/heatmap/felicidade quando a chamada cai no fallback pro Claude.
674 */
675function _escaparAspasSoltas(s) {
676  var out = '', dentro = false, esc = false;
677  for (var i = 0; i < s.length; i++) {
678    var c = s[i];
679    if (esc) { out += c; esc = false; continue; }
680    if (c === '\\') { out += c; esc = true; continue; }
681    if (c === '"') {
682      if (!dentro) { dentro = true; out += c; continue; }
683      var j = i + 1;
684      while (j < s.length && /\s/.test(s[j])) j++;
685      if (j < s.length && ',}]:'.indexOf(s[j]) >= 0) { dentro = false; out += c; }
686      else out += '\\"';
687      continue;
688    }
689    out += c;
690  }
691  return out;
692}
693
694/**
695 * Parser robusto de JSON vindo da IA. A IA frequentemente devolve JSON quase-válido:
696 * fences ```json, preâmbulo de texto, números com sinal "+" à frente ("delta": +0.22,
697 * "crescimento": +40) e vírgulas finais. JSON.parse cru EXPLODE em qualquer um desses —
698 * era a causa de: heatmap exigir 2 tentativas, e viral cair no catch (renderizando dados
699 * antigos com emerging_trends/viral_analise vazios). Centraliza a limpeza num só lugar.
700 * @param {string|object} raw
701 * @returns {object} objeto parseado
702 * @throws se nem após a limpeza for JSON válido
703 */
704function _parseAIJson(raw) {
705  if (raw && typeof raw === 'object') return raw;            // já veio parseado
706  var s = String(raw == null ? '' : raw);
707  s = s.replace(/```json/gi, '').replace(/```/g, '').trim(); // remove fences
708  var first = s.indexOf('{'), last = s.lastIndexOf('}');     // recorta do 1º { ao último }
709  if (first >= 0 && last > first) s = s.slice(first, last + 1);
710  // tenta direto; só aplica reparos se falhar (não arrisca corromper JSON já-válido)
711  try { return JSON.parse(s); } catch (_e0) {}
712  var repaired = s
713    .replace(/:\s*\+(\d)/g, ': $1')          // "delta": +0.22  → "delta": 0.22 (CAUSA do erro)
714    .replace(/,\s*([}\]])/g, '$1')           // vírgula final antes de } ou ]
715    .replace(/�/g, '');                 // null bytes
716  try { return JSON.parse(repaired); } catch (_e1) {}
717  // Último recurso: aspas literais dentro do valor (ver _escaparAspasSoltas).
718  return JSON.parse(_escaparAspasSoltas(repaired));   // se ainda falhar, lança (caller trata no catch)
719}
720
721/**
722 * Helper para setar estado de loading/erro no botão de retry.
723 */
724function _retryBtnLoading(containerId, loading, errorMsg) {
725  var btn = document.getElementById(containerId + '-retry-btn');
726  if (!btn) return;
727  if (loading) {
728    btn.disabled = true;
729    btn.textContent = t('btn_generating');
730  } else {
731    btn.disabled = false;
732    btn.textContent = t('module_error_retry_btn');
733  }
734  if (errorMsg) {
735    var msgEl = document.getElementById(containerId + '-retry-msg');
736    if (!msgEl) {
737      msgEl = document.createElement('div');
738      msgEl.id = containerId + '-retry-msg';
739      msgEl.style.cssText = 'font-size:12px;color:var(--red);margin-top:10px;';
740      btn.parentElement.appendChild(msgEl);
741    }
742    msgEl.textContent = errorMsg;
743  }
744}
745
746// ===== VALIDAÇÕES POR MÓDULO =====
747function _heatmapHasData(d) { return d.heatmap_areas && Array.isArray(d.heatmap_areas) && d.heatmap_areas.length > 0; }
748function _crisisHasData(d) { return d.crisis_indicators && Array.isArray(d.crisis_indicators) && d.crisis_indicators.length > 0; }
749function _profileHasData(d) {
750  if (!d) return false;
751  // Origens é DINÂMICO (0..N) — vazio é válido. O módulo tem dados se houver origens
752  // OU um turista_perfil com algum campo real OU análise textual.
753  var hasOrigins = d.turista_origens && Array.isArray(d.turista_origens) && d.turista_origens.length > 0;
754  var tp = d.turista_perfil;
755  var hasPerfil = tp && typeof tp === 'object' && Object.keys(tp).some(function(k){
756    var v = tp[k]; if (v == null) return false;
757    var s = String(v).trim().toLowerCase();
758    return s !== '' && s !== 'dados_indisponiveis';
759  });
760  var an = d.turista_analise == null ? '' : String(d.turista_analise).trim();
761  var hasAnalise = an !== '' && an !== '--';
762  return !!(hasOrigins || hasPerfil || hasAnalise);
763}
764function _emissoresHasData(d) { var e = d.emissores_ALWAYS_RETURN_ALL_4_DO_NOT_SKIP || d.emissores; return e && Array.isArray(e) && e.length > 0; }
765function _socialHasData(d) { var s = d.social_radar_MANDATORY_DO_NOT_SKIP; return s && s.consenso && Array.isArray(s.consenso) && s.consenso.length > 0; }
766function _oracleHasData(d) { return d.oracle_alerts && Array.isArray(d.oracle_alerts) && d.oracle_alerts.length > 0; }
767
768// Influence retry — re-fetch do Supabase (NÃO chama AI)
769async function _retryInfluence() {
770  var btn = document.querySelector('#page-influence button[onclick*="retryInfluence"]');
771  if (btn) { btn.disabled = true; btn.textContent = '⏳ Carregando...'; }
772  try {
773    await loadCuratedInfluencersForDest(G.dest || '');
774    renderInfluence(window._lastAnalysisData || {});
775  } catch(e) {
776    console.error('[Influence] Retry failed:', e);
777    if (btn) { btn.disabled = false; btn.textContent = 'Tentar novamente'; }
778  }
779}
780
781
782// ===== RETRY FUNCTIONS =====
783
784async function _retryCrisis() {
785  _retryBtnLoading('crisis', true);
786  try {
787    var d = window._lastAnalysisData || {};
788    var dest = G.dest || '';
789    var langName = {pt:'Portuguese',en:'English',es:'Spanish',fr:'French'}[G.lang||'pt'] || 'Portuguese';
790    var now = new Date();
791    var startDate = new Date(now.getTime() - (parseInt(G.periodo||'30',10)) * 86400000);
792    var fmtD = function(dt) { return dt.toISOString().split('T')[0]; };
793    var extraCtx = '';
794    if (d.temas) extraCtx += 'Temas: ' + d.temas.map(function(t) { return t.nome + ' (' + t.sent + ', ' + t.count + ')'; }).join(', ');
795    if (d.sentimento) extraCtx += '\nSentimento: positivo ' + d.sentimento.positivo + '%, neutro ' + d.sentimento.neutro + '%, negativo ' + d.sentimento.negativo + '%';
796    if (d.heatmap_areas) extraCtx += '\nLocais: ' + d.heatmap_areas.map(function(h) { return h.nome + ' (score:' + h.score + ')'; }).join(', ');
797
798    var meta = await getPromptWithMeta('crisis-retry', { dest: dest, startDate: fmtD(startDate), now: fmtD(now), periodoNum: G.periodo||'30', langName: langName, extraDataCtx: extraCtx || 'Contexto não disponível' });
799    if (!meta?.text) throw new Error('Prompt crisis-retry não encontrado');
800    var raw = await callAIGlobal(meta.text, 16384, true, meta);
801    var result = _parseAIJson(raw);
802    if (!result || !result.crisis_indicators || result.crisis_indicators.length === 0) throw new Error('Resposta sem indicadores de crise');
803
804    var fields = ['crisis_level', 'crisis_resumo', 'crisis_indicators', 'crisis_analise'];
805    var supabase = await getSupabase();
806    var analysisId = sessionStorage.getItem('cp_active_analysis');
807    for (var i = 0; i < fields.length; i++) {
808      if (result[fields[i]] != null) {
809        d[fields[i]] = result[fields[i]];
810        if (analysisId) await supabase.rpc('merge_analysis_field', { p_analysis_i
810d: analysisId, p_field: fields[i], p_value: JSON.stringify(result[fields[i]]) });
811      }
812    }
813    window._lastAnalysisData = d;
814    _hideModuleError('crisis');
815    if (typeof renderCrisis === 'function') renderCrisis(d);
816  } catch(e) {
817    console.error('[Crisis] Retry failed:', e);
818    _retryBtnLoading('crisis', false, 'Falha: ' + (e.message || 'erro desconhecido'));
819  }
820}
821
822async function _retryProfile() {
823  _retryBtnLoading('profile', true);
824  try {
825    var d = window._lastAnalysisData || {};
826    var dest = G.dest || '';
827    var langName = {pt:'Portuguese',en:'English',es:'Spanish',fr:'French'}[G.lang||'pt'] || 'Portuguese';
828    var now = new Date();
829    var startDate = new Date(now.getTime() - (parseInt(G.periodo||'30',10)) * 86400000);
830    var fmtD = function(dt) { return dt.toISOString().split('T')[0]; };
831    var extraCtx = '';
832    if (d.temas) extraCtx += 'Temas: ' + d.temas.map(function(t) { return t.nome + ' (' + t.count + ' menções)'; }).join(', ');
833    if (d.sentimento) extraCtx += '\nSentimento: positivo ' + d.sentimento.positivo + '%, negativo ' + d.sentimento.negativo + '%';
834    if (d.emissores_ALWAYS_RETURN_ALL_4_DO_NOT_SKIP) extraCtx += '\nMercados: ' + d.emissores_ALWAYS_RETURN_ALL_4_DO_NOT_SKIP.map(function(m) { return m.pais + ' (' + m.participacao + '%)'; }).join(', ');
835
836    var meta = await getPromptWithMeta('profile-retry', { dest: dest, startDate: fmtD(startDate), now: fmtD(now), periodoNum: G.periodo||'30', langName: langName, extraDataCtx: extraCtx || 'Contexto não disponível' });
837    if (!meta?.text) throw new Error('Prompt profile-retry não encontrado');
838    var raw = await callAIGlobal(meta.text, 16384, true, meta);
839    var result = _parseAIJson(raw);
840    if (!result || !_profileHasData(result)) throw new Error('Resposta sem perfil do turista');
841
842    var fields = ['turista_origens', 'origens_proxy', 'turista_perfil', 'turista_interesses', 'turista_analise'];
843    var supabase = await getSupabase();
844    var analysisId = sessionStorage.getItem('cp_active_analysis');
845    for (var i = 0; i < fields.length; i++) {
846      if (result[fields[i]] != null) {
847        d[fields[i]] = result[fields[i]];
848        if (analysisId) await supabase.rpc('merge_analysis_field', { p_analysis_id: analysisId, p_field: fields[i], p_value: JSON.stringify(result[fields[i]]) });
849      }
850    }
851    window._lastAnalysisData = d;
852    _hideModuleError('profile');
853    if (typeof renderProfile === 'function') renderProfile(d);
854  } catch(e) {
855    console.error('[Profile] Retry failed:', e);
856    _retryBtnLoading('profile', false, 'Falha: ' + (e.message || 'erro desconhecido'));
857  }
858}
859
860// ===== Avaliação Média por Setor — nota REAL a partir de sector_establishments_cache =====
861// Taxonomia fixa dos 9 setores (mesma ordem/emoji do template heatmap-retry). A nota deixou de vir
862// do LLM: é a média ponderada de total_score (nota Google) por reviews_count dos estabelecimentos
863// coletados no admin. Sem dados coletados → nota null ("sem dados").
864const SECTOR_TAXONOMY = [
865  {emoji:'🍽️',setor:'Gastronomia'},{emoji:'🏨',setor:'Hospedagem'},{emoji:'🎢',setor:'Atrações'},
866  {emoji:'🚌',setor:'Transporte'},{emoji:'🌙',setor:'Vida Noturna'},{emoji:'🛍️',setor:'Comércio'},
867  {emoji:'🏛️',setor:'Cultura'},{emoji:'🌿',setor:'Natureza'},{emoji:'⚽',setor:'Esportes'}
868];
869// s.setor é chave interna fixa em PT (bate com a coluna sector_establishments_cache.sector
870// e com openSectorModal(s.setor)) — NUNCA traduzir o valor em si. Este mapa só resolve o
871// RÓTULO exibido, via t(), preservando a chave interna intacta.
872const SECTOR_I18N_KEY = {
873  'Gastronomia':'sector_gastronomia', 'Hospedagem':'sector_hospedagem', 'Atrações':'sector_atracoes',
874  'Transporte':'sector_transporte', 'Vida Noturna':'sector_vida_noturna', 'Comércio':'sector_comercio',
875  'Cultura':'sector_cultura', 'Natureza':'sector_natureza', 'Esportes':'sector_esportes'
876};
877function _sectorLabel(setor) { var k = SECTOR_I18N_KEY[setor]; return k ? t(k) : setor; }
878function _secEsc(s){ return String(s == null ? '' : s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
879
880// Lê o cache de estabelecimentos por setor da cidade e devolve { ratings, rowsBySector } ou null.
881// ratings: 9 setores na ordem da taxonomia, nota = Σ(total_score×reviews_count)/Σ(reviews_count) ou null.
882async function loadSectorRatings(dest) {
883  try {
884    const parts = (typeof _destParts === 'function') ? _destParts(dest) : { cityNorm: (dest||'').split(',')[0].trim().toLowerCase() };
885    const cityNorm = parts.cityNorm;
886    if (!cityNorm) return null;
887    const supabase = await getSupabase();
888    const { data: rows } = await supabase.from('sector_establishments_cache')
889      .select('*').eq('destination', cityNorm).gt('expires_at', new Date().toISOString());
890    if (!rows || !rows.length) return null;
891    const bySector = {};
892    rows.forEach(function(r){ (bySector[r.sector] = bySector[r.sector] || []).push(r); });
893    const ratings = SECTOR_TAXONOMY.map(function(t){
894      const list = bySector[t.setor] || [];
895      let wsum = 0, wn = 0;
896      list.forEach(function(r){ const sc = parseFloat(r.total_score)||0; const w = r.reviews_count||0; if(sc>0 && w>0){ wsum += sc*w; wn += w; } });
897      const nota = wn ? +(wsum/wn).toFixed(2) : null;
898      // hasReviewText: pelo menos 1 estabelecimento do setor tem texto de review coletado (o admin
899      // busca isso sob demanda, com custo — a maioria dos setores não tem). Controla 
899se o card fica
900      // clicável no heatmap (renderSectorRatings) — nota/contagem aparecem sempre, texto só se existir.
901      const hasReviewText = list.some(function(r){ return Array.isArray(r.reviews) && r.reviews.length > 0; });
902      return { emoji:t.emoji, setor:t.setor, nota:nota, estabCount:list.length, reviewCount: list.reduce(function(a,r){ return a+(r.reviews_count||0); }, 0), hasReviewText: hasReviewText };
903    });
904    return { ratings: ratings, rowsBySector: bySector };
905  } catch (e) { console.warn('[SectorRatings] load falhou:', e); return null; }
906}
907
908async function _retryHeatmap() {
909  _retryBtnLoading('heatmap', true);
910  try {
911    var d = window._lastAnalysisData || {};
912    var dest = G.dest || '';
913    var langName = {pt:'Portuguese',en:'English',es:'Spanish',fr:'French'}[G.lang||'pt'] || 'Portuguese';
914    var now = new Date();
915    var startDate = new Date(now.getTime() - (parseInt(G.periodo||'30',10)) * 86400000);
916    var fmtD = function(dt) { return dt.toISOString().split('T')[0]; };
917    var extraCtx = '';
918    if (d.temas) extraCtx += 'Temas: ' + d.temas.map(function(t) { return t.nome + ' (' + t.sent + ', ' + t.count + ')'; }).join(', ');
919    if (d.sentimento) extraCtx += '\nSentimento: positivo ' + d.sentimento.positivo + '%, negativo ' + d.sentimento.negativo + '%';
920    if (d.turista_origens) extraCtx += '\nOrigens: ' + d.turista_origens.map(function(o) { return o.origem + ' ' + o.pct + '%'; }).join(', ');
921
922    // Atrativos canônicos do destino (admin → Google Places). É AQUI que o heatmap trava a lista.
923    var _heatmapAttrs = [];
924    try { _heatmapAttrs = (typeof resolveAttractions === 'function') ? await resolveAttractions(dest) : []; } catch (_eHa) {}
925    var _heatmapAtrCtx = (typeof buildAtrativosFixosCtx === 'function') ? buildAtrativosFixosCtx(_heatmapAttrs) : '';
926    var meta = await getPromptWithMeta('heatmap-retry', { dest: dest, startDate: fmtD(startDate), now: fmtD(now), periodoNum: G.periodo||'30', langName: langName, extraDataCtx: extraCtx || 'Contexto não disponível', atrativosFixosCtx: _heatmapAtrCtx });
927    if (!meta?.text) throw new Error('Prompt heatmap-retry não encontrado');
928    var raw = await callAIGlobal(meta.text, 16384, true, meta);
929    var result = _parseAIJson(raw);
930    if (!result || !result.heatmap_areas || result.heatmap_areas.length === 0) throw new Error('Resposta sem áreas do heatmap');
931
932    // heatmap_areas.score/tendencia NÃO vêm mais da IA: nota real do Google Places (mesma
933    // fonte usada no admin) substitui o score 0-100 inventado; tendência sai — não existe
934    // dado histórico real por trás da seta subindo/caindo.
935    // Casamento: a IA escreve o nome com formatação própria (tira "do Rio de Janeiro", adiciona
936    // "(MAR)", etc.) — nome exato quase nunca bate com o título curado. Quando a contagem dos 2
937    // arrays é igual, confia na POSIÇÃO (a IA gera 1 entrada por atrativo NESTA ORDEM — ver
938    // buildAtrativosFixosCtx); nome normalizado por substring só entra como fallback se a
939    // contagem divergir (a IA pulou/adicionou algum lugar apesar da instrução).
940    var _normName = function(s){
941      return String(s||'').toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g,'')
942        .replace(/\([^)]*\)/g, ' ').replace(/\|.*/g, ' ').replace(/[^a-z0-9\s]/g, ' ')
943        .replace(/\s+/g, ' ').trim();
944    };
945    var _namesMatch = function(a, b) {
946      var na = _normName(a), nb = _normName(b);
947      return !!na && !!nb && (na === nb || na.indexOf(nb) !== -1 || nb.indexOf(na) !== -1);
948    };
949    var _sameCount = _heatmapAttrs.length === result.heatmap_areas.length;
950    result.heatmap_areas.forEach(function(area, idx){
951      var src = _sameCount ? _heatmapAttrs[idx] : _heatmapAttrs.find(function(x){ return _namesMatch(x.title, area.nome); });
952      area.google_rating = (src && src.total_score != null) ? src.total_score : null;
953      area.google_reviews_count = (src && src.reviews_count != null) ? src.reviews_count : null;
954      // resumo_reviews só existe quando o atrativo TEM review de texto real coletado — sem
955      // isso, força vazio mesmo que a IA tenha respondido algo (nunca confiar cegamente).
956      var hasRealReviews = !!(src && Array.isArray(src.reviews) && src.reviews.length > 0);
957      area.resumo_reviews = hasRealReviews ? (area.resumo_reviews || '') : '';
958      // Lista bruta dos reviews reais (mesma fonte do resumo) — o frontend mostra os 2:
959      // resumo da IA + reviews originais, pra quem quiser conferir/ler na íntegra.
960      area.reviews = hasRealReviews ? src.reviews : [];
961      delete area.score;
962      delete area.tendencia;
963      delete area.correlacao;
964    });
965
966    // sector_ratings deixa de vir do LLM: nota REAL (média ponderada) do cache de estabelecimentos por
967    // setor, ou taxonomia com nota null ("sem dados") quando a cidade não foi coletada no admin.
968    try {
969      var _sec = await loadSectorRatings(dest);
970      result.sector_ratings = _sec ? _sec.ratings : SECTOR_TAXONOMY.map(function(s){ return { emoji:s.emoji, setor:s.setor, nota:null }; });
971      window._sectorEstablishments = _sec ? _sec.rowsBySector : {};
972    } catch (_eS) { result.sector_ratings = SECTOR_TAXONOMY.map(function(s){ return { emoji:s.emoji, setor:s.setor, nota:null }; }); window._sectorEstablishments = {}; }
973
974    var fields = ['heatmap_areas', 'silent_crisis', 'heatmap_analise', 'sector_ratings'];
975    var supabase = await getSupabase();
976    var analysisId = sessionStorage.getItem('cp_active_analysis');
977    for (var i = 0; i < fields.length; i++) {
978      if (result[fields[i]] != null) {
979        d[fields[i]] = result[fields[i]];
980        if (analysisId) await supabase.rpc('merge_analysis_field', { p_analysis_i
980d: analysisId, p_field: fields[i], p_value: JSON.stringify(result[fields[i]]) });
981      }
982    }
983    window._lastAnalysisData = d;
984    _hideModuleError('heatmap');
985    if (typeof renderHeatmap === 'function') renderHeatmap(d);
986  } catch(e) {
987    console.error('[Heatmap] Retry failed:', e);
988    _retryBtnLoading('heatmap', false, 'Falha: ' + (e.message || 'erro desconhecido'));
989  }
990}
991
992async function _retryEmissores() {
993  _retryBtnLoading('emissores', true);
994  try {
995    var d = window._lastAnalysisData || {};
996    var dest = G.dest || '';
997    var langName = {pt:'Portuguese',en:'English',es:'Spanish',fr:'French'}[G.lang||'pt'] || 'Portuguese';
998    var now = new Date();
999    var startDate = new Date(now.getTime() - (parseInt(G.periodo||'30',10)) * 86400000);
1000    var fmtD = function(dt) { return dt.toISOString().split('T')[0]; };
1001    // Mercados desta análise (persistidos) OU os cadastrados na sessão atual.
1002    // Sem mercados: NÃO inventamos um conjunto default — abre a tela de cadastro e para.
1003    var _savedMk = d._meta_emitterMarkets;
1004    var marketCodes = (_savedMk && _savedMk.length) ? _savedMk : ((G.markets && G.markets.length) ? G.markets : null);
1005    if (!marketCodes) {
1006      _retryBtnLoading('emissores', false);
1007      if (typeof _emissoresRenderCadastro === 'function') _emissoresRenderCadastro();
1008      return false;
1009    }
1010    var marketsCtx = marketCodes.join(', ');
1011
1012    // ===== DADOS REAIS (nunca estimados): MTur (chegadas oficiais) + câmbio + Trends/país =====
1013    // Buscados em paralelo. O que voltar vira contexto do prompt E é guardado em `d`
1014    // para o renderer usar DIRETO (o número na tela vem da fonte, não do LLM).
1015    var mtur = null, fx = null;
1016    try {
1017      var _r = await Promise.all([
1018        (typeof fetchMturArrivals === 'function') ? fetchMturArrivals(dest, marketCodes) : Promise.resolve(null),
1019        (typeof fetchMarketExchange === 'function') ? fetchMarketExchange(marketCodes) : Promise.resolve(null)
1020      ]);
1021      mtur = _r[0]; fx = _r[1];
1022    } catch (_eFetch) { console.warn('[Emissores] coleta de dados reais parcial:', _eFetch && _eFetch.message); }
1023
1024    var realBlocks = [];
1025    if (mtur && mtur.available && typeof buildMturContext === 'function') { var mb = buildMturContext(mtur); if (mb) realBlocks.push(mb); }
1026    else realBlocks.push('CHEGADAS OFICIAIS (MTur): sem dado de origem por UF para este destino — NÃO estime participação/volume por mercado; devolva null nesses campos.');
1027    if (fx && fx.block) realBlocks.push(fx.block);
1028    // contexto qualitativo do próprio destino (temas/sentimento) — para os textos
1029    var qual = [];
1030    if (d.temas) qual.push('Temas do destino: ' + d.temas.map(function(t){ return t.nome; }).join(', '));
1031    if (d.sentimento) qual.push('Sentimento geral do destino: positivo ' + d.sentimento.positivo + '%');
1032    if (qual.length) realBlocks.push(qual.join('\n'));
1033    var extraCtx = realBlocks.join('\n\n');
1034
1035    var meta = await getPromptWithMeta('emissores-retry', { dest: dest, startDate: fmtD(startDate), now: fmtD(now), periodoNum: G.periodo||'30', langName: langName, marketsCtx: marketsCtx, extraDataCtx: extraCtx || 'Contexto não disponível' });
1036    if (!meta?.text) throw new Error('Prompt emissores-retry não encontrado');
1037    var raw = await callAIGlobal(meta.text, 16384, true, meta);
1038    var result = _parseAIJson(raw);
1039    if (!result || !result.emissores_ALWAYS_RETURN_ALL_4_DO_NOT_SKIP || result.emissores_ALWAYS_RETURN_ALL_4_DO_NOT_SKIP.length === 0) throw new Error('Resposta sem dados de emissores');
1040
1041    // Campos do LLM (qualitativos + estruturais). Os NUMÉRICOS reais vêm de emissores_mtur/emissores_fx.
1042    var fields = ['emissores_ALWAYS_RETURN_ALL_4_DO_NOT_SKIP', 'emissores_cambio_por_mercado', 'emissores_oportunidade', 'emissores_analise', 'emissores_alertas', 'emissores_cambio', 'emissores_idioma', 'emissores_narrativas_positivas', 'emissores_narrativas_negativas', 'emissores_narrativas_analise'];
1043    var supabase = await getSupabase();
1044    var analysisId = sessionStorage.getItem('cp_active_analysis');
1045    for (var i = 0; i < fields.length; i++) {
1046      if (result[fields[i]] != null) {
1047        d[fields[i]] = result[fields[i]];
1048        if (analysisId) await supabase.rpc('merge_analysis_field', { p_analysis_i
1048d: analysisId, p_field: fields[i], p_value: JSON.stringify(result[fields[i]]) });
1049      }
1050    }
1051    // Persistir os DADOS REAIS junto da análise (fonte de verdade dos números da tela)
1052    d.emissores_mtur = (mtur && mtur.available) ? mtur : { available: false };
1053    d.emissores_fx = fx || { available: false };
1054    if (analysisId) {
1055      await supabase.rpc('merge_analysis_field', { p_analysis_id: analysisId, p_field: 'emissores_mtur', p_value: JSON.stringify(d.emissores_mtur) });
1056      await supabase.rpc('merge_analysis_field', { p_analysis_id: analysisId, p_field: 'emissores_fx', p_value: JSON.stringify(d.emissores_fx) });
1057    }
1058    window._lastAnalysisData = d;
1059    _hideModuleError('emissores');
1060    if (typeof renderEmissores === 'function') renderEmissores(d);
1061    return true;
1062  } catch(e) {
1063    console.error('[Emissores] Retry failed:', e);
1064    _retryBtnLoading('emissores', false, 'Falha: ' + (e.message || 'erro desconhecido'));
1065    return false;
1066  }
1067}
1068
1069async function _retrySocial() {
1070  _retryBtnLoading('social', true);
1071  try {
1072    var d = window._lastAnalysisData || {};
1073    var dest = G.dest || '';
1074    var langName = {pt:'Portuguese',en:'English',es:'Spanish',fr:'French'}[G.lang||'pt'] || 'Portuguese';
1075    var now = new Date();
1076    var startDate = new Date(now.getTime() - (parseInt(G.periodo||'30',10)) * 86400000);
1077    var fmtD = function(dt) { return dt.toISOString().split('T')[0]; };
1078    var extraCtx = '';
1079    if (d.temas) extraCtx += 'Temas: ' + d.temas.map(function(t) { return t.nome + ' (' + t.count + ')'; }).join(', ');
1080    if (d.influencers) extraCtx += '\nInfluenciadores: ' + d.influencers.map(function(i) { return i.handle + ' (' + i.plataforma + ')'; }).join(', ');
1081    if (d.viral_predictions) extraCtx += '\nPredições virais: ' + d.viral_predictions.map(function(v) { return v.titulo; }).join(', ');
1082
1083    var meta = await getPromptWithMeta('social-retry', { dest: dest, startDate: fmtD(startDate), now: fmtD(now), periodoNum: G.periodo||'30', langName: langName, extraDataCtx: extraCtx || 'Contexto não disponível' });
1084    if (!meta?.text) throw new Error('Prompt social-retry não encontrado');
1085    var raw = await callAIGlobal(meta.text, 16384, true, meta);
1086    var result = _parseAIJson(raw);
1087    if (!result || !result.social_radar_MANDATORY_DO_NOT_SKIP) throw new Error('Resposta sem social radar');
1088
1089    d.social_radar_MANDATORY_DO_NOT_SKIP = result.social_radar_MANDATORY_DO_NOT_SKIP;
1090    var supabase = await getSupabase();
1091    var analysisId = sessionStorage.getItem('cp_active_analysis');
1092    if (analysisId) await supabase.rpc('merge_analysis_field', { p_analysis_id: analysisId, p_field: 'social_radar_MANDATORY_DO_NOT_SKIP', p_value: JSON.stringify(result.social_radar_MANDATORY_DO_NOT_SKIP) });
1093    window._lastAnalysisData = d;
1094    _hideModuleError('social');
1095    if (typeof renderSocial === 'function') renderSocial(d);
1096  } catch(e) {
1097    console.error('[Social] Retry failed:', e);
1098    _retryBtnLoading('social', false, 'Falha: ' + (e.message || 'erro desconhecido'));
1099  }
1100}
1101
1102async function _retryOracle() {
1103  _retryBtnLoading('oracle', true);
1104  try {
1105    var d = window._lastAnalysisData || {};
1106    var dest = G.dest || '';
1107    var langName = {pt:'Portuguese',en:'English',es:'Spanish',fr:'French'}[G.lang||'pt'] || 'Portuguese';
1108    var now = new Date();
1109    var startDate = new Date(now.getTime() - (parseInt(G.periodo||'30',10)) * 86400000);
1110    var fmtD = function(dt) { return dt.toISOString().split('T')[0]; };
1111    var sectorCtx = G.sector || 'destination';
1112
1113    var meta = await getPromptWithMeta('analysis-oracle', { dest: dest, sectorCtx: sectorCtx, cityProfileCtx: '', uploadCtx: '', extraDataCtx: '', exchangeCtx: '', startDate: fmtD(startDate), now: fmtD(now), periodoNum: G.periodo||'30', langName: langName, failedApisCtx: '', atrativosFixosCtx: window._atrativosFixosCtx || '' });
1114    if (!meta?.text) throw new Error('Prompt analysis-oracle não encontrado');
1115    var raw = await callAIGlobal(meta.text, 16384, true, meta);
1116    var result = _parseAIJson(raw);
1117    if (!result || !result.oracle_alerts || result.oracle_alerts.length === 0) throw new Error('Resposta sem alertas oracle');
1118
1119    // Merge SELETIVO — só campos oracle_* e sentimento_subjacente (NÃO feliz_*, NÃO can_*)
1120    var fields = ['oracle_alerts', 'oracle_waves', 'oracle_windows', 'oracle_analise', 'sentimento_subjacente'];
1121    var supabase = await getSupabase();
1122    var analysisId = sessionStorage.getItem('cp_active_analysis');
1123    for (var i = 0; i < fields.length; i++) {
1124      if (result[fields[i]] != null) {
1125        d[fields[i]] = result[fields[i]];
1126        if (analysisId) await supabase.rpc('merge_analysis_field', { p_analysis_i
1126d: analysisId, p_field: fields[i], p_value: JSON.stringify(result[fields[i]]) });
1127      }
1128    }
1129    window._lastAnalysisData = d;
1130    _hideModuleError('oracle');
1131    if (typeof renderOracle === 'function') renderOracle(d);
1132  } catch(e) {
1133    console.error('[Oracle] Retry failed:', e);
1134    _retryBtnLoading('oracle', false, 'Falha: ' + (e.message || 'erro desconhecido'));
1135  }
1136}
1137
1138// Benchmark/Dashboard não têm prompt *-retry no pipeline original (rodam no background
1139// com analysis-benchmark/analysis-dashboard, que exigem o _commonPromptVars completo).
1140// Para o botão "Refazer", usam prompts dedicados leves (benchmark-retry/dashboard-retry,
1141// migration 20260613) — mesmo padrão dos demais _retry*.
1142// busyMsg: rótulo do loading nos cards. O retry reescreve o benchmark INTEIRO
1143// (ranking, oportunidades, diferencial, força dos concorrentes e SWOT), então todos
1144// eles entram em estado de carregamento — não só o card de onde o usuário clicou.
1145async function _retryBenchmark(busyMsg) {
1146  _retryBtnLoading('benchmark', true);
1147  _benchCardsBusy(true, busyMsg || 'Atualizando benchmark…');
1148  try {
1149    var d = window._lastAnalysisData || {};
1150    var dest = G.dest || '';
1151    var langName = {pt:'Portuguese',en:'English',es:'Spanish',fr:'French'}[G.lang||'pt'] || 'Portuguese';
1152    var now = new Date();
1153    var startDate = new Date(now.getTime() - (parseInt(G.periodo||'30',10)) * 86400000);
1154    var fmtD = function(dt) { return dt.toISOString().split('T')[0]; };
1155    var extraCtx = '';
1156    var _concList = (G.concs && G.concs.length) ? G.concs : (d.concorrentes||[]).filter(function(c){return !c.principal;}).map(function(c){return c.nome;});
1157    if (_concList.length) extraCtx += 'Concorrentes a avaliar (além do próprio destino): ' + _concList.join(', ');
1158    if (d.temas) extraCtx += '\nTemas: ' + d.temas.map(function(t) { return t.nome + ' (' + t.sent + ', ' + t.count + ')'; }).join(', ');
1159    if (d.sentimento) extraCtx += '\nSentimento: positivo ' + d.sentimento.positivo + '%, negativo ' + d.sentimento.negativo + '%';
1160
1161    var meta = await getPromptWithMeta('benchmark-retry', { dest: dest, startDate: fmtD(startDate), now: fmtD(now), periodoNum: G.periodo||'30', langName: langName, extraDataCtx: extraCtx || 'Contexto não disponível' });
1162    if (!meta?.text) throw new Error('Prompt benchmark-retry não encontrado');
1163    var raw = await callAIGlobal(meta.text, 16384, true, meta);
1164    var result = _parseAIJson(raw);
1165    if (!result || !result.concorrentes || result.concorrentes.length === 0) throw new Error('Resposta sem concorrentes');
1166
1167    var fields = ['concorrentes', 'oportunidades', 'n_oportunidades', 'benchmark_narrativa', 'benchmark_competitor_strength', 'diferenciais_competitivos', 'benchmark_swot'];
1168    var supabase = await getSupabase();
1169    var analysisId = sessionStorage.getItem('cp_active_analysis');
1170    for (var i = 0; i < fields.length; i++) {
1171      if (result[fields[i]] != null) {
1172        d[fields[i]] = result[fields[i]];
1173        if (analysisId) await supabase.rpc('merge_analysis_field', { p_analysis_id: analysisId, p_field: fields[i], p_value: JSON.stringify(result[fields[i]]) });
1174      }
1175    }
1176    window._lastAnalysisData = d;
1177    _hideModuleError('benchmark');
1178    if (typeof renderBenchmark === 'function') renderBenchmark(d);
1179  } catch(e) {
1180    console.error('[Benchmark] Retry failed:', e);
1181    _retryBtnLoading('benchmark', false, 'Falha: ' + (e.message || 'erro desconhecido'));
1182  } finally {
1183    _benchCardsBusy(false);
1184  }
1185}
1186
1187async function _retryDashboard() {
1188  _retryBtnLoading('dashboard', true);
1189  try {
1190    var d = window._lastAnalysisData || {};
1191    var dest = G.dest || '';
1192    var langName = {pt:'Portuguese',en:'English',es:'Spanish',fr:'French'}[G.lang||'pt'] || 'Portuguese';
1193    var now = new Date();
1194    var startDate = new Date(now.getTime() - (parseInt(G.periodo||'30',10)) * 86400000);
1195    var fmtD = function(dt) { return dt.toISOString().split('T')[0]; };
1196    var extraCtx = '';
1197    if (d.temas) extraCtx += 'Temas atuais: ' + d.temas.map(function(t) { return t.nome + ' (' + t.sent + ', ' + t.count + ')'; }).join(', ');
1198    if (d.sentimento) extraCtx += '\nSentimento atual: positivo ' + d.sentimento.positivo + '%, neutro ' + d.sentimento.neutro + '%, negativo ' + d.sentimento.negativo + '%';
1199    if (d.crisis_resumo) extraCtx += '\nCrise: ' + d.crisis_resumo;
1200    if (d.turista_origens) extraCtx += '\nOrigens: ' + d.turista_origens.map(function(o) { return o.origem + ' ' + o.pct + '%'; }).join(', ');
1201
1202    var meta = await getPromptWithMeta('dashboard-retry', { dest: dest, startDate: fmtD(startDate), now: fmtD(now), periodoNum: G.periodo||'30', langName: langName, extraDataCtx: extraCtx || 'Contexto não disponível' });
1203    if (!meta?.text) throw new Error('Prompt dashboard-retry não encontrado');
1204    var raw = await callAIGlobal(meta.text, 16384, true, meta);
1205    var result = _parseAIJson(raw);
1206    if (!result || result.sentimento == null) throw new Error('Resposta sem sentimento do dashboard');
1207
1208    // trend_data é dado REAL de busca (Google Trends), não vem do retry → preservar.
1209    var fields = ['score', 'sentimento', 'temas', 'tendencia_busca', 'tendencia_dir', 'n_oportunidades', 'insights'];
1210    var supabase = await getSupabase();
1211    var analysisId = sessionStorage.getItem('cp_active_analysis');
1212    for (var i = 0; i < fields.length; i++) {
1213      if (result[fields[i]] != null) {
1214        d[fields[i]] = result[fields[i]];
1215        if (analysisId) await supabase.rpc('merge_analysis_field', { p_analysis_i
1215d: analysisId, p_field: fields[i], p_value: JSON.stringify(result[fields[i]]) });
1216      }
1217    }
1218    window._lastAnalysisData = d;
1219    _hideModuleError('dashboard');
1220    if (typeof renderDashboard === 'function') renderDashboard(dest, G.concs || [], d);
1221  } catch(e) {
1222    console.error('[Dashboard] Retry failed:', e);
1223    _retryBtnLoading('dashboard', false, 'Falha: ' + (e.message || 'erro desconhecido'));
1224  }
1225}
1226
1227function _isBypassUser() {
1228  // Flag carregada do perfil Supabase (profiles.is_bypass)
1229  if (window._userIsBypass === true) return true;
1230  // Usar estado validado pelo servidor via router (se disponível)
1231  if (window._routerAuthCache && window._routerAuthCache.isBypass) return true;
1232  return false;
1233}
1234
1235// Helper: usuario com acesso APENAS ao modulo compass (configurado pelo admin
1236// em Controle de Features > Por Usuario). Pula onboarding e vai direto pro Compass —
1237// esses usuarios nao criam analises no Pulse. Lê de _routerAuthCache.modules (RPC get_user_modules).
1238function _isCompassOnlyAccess() {
1239  if (_isBypassUser()) return false;
1240  var modules = window._routerAuthCache && window._routerAuthCache.modules;
1241  if (!modules) return false;
1242  var enabled = Object.keys(modules).filter(function (k) { return modules[k] === true; });
1243  return enabled.length === 1 && enabled[0] === 'compass';
1244}
1245
1246// Safe prompt interpolation — replaces {{varName}} with values from vars object
1247// For fallback prompts with JS expressions, uses eval as last resort
1248function interpolatePrompt(template, vars) {
1249  // If template uses {{var}} (database format), do safe replacement
1250  if (template.includes('{{')) {
1251    return template.replace(/\{\{(\w+)\}\}/g, (_, key) => vars[key] !== undefined ? String(vars[key]) : '');
1252  }
1253  // Fallback prompts use ${expr} — need eval for JS expressions like ${fmtDate(startDate)}
1254  // Inject all vars into scope and eval as template literal
1255  try {
1256    const keys = Object.keys(vars);
1257    const values = Object.values(vars);
1258    const fn = new Function(...keys, 'return `' + template.replace(/`/g, '\\`') + '`');
1259    return fn(...values);
1260  } catch(e) {
1261    console.warn('interpolatePrompt eval fallback failed:', e.message);
1262    // Last resort: simple ${var} replacement
1263    return template.replace(/\$\{(\w+)\}/g, (_, key) => vars[key] !== undefined ? String(vars[key]) : '');
1264  }
1265}
1266//   core/component-loader.js — _loadedComponents, loadComponent()
1267// ============================================
1268
1269// ===== NAV =====
1270function toggleMobileSidebar() {
1271  const sb = document.querySelector('.sidebar');
1272  const ov = document.getElementById('sidebar-overlay');
1273  sb.classList.toggle('open');
1274  ov.classList.toggle('active');
1275}
1276
1277async function goPage(p) {
1278  // 'loading' é estado transitório da UI, não uma rota — nunca delegar ao router
1279  if (p !== 'loading' && window.AppRouter && !window._routerNavigating) {
1280    window.AppRouter.navigateToPage(p);
1281    return;
1282  }
1283
1284  // Onboarding pages são gerenciadas pelo router, não pelo sistema de pages
1285  if (p.startsWith('onboarding')) {
1286    // Modo onboarding: esconder shell do app via CSS class
1287    document.body.classList.add('onboarding');
1288    document.body.classList.remove('loading-screen');
1289    const sidebar = document.querySelector('.sidebar');
1290    const topbar  = document.querySelector('.topbar');
1291    const content = document.querySelector('.content');
1292    if (sidebar) sidebar.style.removeProperty('display');
1293    if (topbar)  topbar.style.removeProperty('display');
1294    if (content) { content.style.removeProperty('display'); content.style.removeProperty('margin-left'); }
1295    return;
1296  }
1297
1298  // Fecha sidebar mobile na navegação
1299  const sb = document.querySelector('.sidebar');
1300  const ov = document.getElementById('sidebar-overlay');
1301  if (sb) sb.classList.remove('open');
1302  if (ov) ov.classList.remove('active');
1303
1304  document.querySelectorAll('.page').forEach(x => x.classList.remove('active'));
1305  const pg = document.getElementById('page-' + p);
1306  if (!pg) return;
1307
1308  // Lazy load do componente na primeira visita (ComponentLoader)
1309  await loadComponent(p);
1310  pg.classList.add('active');
1311
1312  // Visibilidade de sidebar/topbar/content
1313  const sidebar = document.querySelector('.sidebar');
1314  const topbar  = document.querySelector('.topbar');
1315  const content = document.querySelector('.content');
1316  if (p === 'loading') {
1317    document.body.classList.add('loading-screen');
1318    if (sidebar) sidebar.style.removeProperty('display');
1319    if (topbar)  topbar.style.removeProperty('display');
1320    if (content) { content.style.removeProperty('display'); content.style.removeProperty('margin-left'); }
1321  } else {
1322    document.body.classList.remove('loading-screen');
1323    document.body.classList.remove('onboarding');
1324    if (topbar)  topbar.style.display = 'flex';
1325    if (sidebar) sidebar.style.display = 'flex';
1326    if (content) { content.style.display = 'block'; content.style.marginLeft = '220px'; }
1327  }
1328
1329  // Atualiza item ativo no menu
1330  document.querySelectorAll('.nav-item').forEach(x => x.classList.remove('active'));
1331  document.querySelectorAll(`[data-page="${p}"]`).forEach(ni => {
1332    if (ni.id === 'nav-oracle') return;
1333    ni.classList.add('active');
1334  });
1335
1336  // Hook pós-carregamento: chama funções de dados após componente no DOM
1337  if (p === 'history') loadHistoryFromSupabase();
1338  if (p === 'benchmark' && typeof loadSavedBenchmark === 'function') loadSavedBenchmark();
1339  if (p === 'can' && typeof loadSavedCanPlan === 'function') loadSavedCanPlan();
1340  // 'gemeo' não faz parte do RENDER_PAGES pré-carregado no startAnalysis (só carrega sob
1341  // demanda aqui) — sem isto os data-i18n do fragmento ficam presos no default PT-BR, e
1342  // os badges de dados do Pulse nunca pintam (o DOMContentLoaded do gemeo.js já passou
1343  // muito antes do fragmento existir no DOM, então renderPulseBadges() nunca roda sozinho).
1344  if (p === 'gemeo') {
1345    if (typeof applyI18n === 'function') applyI18n();
1346    if (typeof renderPulseBadges === 'function') renderPulseBadges();
1347  }
1348  // 'simulator' também é carregado sob demanda (fora do RENDER_PAGES) — mesmo motivo do gemeo acima.
1349  if (p === 'simulator' && typeof applyI18n === 'function') applyI18n();
1350  if (p === 'user-plan') {
1351    if (typeof applyI18n === 'function') applyI18n();
1352    if (typeof loadUserPlanPage === 'function') loadUserPlanPage();
1353  }
1354  // Módulos básicos: ao abrir, renderiza o estado atual (dados / spinner se o
1355  // background ainda carrega / erro). A carga real roda em background no pipeline.
1356  if ((p === 'crisis' || p === 'profile' || p === 'benchmark') && typeof _ensureBasicModule === 'function') _ensureBasicModule(p);
1357
1358  // Botão padrão "Refazer análise" no canto do módulo (rede de segurança ao navegar;
1359  // o injetor real fica no fim de cada renderer). Idempotente e gated por "tem dados".
1360  if (typeof _ensureRedoButton === 'function') _ensureRedoButton(p);
1361
1362  // Section info in topbar for premium pages
1363  const sectionMap = {
1364    'felicidade': { icon:'😊', title:t('sec_title_felicidade'), sub:t('sec_sub_felicidade'), color:'var(--yellow)' },
1365    'gemeo':      { icon:'🌐', title:t('sec_title_gemeo'),      sub:t('sec_sub_gemeo'),      color:'var(--blue)' },
1366    'can':        { icon:'💛', title:t('sec_title_can'),        sub:t('sec_sub_can'),        color:'var(--magenta)' },
1367    'sinc':       { icon:'🔮', title:t('sec_title_sinc'),       sub:t('sec_sub_sinc'),       color:'var(--accent)' },
1368    'emissores':  { icon:'🌍', title:t('sec_title_emissores'),  sub:t('sec_sub_emissores'),  color:'var(--blue)' },
1369    'viral':      { icon:'🚀', title:t('sec_title_viral'),      sub:t('sec_sub_viral'),      color:'var(--accent)' },
1370    'calor':      { icon:'🗺️', title:t('sec_title_calor'),      sub:t('sec_sub_calor'),      color:'var(--accent)' },
1371    'radar':      { icon:'📡', title:t('sec_title_radar'),      sub:t('sec_sub_radar'),      color:'var(--accent)' },
1372    'simulador':  { icon:'🧪', title:t('sec_title_simulador'),  sub:t('sec_sub_simulador'),  color:'var(--blue)' },
1373    'campaign':   { icon:'🎯', title:t('sec_title_campaign'),   sub:t('sec_sub_campaign'),   color:'var(--accent)' },
1374    'projecao':   { icon:'📈', title:t('sec_title_projecao'),   sub:t('sec_sub_projecao'),   color:'var(--accent)' },
1375    'calendario': { icon:'📅', title:t('sec_title_calendario'), sub:t('sec_sub_calendario'), color:'var(--accent)' },
1376    'publicmax':  { icon:'🏛️', title:t('sec_title_publicmax'),  sub:t('sec_sub_publicmax'),  color:'var(--blue)' },
1377    'termometro': { icon:'🌡️', title:t('sec_title_termometro'), sub:t('sec_sub_termometro'), color:'var(--accent)' },
1378    'compass':    { icon:'🧭', title:t('sec_title_compass'),    sub:t('sec_sub_compass'),    color:'var(--cyan)' },
1379    'validador':  { icon:'✅', title:t('sec_title_validador'),  sub:t('sec_sub_validador'),  color:'var(--blue)' },
1380  };
1381  const si = document.getElementById('topbar-section-info');
1382  const siIcon  = document.getElementById('topbar-section-icon');
1383  const siTitle = document.getElementById('topbar-section-title');
1384  const siSub   = document.getElementById('topbar-section-sub');
1385  if (si && siIcon && siTitle && siSub) {
1386    const sm = sectionMap[p];
1387    if (sm) {
1388      siIcon.textContent = sm.icon;
1389      siTitle.textContent = sm.title;
1390      siTitle.style.color = sm.color;
1391      siSub.textContent = sm.sub;
1392      si.style.display = 'flex';
1393    } else {
1394      si.style.display = 'none';
1395    }
1396  }
1397  // Inicializações de componentes que têm loaders externos (já tratados no ComponentLoader)
1398  // campaign e oracle são self-loaded — seus init são chamados pelo próprio componente
1399  // Notificar o router que a página renderizou
1400  if (window.AppRouter && window._routerNavigating) {
1401    window.AppRouter._onPageRendered(p);
1402  }
1403
1404  // Scroll to top — .content is the scroll container
1405  requestAnimationFrame(() => {
1406    const contentEl = document.querySelector('.content');
1407    if (contentEl) contentEl.scrollTop = 0;
1408    else { document.documentElement.scrollTop = 0; document.body.scrollTop = 0; }
1409  });
1410}
1411
1412// ===== ANALYSIS =====
1413async function startAnalysis(skipLimitCheck = false) {
1414  // Verificar limite de análises do plano
1415  if (!skipLimitCheck) {
1416    const limitCheck = await checkAnalysisLimit();
1417    if (!limitCheck.allowed) {
1418      showAnalysisLimitAlert(limitCheck.used, limitCheck.limit, limitCheck.plan);
1419      resetToOnboarding();
1420      return;
1421    }
1422  }
1423
1424  // Garantir que as API keys estão no localStorage
1425  provisionApiKeys();
1426  // If G.dest already set (from onboarding), use it directly
1427  let dest = G.dest || '';
1428  if (!dest) {
1429    const pais   = document.getElementById('f-pais')?.value.trim()   || '';
1430    const estado = document.getElementById('f-estado')?.value.trim() || '';
1431    const cidade = document.getElementById('f-cidade')?.value.trim() || '';
1432    if (!cidade) return alert(G.lang === 'en' ? 'Please enter the city/place name.' : 'Informe o nome da cidade ou local.');
1433    dest = [cidade, estado, pais].filter(Boolean).join(', ');
1434  }
1435
1436  // Carregar influenciadores curados do Supabase para a região do destino
1437  await loadCuratedInfluencersForDest(dest);
1438  const fDestinoEl = document.getElementById('f-destino');
1439  if (fDestinoEl) fDestinoEl.value = dest;
1440  const apiKey = 'managed-server-side'; // Chaves gerenciadas via Edge Function
1441  console.log('[Analysis] Provider:', G.provider, '| Keys managed server-side');
1442  console.log('[Analysis] Dest:', dest);
1443  // Handle uploaded data
1444  G.uploadedData = await readUploadedFile();
1445
1446  G.dest = _deduplicateDest(dest);
1447  G.apiKey = apiKey;
1448  // Safe reads — elements may not exist when called from onboarding
1449  const c1 = document.getElementById('f-c1')?.value?.trim() || '';
1450  const c2 = document.getElementById('f-c2')?.value?.trim() || '';
1451  const c3 = document.getElementById('f-c3')?.value?.trim() || '';
1452  // Prefer G.concs from onboarding if already set
1453  const concs = (G.concs && G.concs.length > 0)
1454    ? G.concs
1455    : [c1, c2, c3].filter(Boolean);
1456  // Prefer G.periodo from onboarding if already set
1457  const periodo = G.periodo || document.getElementById('f-periodo')?.value || '30';
1458  const foco = (typeof OB !== 'undefined' && OB.foco)
1459    || document.getElementById('f-foco')?.value || 'geral';
1460  G.concs = concs;
1461  G.periodo = periodo;
1462
1463  // Sem tela de loading cheia: vai DIRETO pro painel e mostra o spinner de módulo
1464  // no dashboard enquanto o analysis-dashboard (+benchmark) roda. crisis/profile
1465  // carregam sob demanda quando o usuário abre o módulo no menu.
1466  await _enterDashboardLoading();
1467  // Atualiza o destino no topbar JÁ no loading — senão fica exibindo o destino da análise anterior.
1468  var _topDestEl = document.getElementById('topbar-dest'); if (_topDestEl) _topDestEl.textContent = dest;
1469
1470  try {
1471    // ===== LIMPAR DADOS DA ANÁLISE ANTERIOR antes de iniciar nova =====
1472    // O ponteiro da análise ativa PRECISA morrer aqui. Enquanto ele apontava pra
1473    // análise anterior, qualquer módulo de background ainda em voo (ou terminando
1474    // agora) gravava na cidade errada via merge_analysis_field — era a causa-raiz da
1475    // contaminação entre municípios. Mesmo motivo do resetToOnboarding (que faz isso
1476    // desde sempre, mas só é chamado em erro, nunca no caminho feliz).
1477    sessionStorage.removeItem('cp_active_analysis');
1478    // Aborta JÁ o background da análise anterior. callClaude incrementa de novo ao
1479    // iniciar o dele (o token do bg é capturado depois), mas até aqui o background
1480    // antigo se considerava válido e seguia mesclando/gravando.
1481    window._analysisRunToken = (window._analysisRunToken || 0) + 1;
1482    G.data = null;
1483    G.lastData = null;
1484    window._lastAnalysisData = null;
1485    window._benchmarkDigital = null;  // SEO/PageSpeed do benchmark anterior (evita vazar no export)
1486    if (typeof resetCanActionPlan === 'function') resetCanActionPlan();  // plano do Can não vaza p/ a análise nova
1487    if (typeof resetGemeoSimulation === 'function') resetGemeoSimulation();  // simulação/histórico do Gêmeo não vazam p/ a análise nova
1488    // Já marca crise/perfil/benchmark como "carregando" ANTES do callClaude, p/ que
1489    // abrir esses módulos enquanto o dashboard roda mostre spinner (não erro).
1490    // callClaude re-seta este objeto (+ dashInsights) ao iniciar o background.
1491    window._bgLoading = { crisis: true, profile: true, benchmark: true };
1492    window._autoLoadAttempted = {};
1493    window._ctaAttempted = {};        // CTA sob demanda dos módulos premium/oracle
1494    oracleDebateHistory = [];
1495    oracleChatHistory = [];
1496    oracleAnalysisCtx = '';
1497    canChatHistory = [];
1498    // =================================================================
1499
1500    const result = await callClaude(apiKey, dest, concs, periodo, foco, G.sector, G.lang, G.provider, G.uploadedData);
1501    G.data = result;
1502    G.lastData = result;
1503    _hideModuleError('dashboard');   // remove o spinner ANTES do renderAll (Chart.js precisa do canvas visível)
1504    await renderAll(dest, concs, result, periodo);
1505    // ===== Show nav items based on user plan =====
1506    const navPlan = getUserPlan();
1507    const navUnlockPremium = (navPlan === 'premium' || navPlan === 'oracle' || navPlan === 'enterprise');
1508    const navUnlockOracle = (navPlan === 'oracle' || navPlan === 'enterprise');
1509    const basicNavs = ['dashboard','crisis','profile','benchmark','studio'];
1510    const premiumNavs = ['heatmap','influence','viral','simulator','social','campaign'];
1511    const oracleNavs = ['emissores','oracle','oracle-main','projecao','calendario','publicmax','felicidade','gemeo','can','sinc','compass'];
1512    basicNavs.forEach(p => { const el = document.getElementById('nav-' + p); if (el) el.style.display = 'flex'; });
1513    if (navUnlockPremium) premiumNavs.forEach(p => { const el = document.getElementById('nav-' + p); if (el) el.style.display = 'flex'; });
1514    if (navUnlockOracle) oracleNavs.forEach(p => { const el = document.getElementById('nav-' + p); if (el) el.style.display = 'flex'; });
1515    document.getElementById('btn-docs').style.display = 'flex';
1516    document.getElementById('btn-export').style.display = 'flex';
1517    document.getElementById('btn-report').style.display = 'flex';
1518    document.getElementById('btn-export').onclick = () => exportCurrentSection();
1519    // Dashboard tem dados → libera premium/oracle do estado "carregando".
1520    _setUpsellNavLoading(false);
1521    // Aplicar cadeados e overrides por usuário DEPOIS de mostrar tudo
1522    enforceMenuByPlan();
1523    document.getElementById('topbar-dest').textContent = dest;
1524    document.getElementById('topbar-badge').style.display = 'inline-flex';
1525    document.getElementById('topbar-badge').textContent = t('badge_live');
1526    if (result.crisis_level === 'high') document.getElementById('crisis-count').style.display = 'flex';
1527    applyI18n();
1528    // Salvar automaticamente no Supabase
1529    saveAnalysisToSupabase();
1530    goPage('dashboard');
1531  } catch(err) {
1532    console.error('[startAnalysis] Full error:', err);
1533    const errMsg = err.message || 'Erro desconhecido';
1534    const isFailedFetch = errMsg.toLowerCase().includes('fetch') || errMsg.toLowerCase().includes('network');
1535    if (isFailedFetch) {
1536      const msg = G.lang === 'pt'
1537        ? `⚠️ Erro de conexão com a API.\n\nDetalhes: ${errMsg}\n\nVerifique:\n• Sua API Key está correta e tem créditos?\n• Sua conexão com a internet está ok?\n• Tente trocar o provedor de IA nas configurações.`
1538        : `⚠️ API connection error.\n\nDetails: ${errMsg}\n\nCheck:\n• Is your API Key correct with credits?\n• Is your internet working?\n• Try switching AI provider.`;
1539      alert(msg);
1540    } else {
1541      alert('Error: ' + errMsg + '\n\nCheck browser console (F12) for details.');
1542    }
1543    G.dest = ''; G.periodo = ''; G.concs = [];
1544    try { _setUpsellNavLoading(false); } catch (_e) {}
1545    resetToOnboarding();
1546  }
1547}
1548
1549// ===== ATRATIVOS — lista canônica do destino (Google Places: admin OU fallback Apify) =====
1550// resolveAttractions(dest): a fonte da lista de atrativos. Prioridade:
1551//   1) Curadoria do admin (configured_cities + city_attractions) — lista travada por cidade;
1552//   2) Fallback automático: busca focada em atrativos no Google Places (fetchAttractions, apify.js).
1553// Sempre tolerante a falha: em erro / sem dados, retorna [] → a análise cai no fluxo antigo
1554// (a IA descobre 6-8 lugares). Os atrativos viram heatmap_areas; a IA NÃO inventa a lista.
1555async function resolveAttractions(dest) {
1556  try {
1557    if (!dest) return [];
1558    const removeAccents = (s) => (s || '').normalize('NFD').replace(/[\u0300-\u036f]/g, '');
1559    const parts = dest.split(',').map((s) => s.trim());
1560    const cityNorm = removeAccents(parts[0].toLowerCase());
1561    if (!cityNorm) return [];
1562    // UF opcional no destino (ex.: "Bom Jardim, PE") desambigua cidades hom\u00f4nimas.
1563    const ufGuess = (parts[1] || '').toUpperCase().replace(/[^A-Z]/g, '').slice(0, 2);
1564    const supabase = await getSupabase();
1565
1566    // 1) Curadoria do admin
1567    try {
1568      const { data: cities } = await supabase
1569        .from('configured_cities')
1570        .select('id, uf')
1571        .eq('city_norm', cityNorm)
1572        .eq('active', true);
1573      if (cities && cities.length > 0) {
1574        let chosen = cities[0];
1575        if (ufGuess) {
1576          const m = cities.find((c) => (c.uf || '').toUpperCase() === ufGuess);
1577          if (m) chosen = m;
1578        }
1579        const { data: attrs } = await supabase
1580          .from('city_attractions')
1581          .select('title, name, total_score, reviews_count, category, street, reviews')
1582          .eq('city_id', chosen.id)
1583          .eq('active', true)
1584          .order('position', { ascending: true });
1585        if (attrs && attrs.length > 0) {
1586          console.log(`[Atrativos] ✅ ${attrs.length} CURADOS (admin) para "${cityNorm}"`);
1587          return attrs.map(a => ({
1588            title: a.title || a.name, total_score: a.total_score, reviews_count: a.reviews_count,
1589            category: a.category, street: a.street, reviews: a.reviews || [], source: 'admin'
1590          }));
1591        }
1592      }
1593    } catch (e) { console.warn('[Atrativos] lookup admin falhou:', e.message); }
1594
1595    // 2) Fallback: Google Places focado em atrativos (apify.js)
1596    if (typeof fetchAttractions === 'function') {
1597      const auto = await fetchAttractions(dest);
1598      if (auto && auto.length > 0) {
1599        console.log(`[Atrativos] ✅ ${auto.length} AUTO (Google Places) para "${cityNorm}"`);
1600        return auto.map(a => Object.assign({ source: 'auto' }, a));
1601      }
1602    }
1603    return [];
1604  } catch (e) {
1605    console.warn('[Atrativos] resolveAttractions falhou:', e.message);
1606    return [];
1607  }
1608}
1609
1610// Monta o bloco de texto injetado nos prompts. Lista vazia → '' (placeholder some → fluxo antigo).
1611function buildAtrativosFixosCtx(attractions) {
1612  if (!attractions || attractions.length === 0) return '';
1613  const lines = attractions.map((a, i) => {
1614    const score = (a.total_score != null && a.total_score !== 0) ? `⭐${a.total_score}` : 's/ nota';
1615    const rev = a.reviews_count ? `${a.reviews_count} avaliações` : 'sem volume';
1616    const cat = a.category ? ` — ${a.category}` : '';
1617    const sample = (a.reviews || []).slice(0, 2)
1618      .map(r => `"${String(r.text || '').slice(0, 140)}" (${r.stars || '?'}★)`).join(' ');
1619    return `${i + 1}) ${a.title}${cat} — ${score} (${rev})${sample ? '\n   reviews: ' + sample : ''}`;
1620  }).join('\n');
1621  return `\n\n📍 ATRATIVOS (LISTA CANÔNICA E TRAVADA — fonte: Google Places):\n` +
1622    `ESTA LISTA SUBSTITUI qualquer instrução anterior de "descobrir/identificar 6-8 lugares". ` +
1623    `Analise EXCLUSIVAMENTE os atrativos abaixo. NÃO adicione, NÃO invente e NÃO remova lugares. ` +
1624    `Gere UMA entrada de heatmap_areas para CADA atrativo, NESTA ORDEM, inferindo ` +
1625    `sentimento/score (0-100), tendência e fator principal a partir dos reviews e dos dados coletados:\n` +
1626    lines;
1627}
1628
1629async function callClaude(apiKey, dest, concs, periodo, foco, sector, lang, provider, uploadedData) {
1630  const concStr = concs.length > 0 ? concs.join(', ') : 'none';
1631  
1632  // Build enriched competitor context with IG, site, type
1633  let concEnrichedCtx = '';
1634  if (G.concsData && G.concsData.length > 0) {
1635    concEnrichedCtx = '\n\n📊 COMPETITOR ENRICHED DATA:\n' +
1636      (G.empresaIG || G.empresaSite ? `YOUR BUSINESS: ${dest} | IG: ${G.empresaIG || 'n/a'} | Site: ${G.empresaSite || 'n/a'}\n` : '') +
1637      G.concsData.map((c, i) => 
1638        `COMPETITOR ${i+1}: ${c.nome} | Type: ${c.tipo === 'geografico' ? 'GEOGRAPHIC (nearby, may differ in audience)' : 'DIRECT (same product/price/audience)'} | IG: ${c.ig || 'n/a'} | Site: ${c.site || 'n/a'}`
1639      ).join('\n') +
1640      '\nIMPORTANT: Use competitor type (DIRECT vs GEOGRAPHIC) to contextualize your analysis. Direct competitors are the main threat. Geographic competitors share foot traffic but may serve different audiences.';
1641  }
1642  const macroContextStr = macroGetContextString();
1643  const sectorCtx = sectorContexts[sector]?.[lang] || sectorContexts[sector]?.en || sector;
1644  const langName = {pt:'Portuguese',en:'English',es:'Spanish',fr:'French'}[lang] || 'English';
1645
1646  async function callAI(prompt, maxTok, meta, attempt) {
1647    // Backward compat: 3º arg podia ser número (attempt) na assinatura antiga.
1648    if (typeof meta === 'number') { attempt = meta; meta = null; }
1649    attempt = attempt || 1;
1650    meta = meta || {};
1651    const _aiStart = Date.now();
1652    let _aiRawResponse = '';
1653    let _aiParsed = false;
1654    let _aiSlug = '';
1655    const _usage = {};
1656    // Detect slug: prefer meta.slug (set by getPromptWithMeta), fallback to content matching
1657    if (meta?.slug) _aiSlug = meta.slug;
1658    else if (prompt.includes('REALISTIC analysis')) _aiSlug = 'analysis-main';
1659    else if (prompt.includes('Premium analytics')) _aiSlug = 'analysis-premium';
1660    else if (prompt.includes('Oracle intelligence')) _aiSlug = 'analysis-oracle';
1661    else if (prompt.includes('Market intelligence')) _aiSlug = 'analysis-markets';
1662    else if (prompt.includes('Social radar analysis')) _aiSlug = 'analysis-social';
1663
1664    // RAG (docs/domains/rag/adrs/adr-005.md): quando o prompt é doc-grounded
1665    // e há arquivo(s) indexado(s) da análise, encaminha file_ids+retrieval_query
1666    // pro ai-analyze — a recuperação roda lá (chokepoint único), sem rota especial.
1667    const _ragFileIds = (meta.rag_enabled === true && _agentFileIds && _agentFileIds.length > 0)
1668      ? _agentFileIds
1669      : undefined;
1670    const _ragQuery = _ragFileIds ? (meta.rag_vocabulary_hints || '') : undefined;
1671
1672    try {
1673      let txt = await callAISecure(((meta && meta.model_provider) || provider), prompt, maxTok, true, (meta && meta.model_api_id) || undefined, _usage, _ragFileIds, _ragQuery);
1674      _aiRawResponse = txt || '';
1675      
1676      // Clean and repair JSON
1677      if (!txt) throw new Error('Empty response from AI — check your API key and quota.');
1678      let cleaned = txt.replace(/```json|```/g,'').trim();
1679      
1680      // Extract JSON object/array from possible surrounding text
1681      const jStart = cleaned.indexOf('{') <= (cleaned.indexOf('[') === -1 ? Infinity : cleaned.indexOf('[')) ? cleaned.indexOf('{') : cleaned.indexOf('[');
1682      const isObj = cleaned[jStart] === '{';
1683      const jEnd = isObj ? cleaned.lastIndexOf('}') : cleaned.lastIndexOf(']');
1684      if (jStart >= 0 && jEnd > jStart) cleaned = cleaned.substring(jStart, jEnd + 1);
1685      
1686      // Attempt parse — if fails, try to repair then retry once.
1687      try {
1688        const result = JSON.parse(cleaned);
1689        _aiParsed = true;
1690        logPromptCall({ slug: _aiSlug, provider: _usage.provider || (meta && meta.model_provider) || provider, model: _usage.model || ((meta && meta.model_api_id) || null), tokensInput: _usage.tokensInput, tokensOutput: _usage.tokensOutput, promptSent: prompt, responseRaw: _aiRawResponse, responseParsed: true, durationMs: Date.now() - _aiStart, success: true, ...(_usage.rag ? { apisDataSummary: Object.assign({}, _currentApisData, { rag: Object.assign({}, _usage.rag, { document_grounded: result.document_grounded }) }) } : {}) });
1691        return result;
1692      } catch(parseErr) {
1693        // Try JSON repair: close unclosed structures
1694        const repaired = repairJSON(cleaned);
1695        try {
1696          const result = JSON.parse(repaired);
1697          _aiParsed = true;
1698          logPromptCall({ slug: _aiSlug, provider: _usage.provider || (meta && meta.model_provider) || provider, model: _usage.model || ((meta && meta.model_api_id) || null), tokensInput: _usage.tokensInput, tokensOutput: _usage.tokensOutput, promptSent: prompt, responseRaw: _aiRawResponse, responseParsed: true, durationMs: Date.now() - _aiStart, success: true, ...(_usage.rag ? { apisDataSummary: Object.assign({}, _currentApisData, { rag: Object.assign({}, _usage.rag, { document_grounded: result.document_grounded }) }) } : {}) });
1699          return result;
1700        } catch(e2) {
1701          // Retry once with shorter, simpler prompt instruction
1702          if (attempt < 2) {
1703            console.warn('JSON truncated, retrying...');
1704            const shortPrompt = prompt + '\n\nCRITICAL: Your previous response was truncated. Respond with SHORTER field values but COMPLETE valid JSON. Close all brackets.';
1705            return callAI(shortPrompt, maxTok, meta, attempt + 1);
1706          }
1707          logPromptCall({ slug: _aiSlug, provider: _usage.provider || (meta && meta.model_provider) || provider, model: _usage.model || ((meta && meta.model_api_id) || null), tokensInput: _usage.tokensInput, tokensOutput: _usage.tokensOutput, promptSent: prompt, responseRaw: _aiRawResponse, responseParsed: false, durationMs: Date.now() - _aiStart, success: false, errorMessage: 'JSON parse failed after repair' });
1708          throw new Error('Erro ao processar resposta da IA. Tente novamente ou use outro provedor.');
1709        }
1710      }
1711    } catch(error) {
1712      // Retry automatico para resposta vazia (transiente da LLM).
1713      if (error.code === 'EMPTY_RESPONSE' && attempt < 2) {
1714        logPromptCall({
1715          slug: _aiSlug, provider: _usage.provider || (meta && meta.model_provider) || provider, promptSent: prompt, responseRaw: '',
1716          responseParsed: false, durationMs: Date.now() - _aiStart,
1717          success: false, errorMessage: error.message,
1718          attempt: attempt, retryReason: 'empty_response'
1719        });
1720        console.warn('[callAI] Empty response — retrying ' + (attempt + 1) + '/2');
1721        await new Promise(function(r) { setTimeout(r, 1500); });
1722        return callAI(prompt, maxTok, meta, attempt + 1);
1723      }
1724
1725      if (!_aiParsed) {
1726        logPromptCall({ slug: _aiSlug, provider: _usage.provider || (meta && meta.model_provider) || provider, model: _usage.model || ((meta && meta.model_api_id) || null), tokensInput: _usage.tokensInput, tokensOutput: _usage.tokensOutput, promptSent: prompt, responseRaw: _aiRawResponse, responseParsed: false, durationMs: Date.now() - _aiStart, success: false, errorMessage: error.message, attempt: attempt });
1727      }
1728      console.error('❌ callAI error:', error);
1729      throw error;
1730    }
1731  }
1732
1733  // JSON repair utility: closes unclosed strings, arrays, objects
1734  function repairJSON(txt) {
1735    // Remove trailing comma before closing brackets
1736    let s = txt.replace(/,\s*([}\]])/g, '$1');
1737    // Count open structures
1738    let inStr = false, escape = false;
1739    let opens = [];
1740    for (let i = 0; i < s.length; i++) {
1741      const c = s[i];
1742      if (escape) { escape = false; continue; }
1743      if (c === '\\') { escape = true; continue; }
1744      if (c === '"' && !inStr) { inStr = true; continue; }
1745      if (c === '"' && inStr) { inStr = false; continue; }
1746      if (inStr) continue;
1747      if (c === '{') opens.push('}');
1748      else if (c === '[') opens.push(']');
1749      else if (c === '}' || c === ']') opens.pop();
1750    }
1751    // Close unclosed string if needed
1752    if (inStr) s += '"';
1753    // Close all open structures in reverse order
1754    s += opens.reverse().join('');
1755    return s;
1756  }
1757
1758  // RAG (docs/domains/rag/adrs/adr-005.md) substitui o dump inline do
1759  // documento: o texto chega via o bloco de evidência que o ai-analyze
1760  // monta a partir dos trechos recuperados, não mais concatenado aqui.
1761  const uploadCtx = '';
1762  
1763  // Fetch city profile from previous analysis
1764  let cityProfileCtx = '';
1765  try {
1766    const supabase = await getSupabase();
1767    const { data: { session } } = await supabase.auth.getSession();
1768    if (session) {
1769      const { data: prevAnalysis } = await supabase
1770        .from('analysis_history')
1771        .select('analysis_data, created_at')
1772        .eq('user_id', session.user.id)
1773        .eq('destination', dest)
1774        .order('created_at', { ascending: false })
1775        .limit(1)
1776        .maybeSingle();
1777      
1778      if (prevAnalysis && prevAnalysis.analysis_data) {
1779        const pa = prevAnalysis.analysis_data;
1780        const paDate = new Date(prevAnalysis.created_at).toLocaleDateString('pt-BR');
1781        const parts = [];
1782        if (pa.score) parts.push(`Reputation Score: ${pa.score}/100`);
1783        if (pa.sentimento) parts.push(`Sentiment: ${pa.sentimento.positivo||0}% positive, ${pa.sentimento.neutro||0}% neutral, ${pa.sentimento.negativo||0}% negative`);
1784        if (pa.temas && pa.temas.length) parts.push(`Top themes: ${pa.temas.slice(0,6).map(t=>t.nome+(t.sent==='neg'?' (negative)':'')).join(', ')}`);
1785        if (pa.crisis_level) parts.push(`Crisis level: ${pa.crisis_level}`);
1786        if (pa.crisis_indicators && pa.crisis_indicators.length) parts.push(`Active crises: ${pa.crisis_indicators.slice(0,3).map(c=>c.tema+' ('+c.delta+')').join(', ')}`);
1787        if (pa.turista_perfil) {
1788          const tp = pa.turista_perfil;
1789          parts.push(`Tourist profile: age ${tp.faixa_etaria||'?'}, avg spend ${tp.ticket_medio||'?'}, stay ${tp.estadia_media||'?'}, dominant ${tp.perfil_dominante||'?'}`);
1790        }
1791        if (pa.turista_origens && pa.turista_origens.length) parts.push(`Top origins: ${pa.turista_origens.slice(0,4).map(o=>o.origem+' '+o.pct+'%').join(', ')}`);
1792        if (pa.concorrentes && pa.concorrentes.length) parts.push(`Competitor scores: ${pa.concorrentes.slice(0,4).map(c=>c.nome+' '+c.score).join(', ')}`);
1793        if (parts.length > 0) {
1794          cityProfileCtx = `\n\n📊 CITY PROFILE (from previous analysis on ${paDate}):\n${parts.join('\n')}\nUse this as baseline context. Compare current findings against these benchmarks. Highlight what CHANGED since last analysis.`;
1795          console.log('[Pulse] City profile loaded from previous analysis');
1796        }
1797      }
1798    }
1799  } catch(e) { console.warn('Could not load city profile:', e); }
1800
1801  // ===== START PROMPT LOGGING =====
1802  promptLogStartAnalysis();
1803
1804  // ===== RAG: resolver file_ids ativos UMA vez =====
1805  // Usado pelos prompts com rag_enabled=true. [] se não há doc indexed.
1806  // (docs/domains/rag/adrs/adr-004.md — generalizado de file_id único p/ array)
1807  let _agentFileIds = [];
1808  try {
1809    if (typeof getActiveAnalysisFileIds === 'function') {
1810      _agentFileIds = await getActiveAnalysisFileIds();
1811      if (_agentFileIds.length > 0) console.log('[Pulse] RAG file_ids resolvidos:', _agentFileIds.map(id => id.slice(0, 8) + '…').join(', '));
1812    }
1813  } catch (_eRag) { /* fallback silencioso para fluxo legado */ }
1814
1815  // Fetch real-time data from ALL configured APIs in parallel
1816  let serpCtx = '';
1817  let exchangeCtx = '';
1818  let exchangeRates = null;
1819  let extraDataCtx = '';
1820  // Resolve a lista canônica de atrativos (admin → Google Places) em paralelo com a coleta de dados.
1821  const _attractionsPromise = (typeof resolveAttractions === 'function') ? resolveAttractions(dest) : Promise.resolve([]);
1822  let batch3Promise = null;   // Lote 3 (Apify/TripAdvisor) — deferido p/ não bloquear o dashboard
1823  // Tracking flags para saber quais APIs retornaram dados (usado no failedApisCtx)
1824  let _apiOk = { serp: false, exchange: false, apify: false, news: false, places: false, trends: false, trip: false, tavily: false, brave: false };
1825
1826  // Retry wrapper: tenta 1x, se falhar espera 2s e tenta de novo
1827  async function withRetry(fn, label) {
1828    try {
1829      var result = await fn();
1830      if (result) return result;
1831    } catch(e) { console.warn('[Retry] ' + label + ' falhou na 1ª tentativa:', e.message); }
1832    // Retry após 2s
1833    await new Promise(function(r) { setTimeout(r, 2000); });
1834    try {
1835      var result2 = await fn();
1836      if (result2) { console.log('[Retry] ✅ ' + label + ' sucesso na 2ª tentativa'); return result2; }
1837    } catch(e2) { console.warn('[Retry] ❌ ' + label + ' falhou na 2ª tentativa:', e2.message); }
1838    return null;
1839  }
1840
1841  try {
1842    // ===== LOTE 1: APIs leves (1-5s) =====
1843    console.log('[Pipeline] 🔄 Lote 1: SerpAPI, ExchangeRate, NewsAPI, GoogleTrends...');
1844    const batch1 = await Promise.allSettled([
1845      withRetry(function() { return fetchSerpData(dest, sector); }, 'SerpAPI'),
1846      withRetry(function() { return fetchExchangeRates(); }, 'ExchangeRate'),
1847      withRetry(function() { return typeof fetchNewsData === 'function' ? fetchNewsData(dest) : null; }, 'NewsAPI'),
1848      withRetry(function() { return typeof fetchGoogleTrends === 'function' ? fetchGoogleTrends(dest) : null; }, 'GoogleTrends'),
1849    ]);
1850
1851    // ===== LOTE 2: APIs médias (5-15s) =====
1852    console.log('[Pipeline] 🔄 Lote 2: Places, Tavily, Brave...');
1853    const batch2 = await Promise.allSettled([
1854      withRetry(function() { return typeof fetchPlacesData === 'function' ? fetchPlacesData(dest) : null; }, 'Places'),
1855      withRetry(function() { return typeof fetchTavilyData === 'function' ? fetchTavilyData(dest) : null; }, 'Tavily'),
1856      withRetry(function() { return typeof fetchBraveData === 'function' ? fetchBraveData(dest) : null; }, 'Brave'),
1857    ]);
1858
1859    // ===== LOTE 3: APIs pesadas (Apify/TripAdvisor — dezenas de segundos) =====
1860    // DEFERIDO: NÃO bloqueia o 1º paint do dashboard. Resolve em background e
1861    // alimenta crise/perfil/benchmark (que dependem de reviews/IG/Maps).
1862    console.log('[Pipeline] 🔄 Lote 3 (deferido — não bloqueia o dashboard): Apify, TripAdvisor...');
1863    batch3Promise = Promise.allSettled([
1864      withRetry(function() { return typeof fetchApifyData === 'function' ? fetchApifyData(dest, sector) : null; }, 'Apify'),
1865      withRetry(function() { return typeof fetchTripAdvisorData === 'function' ? fetchTripAdvisorData(dest) : null; }, 'TripAdvisor'),
1866    ]);
1867
1868    // Remontar SÓ o contexto RÁPIDO (lote 1+2). apify/trip entram depois (background).
1869    // Ordem: serp, rates, apify(null), news, places, trends, trip(null), tavily, brave
1870    const results = [
1871      batch1[0], batch1[1], null, batch1[2], batch2[0], batch1[3], null, batch2[1], batch2[2]
1872    ];
1873    const [serpData, ratesData, apifyData, newsData, placesData, trendsData, tripData, tavilyData, braveData] = results.map(r => (r && r.status === 'fulfilled') ? r.value : null);
1874    if (serpData) {
1875      serpCtx = `\n\n${serpData}`;
1876      _apiOk.serp = true;
1877      console.log('[SerpAPI] ✅ Real data injected for:', dest);
1878    }
1879    promptLogApi('serpapi', serpData, serpData ? null : 'no_response_after_retry');
1880    if (ratesData) {
1881      exchangeRates = ratesData;
1882      exchangeCtx = `\n\nCAMBIO REAL ATUAL: ${ratesData.raw}`;
1883      _apiOk.exchange = true;
1884      console.log('[ExchangeRate] ✅ Real rates injected:', ratesData.raw);
1885    }
1886    promptLogApi('exchangerate', ratesData, ratesData ? null : 'no_response_after_retry');
1887    if (apifyData) {
1888      extraDataCtx += `\n\n${apifyData}`;
1889      _apiOk.apify = true;
1890      console.log('[Apify] ✅ YouTube/Maps data injected for:', dest);
1891    }
1892    promptLogApi('apify', apifyData, apifyData ? null : 'no_response_after_retry');
1893    if (newsData) {
1894      extraDataCtx += `\n\n${newsData}`;
1895      _apiOk.news = true;
1896      console.log('[NewsAPI] ✅ News data injected for:', dest);
1897    }
1898    promptLogApi('newsapi', newsData, newsData ? null : 'no_data_for_destination');
1899    if (placesData) {
1900      extraDataCtx += `\n\n${placesData}`;
1901      _apiOk.places = true;
1902      console.log('[Places] ✅ Places data injected for:', dest);
1903    }
1904    promptLogApi('places', placesData, placesData ? null : 'no_response_after_retry');
1905    if (trendsData) {
1906      extraDataCtx += `\n\n${trendsData}`;
1907      _apiOk.trends = true;
1908      console.log('[GoogleTrends] ✅ Trend data injected for:', dest);
1909    }
1910    promptLogApi('google_trends', trendsData, trendsData ? null : 'timeout_or_compute_limit');
1911    if (tripData) {
1912      extraDataCtx += `\n\n${tripData}`;
1913      _apiOk.trip = true;
1914      console.log('[TripAdvisor] ✅ Reviews data injected for:', dest);
1915    }
1916    promptLogApi('tripadvisor', tripData, tripData ? null : 'actor_timeout_or_failed');
1917    if (tavilyData) {
1918      extraDataCtx += `\n\n${tavilyData}`;
1919      _apiOk.tavily = true;
1920      console.log('[Tavily] ✅ Search data injected for:', dest);
1921    }
1922    promptLogApi('tavily', tavilyData, tavilyData ? null : 'no_response_after_retry');
1923    if (braveData) {
1924      extraDataCtx += `\n\n${braveData}`;
1925      _apiOk.brave = true;
1926      console.log('[Brave] ✅ Search data injected for:', dest);
1927    }
1928    promptLogApi('brave', braveData, braveData ? null : 'key_not_configured_or_failed');
1929  } catch(e) { console.warn('[APIs] Failed:', e.message); }
1930
1931  // ===== FAILED APIs CONTEXT — impedir IA de inventar dados =====
1932  // Helper p/ (re)montar o aviso de APIs indisponíveis a partir de _apiOk.
1933  // Chamado p/ a ONDA RÁPIDA (Apify/TripAdvisor ainda pendentes → marcadas como
1934  // indisponíveis) e DE NOVO no background quando o Lote 3 chega (apify/trip ok).
1935  function _buildFailedApisCtx() {
1936    const failedApis = [];
1937    if (!_apiOk.serp) failedApis.push('SerpAPI (web search — temas, sentimento, influenciad
1937ores de busca)');
1938    if (!_apiOk.exchange) failedApis.push('ExchangeRate (câmbio real — dados de moeda nos mercados emissores)');
1939    if (!_apiOk.trends) failedApis.push('Google Trends (tendências de busca — dados de interesse ao longo do tempo)');
1940    if (!_apiOk.trip) failedApis.push('TripAdvisor (reviews e ranking — dados de avaliações reais)');
1941    if (!_apiOk.brave) failedApis.push('Brave Search (busca independente)');
1942    if (!_apiOk.news) failedApis.push('NewsAPI (notícias recentes sobre o destino)');
1943    if (!_apiOk.tavily) failedApis.push('Tavily (busca semântica)');
1944    if (!_apiOk.places) failedApis.push('Google Places / Smart Tour (ranking de lugares)');
1945    if (!_apiOk.apify) failedApis.push('Apify (YouTube, Instagram, Google Maps)');
1946    if (_emitterMarketsLoadFailed) failedApis.push('Mercados Emissores (dados de países emissores de turistas — NÃO gere mercados fictícios, retorne arrays vazios)');
1947    if (_curatedInfluencersLoadFailed) failedApis.push('Influenciadores Curados (banco de influenciadores verificados — NÃO invente nomes de influenciadores, retorne arrays vazios)');
1948    if (failedApis.length === 0) return '';
1949    return '\n\n⚠️ APIS INDISPONÍVEIS NESTA ANÁLISE (dados NÃO coletados): ' + failedApis.join('; ') +
1950      '\nREGRA OBRIGATÓRIA: Para campos que dependem dessas APIs indisponíveis, retorne "dados indisponíveis" ou null. ' +
1951      'NÃO invente dados, reviews, tendências, rankings ou estatísticas que não vieram das APIs reais fornecidas acima. ' +
1952      'Use APENAS os dados reais fornecidos neste prompt. Se não há dados reais para um campo, diga explicitamente que não há dados disponíveis.';
1953  }
1954  let failedApisCtx = _buildFailedApisCtx();
1955
1956  // Lista canônica de atrativos (resolvida em paralelo acima) → bloco injetado nos prompts.
1957  // Vazio quando não há cidade configurada nem retorno do Google Places → some do prompt (fluxo antigo).
1958  let _resolvedAttractions = [];
1959  try { _resolvedAttractions = await _attractionsPromise; } catch (_eAttr) { _resolvedAttractions = []; }
1960  const atrativosFixosCtx = (typeof buildAtrativosFixosCtx === 'function') ? buildAtrativosFixosCtx(_resolvedAttractions) : '';
1961  window._atrativosFixosCtx = atrativosFixosCtx;   // reuso pela onda on-demand (oracle, etc.)
1962
1963  // Generate temporal context based on period and current date
1964  const now = new Date();
1965  const periodoNum = parseInt(periodo) || 30;
1966  const startDate = new Date(now.getTime() - periodoNum * 24 * 60 * 60 * 1000);
1967  const fmtDate = (d) => d.toLocaleDateString('pt-BR', {day:'2-digit', month:'long', year:'numeric'});
1968  const currentDateCtx = `Data atual: ${fmtDate(now)} | Período analisado: ${fmtDate(startDate)} a ${fmtDate(now)} (${periodoNum} dias)`;
1969  // Hemisphere and season context — detectar hemisfério pela latitude do destino
1970  const month = now.getMonth(); // 0-11
1971  
1972  // Detectar hemisfério: usar coordenadas conhecidas ou inferir pelo país
1973  let isNorthernHemisphere = false;
1974  const destLower = dest.toLowerCase();
1975  const northCountries = ['portugal', 'espanha', 'spain', 'frança', 'france', 'alemanha', 'germany', 'italia', 'italy', 'reino unido', 'united kingdom', 'estados unidos', 'united states', 'usa', 'canadá', 'canada', 'méxico', 'mexico', 'japão', 'japan', 'china', 'índia', 'india', 'emirados', 'uae', 'israel'];
1976  for (var nc = 0; nc < northCountries.length; nc++) {
1977    if (destLower.includes(northCountries[nc])) { isNorthernHemisphere = true; break; }
1978  }
1979  // Cidades conhecidas no hemisfério norte
1980  const northCities = ['lisboa', 'porto', 'paris', 'london', 'londres', 'new york', 'miami', 'cancún', 'cancun', 'madrid', 'barcelona', 'roma', 'berlim', 'tokyo', 'dubai'];
1981  for (var nci = 0; nci < northCities.length; nci++) {
1982    if (destLower.includes(northCities[nci])) { isNorthernHemisphere = true; break; }
1983  }
1984
1985  let seasonCtx;
1986  if (isNorthernHemisphere) {
1987    let seasonNorth = 'Primavera';
1988    if (month >= 5 && month <= 7) seasonNorth = 'Verão (alta temporada)';
1989    else if (month >= 8 && month <= 10) seasonNorth = 'Outono';
1990    else if (month >= 11 || month <= 1) seasonNorth = 'Inverno';
1991    seasonCtx = 'Hemisfério Norte: Estação atual = ' + seasonNorth + '. Verão=Jun-Ago, Outono=Set-Nov, Inverno=Dez-Fev, Primavera=Mar-Mai. Considere o clima do hemisfério norte para este destino.';
1992    console.log('[Season] 🌍 Hemisfério Norte detectado para:', dest, '| Estação:', seasonNorth);
1993  } else {
1994    let seasonSouth = 'Primavera';
1995    if (month >= 11 || month <= 1) seasonSouth = 'Verão (alta temporada praias)';
1996    else if (month >= 2 && month <= 4) seasonSouth = 'Outono';
1997    else if (month >= 5 && month <= 7) seasonSouth = 'Inverno';
1998    seasonCtx = 'Hemisfério Sul (Brasil): Estação atual = ' + seasonSouth + '. Verão=Dez-Fev, Outono=Mar-Mai, Inverno=Jun-Ago, Primavera=Set-Nov. NUNCA mencione neve, frio extremo ou condições do hemisfério norte como se fossem do Brasil.';
1999  }
2000
2001  // Generate trend labels matching the exact period
2002  let trendLabels;
2003  if (periodoNum <= 7) {
2004    // Daily labels: last N days
2005    trendLabels = Array.from({length: periodoNum}, (_, i) => {
2006      const d = new Date(startDate.getTime() + i * 24 * 60 * 60 * 1000);
2007      return d.toLocaleDateString('pt-BR', {day:'2-digit', month:'2-digit'});
2008    });
2009  } else if (periodoNum <= 30) {
2010    // Weekly labels: week numbers within period
2011    const numWeeks = Math.ceil(periodoNum / 7);
2012    trendLabels = Array.from({length: numWeeks}, (_, i) => {
2013      const d = new Date(startDate.getTime() + i * 7 * 24 * 60 * 60 * 1000);
2014      return d.toLocaleDateString('pt-BR', {day:'2-digit', month:'2-digit'});
2015    });
2016  } else if (periodoNum <= 90) {
2017    // Bi-weekly or monthly within the period
2018    const numPoints = Math.min(8, Math.ceil(periodoNum / 10));
2019    trendLabels = Array.from({length: numPoints}, (_, i) => {
2020      const d = new Date(startDate.getTime() + (periodoNum / (numPoints-1)) * i * 24 * 60 * 60 * 1000);
2021      return d.toLocaleDateString('pt-BR', {day:'2-digit', month:'short'});
2022    });
2023  } else {
2024    // Monthly labels for long periods
2025    const numMonths = Math.ceil(periodoNum / 30);
2026    trendLabels = Array.from({length: numMonths}, (_, i) => {
2027      const d = new Date(startDate.getTime() + i * 30 * 24 * 60 * 60 * 1000);
2028      return d.toLocaleDateString('pt-BR', {month:'short', year:'2-digit'});
2029    });
2030  }
2031  const trendLabelsJson = JSON.stringify(trendLabels);
2032  const trendPoints = trendLabels.length;
2033
2034  // ===== PROMPT 1 — Core Dashboard (via Supabase) =====
2035  const macroCtx = macroContextStr ? ' MACRO: ' + macroContextStr + ' | ' : '';
2036  const prevPeriodStart = fmtDate(new Date(startDate.getTime() - periodoNum * 86400000));
2037
2038  // Variáveis compartilhadas por todos os prompts de análise
2039  const _commonPromptVars = {
2040    dest, sectorCtx, cityProfileCtx: cityProfileCtx || '', uploadCtx: uploadCtx || '',
2041    serpCtx: serpCtx || '', extraDataCtx: extraDataCtx || '',
2042    currentDateCtx, seasonCtx, startDate: fmtDate(startDate), now: fmtDate(now),
2043    periodoNum, prevPeriodStart, concStr, periodo, foco, langName,
2044    concEnrichedCtx: concEnrichedCtx || '', macroCtx,
2045    failedApisCtx: failedApisCtx || '',
2046    atrativosFixosCtx: atrativosFixosCtx || ''
2047  };
2048
2049  let prompt1 = '', prompt1Meta = null;
2050  let _splitDashMeta = null, _splitInsightsMeta = null, _tiered = false;
2051
2052  if (_USE_SPLIT_PROMPTS) {
2053    // Dashboard em 2 ondas: A = analysis-dashboard (KPIs/temas/gráfico, rápida) +
2054    // B = analysis-dashboard-insights (análise, pesada, por último). Detecta se o
2055    // prompt de insights existe no DB; se não, mantém insights na onda A (fallback).
2056    try {
2057      _splitInsightsMeta = await getPromptWithMeta('analysis-dashboard-insights', {
2058        ..._commonPromptVars, outputSchema: buildDashboardInsightsSchema()
2059      });
2060    } catch (_eIns) { _splitInsightsMeta = null; }
2061    _tiered = !!(_USE_DASHBOARD_TIERS && _splitInsightsMeta && _splitInsightsMeta.text && _splitInsightsMeta.text.trim());
2062    _splitDashMeta = await getPromptWithMeta('analysis-dashboard', {
2063      ..._commonPromptVars, outputSchema: buildDashboardOutputSchema(dest, concs, trendLabelsJson, trendPoints, !_tiered)
2064    });
2065    // crise/perfil/benchmark NÃO são montados aqui: dependem do Lote 3 (Apify/
2066    // TripAdvisor) e são montados+rodados no background, após o dashboard pintar.
2067    console.log('[Pipeline] Onda A (dashboard) pronta | tiered insights=' + _tiered + ' | crise/perfil/benchmark via background pós-Lote3');
2068  } else {
2069    // Monolito original (analysis-main)
2070    const outputSchema1 = buildCoreOutputSchema(dest, concs, trendLabelsJson, trendPoints);
2071    prompt1Meta = await getPromptWithMeta('analysis-main', { ..._commonPromptVars, outputSchema: outputSchema1 });
2072    prompt1 = prompt1Meta?.text || '';
2073  }
2074
2075  // ===== PROMPT 2 — Premium (via Supabase) =====
2076  const prompt2Meta = await getPromptWithMeta('analysis-premium', {
2077    dest, sectorCtx, cityProfileCtx: cityProfileCtx || '',
2078    uploadCtx: uploadCtx || '', extraDataCtx: extraDataCtx ? '\nREAL DATA FROM APIs:\n' + extraDataCtx : '',
2079    startDate: fmtDate(startDate), now: fmtDate(now), periodoNum, langName,
2080    failedApisCtx: failedApisCtx || '', atrativosFixosCtx: atrativosFixosCtx || ''
2081  });
2082  const prompt2 = prompt2Meta?.text || '';
2083
2084  // ===== Build emissores markets list =====
2085  await loadEmitterMarkets(); // Garantir que mercados estão carregados do Supabase
2086
2087  let allMarkets = [];
2088  let numMarkets = 0;
2089  let prompt4 = null;
2090  let prompt4Meta = null;
2091
2092  if (_emitterMarketsLoadFailed || EMITTER_MARKETS.length === 0) {
2093    console.warn('[Pipeline] ⚠️ Mercados emissores indisponíveis — prompt4 será nulo');
2094  } else {
2095    const domesticCode = countryNameToISO2(countryFromDestString(dest));
2096    const domesticMarket = EMITTER_MARKETS.find(m => m.code === domesticCode) || {code:'BR',flag:'🇧🇷',pt:'Brasil',en:'Brazil'};
2097
2098    const selectedCodes = (G.markets && G.markets.length > 0) ? G.markets : ['AR','US','DE'];
2099    const selectedMarkets = selectedCodes.map(c => EMITTER_MARKETS.find(m => m.code === c)).filter(Boolean);
2100
2101    allMarkets = [domesticMarket, ...selectedMarkets];
2102    numMarkets = allMarkets.length;
2103
2104    // ===== PROMPT 4 — Markets (via Supabase + outputSchema dinâmico) =====
2105    const serpBlock = serpCtx ? '\nREAL SEARCH DATA TO USE:\n' + serpCtx : '';
2106    const extraBlock = (exchangeCtx || '') + (extraDataCtx ? '\n' + extraDataCtx : '');
2107    const outputSchema4 = buildMarketsOutputSchema(dest, allMarkets, lang);
2108    prompt4Meta = await getPromptWithMeta('analysis-markets', {
2109      dest, sectorCtx, cityProfileCtx: cityProfileCtx || '',
2110      startDate: fmtDate(startDate), now: fmtDate(now), periodoNum, langName,
2111      numMarkets, serpBlock, extraBlock, outputSchema: outputSchema4,
2112      failedApisCtx: failedApisCtx || ''
2113    });
2114    prompt4 = prompt4Meta?.text || null;
2115  }
2116
2117  // ===== PROMPT 5 — Social Radar (via Supabase) =====
2118  const prompt5Meta = await getPromptWithMeta('analysis-social', {
2119    dest, sectorCtx, cityProfileCtx: cityProfileCtx || '',
2120    uploadCtx: uploadCtx || '', extraDataCtx: extraDataCtx ? '\nREAL DATA:\n' + extraDataCtx : '',
2121    startDate: fmtDate(startDate), now: fmtDate(now), periodoNum, langName,
2122    failedApisCtx: failedApisCtx || '', atrativosFixosCtx: atrativosFixosCtx || ''
2123  });
2124  const prompt5 = prompt5Meta?.text || '';
2125
2126  // ===== PROMPT 3 — Oracle (via Supabase) =====
2127  const prompt3Meta = await getPromptWithMeta('analysis-oracle', {
2128    dest, sectorCtx, cityProfileCtx: cityProfileCtx || '',
2129    uploadCtx: uploadCtx || '',
2130    extraDataCtx: extraDataCtx ? '\nREAL DATA FROM APIs:\n' + extraDataCtx : '',
2131    exchangeCtx: exchangeCtx || '',
2132    startDate: fmtDate(startDate), now: fmtDate(now), periodoNum, langName,
2133    failedApisCtx: failedApisCtx || '', atrativosFixosCtx: atrativosFixosCtx || ''
2134  });
2135  const prompt3 = prompt3Meta?.text || '';
2136
2137  // ===== PLAN-BASED PROMPT FILTERING =====
2138  // Only call prompts that belong to the user's plan
2139  const userPlan = getUserPlan();
2140  const isBasic = (userPlan === 'basic' || userPlan === 'free');
2141  const isPremium = (userPlan === 'premium');
2142  const isOracle = (userPlan === 'oracle' || userPlan === 'enterprise');
2143
2144  // ===== EXECUÇÃO SEQUENCIAL — APENAS 4 PROMPTS BASIC =====
2145  // Independente do plano (basic/premium/oracle), o onboarding roda só os
2146  // módulos do basic. Premium/Oracle/Markets/Social/etc agora rodam sob
2147  // demanda via CTA no painel (cada módulo tem botão "Gerar X").
2148  //
2149  // Sequencial para:
2150  //   1) Não estourar rate limit Claude (50k tokens/min)
2151  //   2) Salvar parcial via merge_analysis_field a cada conclusão
2152  console.log(`[Pulse] Plan: ${userPlan} | onboarding pipeline (basic only) | split=${_USE_SPLIT_PROMPTS}`);
2153
2154  // Helper: roda 1 prompt, dispatcha eventos pra UI, salva parcial. Nunca lança
2155  // a menos que `critical=true` (caso o dashboard, único bloqueante).
2156  //
2157  // `ctx` = { token, dest } da análise que originou este passo. A checagem de corrida
2158  // precisa acontecer ANTES da gravação: antes ela existia só no laço do background,
2159  // depois do _runStep retornar — então um passo da cidade N que terminasse durante a
2160  // cidade N+1 já tinha gravado no registro errado quando o laço abortava.
2161  async function _runStep(slug, prompt, meta, critical, ctx) {
2162    if (!prompt) return null;
2163    _setModuleStatus(slug, 'running');
2164    try {
2165      const result = await callAI(prompt, 16384, meta);
2166      _setModuleStatus(slug, 'done');
2167      // Dashboard (critical) é gravado pelo INSERT do saveAnalysisToSupabase (G.data
2168      // completo). Só o background (crise/perfil/benchmark) persiste via merge — e o
2169      // poll do analysisId NÃO trava o caminho crítico do dashboard.
2170      if (!critical) {
2171        if (ctx && window._analysisRunToken !== ctx.token) {
2172          console.warn('[Pipeline] ' + slug + ' descartado: a análise mudou durante a execução '
2173            + '(resultado de "' + (ctx.dest || '—') + '" NÃO foi gravado)');
2174        } else {
2175          await _persistModuleResult(slug, result, ctx && ctx.dest);
2176        }
2177      }
2178      return result;
2179    } catch (e) {
2180      console.warn(`[Pipeline] ${slug} falhou:`, e.message);
2181      _setModuleStatus(slug, 'error');
2182      if (critical) throw e;
2183      return null;
2184    }
2185  }
2186
2187  let dash = null;
2188  if (_USE_SPLIT_PROMPTS) {
2189    // 1º load: SÓ o dashboard (crítico) → o painel aparece rápido. crise/perfil/
2190    // benchmark carregam em BACKGROUND logo abaixo (prompts completos, confiável).
2191    dash = await _runStep('analysis-dashboard', _splitDashMeta?.text || '', _splitDashMeta, true);
2192  } else {
2193    // Modo monolito legacy — analysis-main inclui dashboard+crisis+profile+benchmark
2194    dash = await _runStep('analysis-main', prompt1, prompt1Meta, true);
2195  }
2196
2197  const finalData = { ...(dash || {}), _exchangeRates: exchangeRates };
2198  window._lastAnalysisData = finalData;
2199
2200  // ===== BACKGROUND: insights (onda B) + crise / perfil / benchmark =====
2201  // Não bloqueia o painel. window._bgLoading[page]=true enquanto carrega (os
2202  // renderers mostram spinner). Guarda de corrida: _analysisRunToken (nova
2203  // análise/restore aborta o background antigo antes do merge).
2204  if (_USE_SPLIT_PROMPTS) {
2205    window._bgLoading = { crisis: true, profile: true, benchmark: true, dashInsights: _tiered };
2206    window._analysisRunToken = (window._analysisRunToken || 0) + 1;
2207    var _bgToken = window._analysisRunToken;
2208    // Destino congelado para este background. É G.dest (JÁ deduplicado por
2209    // _deduplicateDest), que é exatamente o valor que saveAnalysisToSupabase grava em
2210    // analysis_history.destination — e não o `dest` cru recebido por callClaude, que
2211    // pode diferir e faria o servidor recusar toda escrita legítima.
2212    var _bgDest = G.dest;
2213
2214    // ----- ONDA B: insights do dashboard (pesado, por último) -----
2215    // Usa o contexto RÁPIDO (não espera o Lote 3). Persiste FLAT no campo
2216    // 'insights' (compatível com o reload do histórico via loadRecord).
2217    if (_tiered && _splitInsightsMeta && _splitInsightsMeta.text) {
2218      (async function _bgInsights() {
2219        try {
2220          var insRes = await callAI(_splitInsightsMeta.text, 4096, _splitInsightsMeta);
2221          if (window._analysisRunToken !== _bgToken) return;
2222          if (insRes && insRes.insights) {
2223            window._lastAnalysisData = Object.assign(window._lastAnalysisData || {}, { insights: insRes.insights });
2224            if (G.data) G.data.insights = insRes.insights;
2225            await _persistField('insights', insRes.insights, _bgDest);
2226          }
2227        } catch (e) { console.warn('[BG] insights falhou:', e && e.message); }
2228        if (window._analysisRunToken !== _bgToken) return;
2229        window._bgLoading.dashInsights = false;
2230        try { if (typeof renderDashboardInsights === 'function') renderDashboardInsights(window._lastAnalysisData || {}); } catch (_eR) {}
2231      })();
2232    }
2233
2234    // ----- crise / perfil / benchmark: esperam o LOTE 3 (Apify/TripAdvisor) -----
2235    // p/ ter contexto completo (reviews/IG/Maps). Cada módulo roda seu prompt
2236    // COMPLETO, faz merge e re-renderiza se o usuário estiver na tela.
2237    (async function _bgLoadBasics() {
2238      // 1) aguarda os dados pesados e os mescla no contexto
2239      //
2240      // Os 3 módulos DEPENDEM do Lote 3 (reviews/IG/Maps são o contexto deles), então a
2241      // espera continua — o que não pode é esperar em SILÊNCIO. fetchApifyData roda 4
2242      // actors em série (YouTube→Instagram→Places→Reviews) e ainda passa por withRetry:
2243      // sem teto, um hang do Apify fazia crise/perfil/benchmark nunca rodarem e a análise
2244      // ser salva sem esses blocos, sem nenhum aviso (o usuário só via "indisponível" no
2245      // dia seguinte). Agora: avisa ao passar do normal, e o teto arma a RETOMADA.
2246      var heavy = null, _lote3Estourou = false, _tetoTimer = null;
2247      var _avisoTimer = setTimeout(function () {
2248        if (window._analysisRunToken !== _bgToken) return;
2249        console.warn('[BG] Lote 3 (Apify/TripAdvisor) passou de ' + (_LOTE3_AVISO_MS / 1000)
2250          + 's — crise/perfil/benchmark seguem aguardando os dados pesados');
2251      }, _LOTE3_AVISO_MS);
2252      try {
2253        heavy = batch3Promise ? await Promise.race([
2254          batch3Promise,
2255          new Promise(function (r) {
2256            _tetoTimer = setTimeout(function () { _lote3Estourou = true; r(null); }, _LOTE3_TETO_MS);
2257          }),
2258        ]) : null;
2259      } catch (_eH) {}
2260      clearTimeout(_avisoTimer);
2261      clearTimeout(_tetoTimer);   // Lote 3 chegou antes → não deixa o teto pendurado
2262      if (window._analysisRunToken !== _bgToken) return;
2263
2264      if (_lote3Estourou) {
2265        // Teto estourado: NÃO roda os 3 com contexto pela metade (eles dependem do Lote 3).
2266        // Marca a análise como incompleta — assim o módulo diz o MOTIVO em vez de um "sem
2267        // dados" mudo — e arma a retomada: se o Apify responder depois, os 3 rodam e
2268        // persistem sozinhos, limpando a marca.
2269        console.error('[BG] Lote 3 estourou o teto de ' + (_LOTE3_TETO_MS / 60000)
2270          + 'min — crise/perfil/benchmark NÃO rodaram. Retomada armada.');
2271        // AWAIT obrigatório: são 2 round-trips de persistência e a aba pode fechar a
2272        // qualquer momento (é o cenário típico aqui). Sem await, a marca "incompleto"
2273        // se perdia justamente quando ela é a única coisa que sobra pro usuário.
2274        await _bgMarcarIncompletos(['crisis', 'profile', 'benchmark'], _bgDest, _MODULE_LOTE3_REASON);
2275        if (batch3Promise) {
2276          batch3Promise.then(function (late) {
2277            if (window._analysisRunToken !== _bgToken) return;
2278            console.warn('[BG] Lote 3 chegou atrasado — retomando crise/perfil/benchmark');
2279            window._bgLoading = Object.assign(window._bgLoading || {}, { crisis: true, profile: true, benchmark: true });
2280            return _bgRodarBasicos(late);
2281          }).catch(function (e) { console.error('[BG] retomada do Lote 3 falhou:', e && e.message); });
2282        }
2283        return;
2284      }
2285      return _bgRodarBasicos(heavy);
2286    })();
2287
2288    // Roda os 3 módulos básicos com o contexto completo do Lote 3 (chamado no fluxo normal
2289    // e também pela retomada, quando o Apify responde depois do teto).
2290    async function _bgRodarBasicos(heavy) {
2291      var _apifyD = (heavy && heavy[0] && heavy[0].status === 'fulfilled') ? heavy[0].value : null;
2292      var _tripD  = (heavy && heavy[1] && heavy[1].status === 'fulfilled') ? heavy[1].value : null;
2293      var _extraFull = extraDataCtx;
2294      if (_apifyD) { _extraFull += '\n\n' + _apifyD; _apiOk.apify = true; console.log('[Apify] ✅ (background) data injected'); }
2295      if (_tripD)  { _extraFull += '\n\n' + _tripD;  _apiOk.trip  = true; console.log('[TripAdvisor] ✅ (background) data injected'); }
2296      var _varsFull = Object.assign({}, _commonPromptVars, { extraDataCtx: _extraFull, failedApisCtx: _buildFailedApisCtx() });
2297
2298      // 2) monta os prompts dos 3 módulos JÁ com o contexto completo
2299      var _crisisMeta = null, _profileMeta = null, _benchMeta = null;
2300      try { _crisisMeta  = await getPromptWithMeta('analysis-crisis',    Object.assign({}, _varsFull, { outputSchema: buildCrisisOutputSchema() })); } catch (_e1) {}
2301      try { _profileMeta = await getPromptWithMeta('analysis-profile',   Object.assign({}, _varsFull, { outputSchema: buildProfileOutputSchema(langName) })); } catch (_e2) {}
2302      try { _benchMeta   = await getPromptWithMeta('analysis-benchmark', Object.assign({}, _varsFull, { outputSchema: buildBenchmarkOutputSchema(dest, concs) })); } catch (_e3) {}
2303      if (window._analysisRunToken !== _bgToken) return;
2304
2305      var bgSteps = [
2306        { slug: 'analysis-crisis',    meta: _crisisMeta,  page: 'crisis',    render: function () { if (typeof renderCrisis === 'function') renderCrisis(window._lastAnalysisData || {}); } },
2307        { slug: 'analysis-profile',   meta: _profileMeta, page: 'profile',   render: function () { if (typeof renderProfile === 'function') renderProfile(window._lastAnalysisData || {}); } },
2308        { slug: 'analysis-benchmark', meta: _benchMeta,   page: 'benchmark', render: function () { if (typeof renderBenchmark === 'function') renderBenchmark(window._lastAnalysisData || {}); } },
2309      ];
2310      var _faltando = [];
2311      for (var bi = 0; bi < bgSteps.length; bi++) {
2312        if (window._analysisRunToken !== _bgToken) return;   // nova análise → aborta
2313        var st = bgSteps[bi];
2314        try {
2315          var r = await _runStep(st.slug, (st.meta && st.meta.text) || '', st.meta, false, { token: _bgToken, dest: _bgDest });
2316          if (window._analysisRunToken !== _bgToken) return;
2317          if (r) window._lastAnalysisData = Object.assign(window._lastAnalysisData || {}, r);
2318          else _faltando.push(st.page);
2319        } catch (e) { console.error('[BG] ' + st.slug + ' falhou:', e); _faltando.push(st.page); }
2320        window._bgLoading[st.page] = false;
2321        try { st.render(); } catch (e) {}
2322      }
2323      // Fecha o ciclo: ou limpa a marca (rodou tudo, inclusive numa retomada tardia) ou
2324      // registra exatamente quais blocos ficaram de fora — nunca salva incompleto calado.
2325      _bgMarcarIncompletos(_faltando, _bgDest, _MODULE_STEP_REASON);
2326    }
2327  }
2328
2329  return finalData;
2330}
2331
2332
2333
2334
2335
2336// ===== ORACLE — CONSENSUS FRAMEWORK =====
2337
2338var ORACLE_AGENTS = {}; // Dados migrados para tabela oracle_agents no Supabase
2339var _oracleAgentsLoaded = false;
2340
2341async function loadOracleAgents() {
2342  if (_oracleAgentsLoaded && Object.keys(ORACLE_AGENTS).length > 0) return ORACLE_AGENTS;
2343
2344  var result = await withRetry(async function() {
2345    var supabase = await getSupabase();
2346    var resp = await supabase
2347      .from('oracle_agents')
2348      .select('id, name, role, provider, color, prompt, img, sort_order')
2349      .eq('is_active', true)
2350      .order('sort_order');
2351
2352    if (resp.error) throw new Error(resp.error.message || 'Supabase error');
2353    if (!resp.data || resp.data.length === 0) throw new Error('Nenhum agente retornado');
2354    return resp.data;
2355  }, { retries: 3, label: 'Oracle Agents' });
2356
2357  if (result.ok) {
2358    ORACLE_AGENTS = {};
2359    result.data.forEach(function(a) {
2360      ORACLE_AGENTS[a.id] = {
2361        name: a.name,
2362        role: a.role,
2363        provider: a.provider || 'openai',
2364        color: a.color || '#888',
2365        prompt: a.prompt || '',
2366        img: a.img || '',
2367      };
2368    });
2369    _oracleAgentsLoaded = true;
2370    console.log('[Oracle Agents] ✅ ' + result.data.length + ' agentes carregados do Supabase');
2371  } else {
2372    console.error('[Oracle Agents] ❌ Falha total ao carregar agentes');
2373  }
2374  return ORACLE_AGENTS;
2375}
2376
2377const AGENT_ORDER = ['kael', 'kairos', 'nexus', 'aletheia', 'iris', 'can'];
2378
2379var oracleDebateHistory = [];
2380var oracleChatHistory   = [];
2381let oracleAnalysisCtx   = '';
2382let oracleVoiceActive   = false;
2383let oracleSpeechRec     = null;
2384
2385// Collect current analysis data as context for Oracle
2386
2387// Set agent speaking state
2388
2389// Add message to debate feed
2390
2391// Update round indicators
2392
2393// Call Claude API for one agent
2394
2395// Universal API caller for Oracle — uses Edge Function
2396
2397// ===== SINCRONICIDADE — The Execution Maestro =====
2398function renderSincronicidade(d) {
2399  const cardsEl = document.getElementById('sinc-cards');
2400  const timelineEl = document.getElementById('sinc-timeline');
2401  const indexEl = document.getElementById('sinc-index-val');
2402  if (!cardsEl) return;
2403  // Sinc agrega dados de oracle/premium. Se nenhuma fonte rodou → CTA.
2404  var hasAnySrc = (d.emissores_oportunidade && d.emissores_oportunidade.length)
2405    || (d.viral_predictions && d.viral_predictions.length)
2406    || (Array.isArray(d.feliz_emotions) && d.feliz_emotions.length)
2407    || (d.oracle_alerts && d.oracle_alerts.length);
2408  if (!hasAnySrc) {
2409    if (_showModuleCTA('sinc')) return;
2410  }
2411  _hideModuleError('sinc');
2412  cardsEl.innerHTML = '';
2413  if (timelineEl) timelineEl.innerHTML = '';
2414  
2415  // Generate action cards from all analysis data
2416  const cards = [];
2417  const macro = macroGetContextString();
2418  const dest = G.dest || 'destino';
2419  
2420  // 1. OPPORTUNITY cards — from emissores + viral
2421  const emOpp = d.emissores_oportunidade || [];
2422  const topOpp = emOpp.filter(m => parseInt(m.opportunity_score) >= 70).sort((a,b) => parseInt(b.opportunity_score) - parseInt(a.opportunity_score));
2423  if (topOpp.length > 0) {
2424    const m = topOpp[0];
2425    cards.push({
2426      tipo: 'oportunidade', icon: '🟢', color: 'var(--accent)', borderColor: 'rgba(34,211,238,0.3)',
2427      titulo: t('sinc_card_janela_prefix') + m.pais,
2428      gatilho: t('sinc_card_opp_score').replace('{n}', m.opportunity_score).replace('{tend}', m.tendencia_interesse || ''),
2429      acao: m.acao_recomendada || t('sinc_card_intensificar'),
2430      impacto: m.potencial_adicional ? t('sinc_card_potencial_prefix') + m.potencial_adicional : '',
2431      segmentos: m.segmentos_alvo || [],
2432      urgencia: 'alta',
2433    });
2434  }
2435  
2436  // 2. RESILIENCE cards — from crisis data
2437  if (d.crisis_level === 'high' || d.crisis_level === 'medium') {
2438    const topCrisis = (d.crisis_indicators || [])[0];
2439    cards.push({
2440      tipo: 'resiliencia', icon: '🔴', color: 'var(--red)', borderColor: 'rgba(239,68,68,0.3)',
2441      titulo: t('sinc_card_rota_prefix') + (topCrisis?.tema || t('sinc_card_crise_detectada')),
2442      gatilho: topCrisis ? t('sinc_card_crise_gatilho').replace('{tema}', topCrisis.tema).replace('{delta}', topCrisis.delta).replace('{n}', topCrisis.confianca) : t('sinc_card_crise_indicadores'),
2443      acao: t('sinc_card_foco_proximidade'),
2444      impacto: t('sinc_card_mitigacao'),
2445      segmentos: [],
2446      urgencia: 'urgente',
2447    });
2448  }
2449  
2450  // 3. VIRAL SYNC cards — from viral predictions
2451  const virals = d.viral_predictions || [];
2452  const topViral = virals.find(v => v.probabilidade >= 60);
2453  if (topViral) {
2454    cards.push({
2455      tipo: 'social', icon: '📱', color: 'var(--magenta)', borderColor: 'rgba(217,70,239,0.3)',
2456      titulo: t('sinc_card_onda_prefix') + topViral.titulo,
2457      gatilho: t('sinc_card_viral_gatilho').replace('{n}', topViral.probabilidade).replace('{janela}', topViral.janela),
2458      acao: t('sinc_card_viral_acao').replace('{titulo}', topViral.titulo),
2459      impacto: t('sinc_card_amplificacao'),
2460      segmentos: [],
2461      urgencia: 'esta semana',
2462    });
2463  }
2464  
2465  // 4. NARRATIVE cards — from negative narratives
2466  const negNarr = d.emissores_narrativas_negativas || [];
2467  if (negNarr.length > 0) {
2468    const n = negNarr[0];
2469    cards.push({
2470      tipo: 'branding', icon: '🛡️', color: 'var(--yellow)', borderColor: 'rgba(236,201,75,0.3)',
2471      titulo: t('sinc_card_narrativa_prefix') + n.narrativa,
2472      gatilho: t('sinc_card_narrativa_gatilho').replace('{n}', n.crescimento).replace('{mercados}', (n.mercados||[]).join(', ')),
2473      acao: n.acao_mitigar || t('sinc_card_conteudo_institucional'),
2474      impacto: t('sinc_card_protecao_imagem'),
2475      segmentos: [],
2476      urgencia: 'esta semana',
2477    });
2478  }
2479  
2480  // 5. SEASONALITY cards
2481  const sazon = d.emissores_sazonalidade || [];
2482  const now = new Date();
2483  const currentMonth = now.toLocaleDateString('pt-BR', {month:'short'}).slice(0,3);
2484  sazon.forEach(s => {
2485    const isHighSeason = (s.meses_alta || []).some(m => m.toLowerCase().startsWith(currentMonth.toLowerCase()));
2486    if (isHighSeason) {
2487      cards.push({
2488        tipo: 'oportunidade', icon: '📅', color: 'var(--blue)', borderColor: 'rgba(59,130,246,0.3)',
2489        titulo: t('sinc_card_temporada_prefix') + s.pais,
2490        gatilho: t('sinc_card_temporada_gatilho').replace('{pais}', s.pais),
2491        acao: t('sinc_card_temporada_acao').replace('{pais}', s.pais).replace('{motivo}', s.motivo || ''),
2492        impacto: t('sinc_card_demanda_sazonal'),
2493        segmentos: [],
2494        urgencia: 'agora',
2495      });
2496    }
2497  });
2498  
2499  // Calculate Synchronicity Index
2500  const oppCount = cards.filter(c => c.tipo === 'oportunidade').length;
2501  const riskCount = cards.filter(c => c.tipo === 'resiliencia' || c.tipo === 'branding').length;
2502  const syncIndex = Math.min(100, Math.max(0, 50 + (oppCount * 15) - (riskCount * 20) + (topViral ? 10 : 0)));
2503  if (indexEl) {
2504    const idxColor = syncIndex >= 70 ? 'var(--accent)' : syncIndex >= 40 ? 'var(--yellow)' : 'var(--red)';
2505    indexEl.style.color = idxColor;
2506    indexEl.textContent = syncIndex + '%';
2507  }
2508  
2509  // Render cards
2510  const urgencyOrder = {urgente:0, agora:1, 'esta semana':2, alta:3};
2511  cards.sort((a,b) => (urgencyOrder[a.urgencia]||9) - (urgencyOrder[b.urgencia]||9));
2512  
2513  const urgencyLabels = {
2514    urgente: {text:t('sinc_urg_urgente'),bg:'rgba(239,68,68,0.15)',color:'var(--red)'},
2515    agora: {text:t('sinc_urg_agora'),bg:'var(--accent-dim)',color:'var(--accent)'},
2516    'esta semana': {text:t('sinc_urg_semana'),bg:'rgba(236,201,75,0.1)',color:'var(--yellow)'},
2517    alta: {text:t('sinc_urg_alta'),bg:'var(--accent-dim)',color:'var(--accent)'},
2518  };
2519  
2520  // Store cards globally for briefing access
2521  window._sincCards = cards;
2522  
2523  cards.forEach((c, i) => {
2524    const urg = urgencyLabels[c.urgencia] || urgencyLabels.alta;
2525    const el = document.createElement('div');
2526    el.style.cssText = `padding:20px;border-radius:14px;background:var(--surface);border:1px solid ${c.borderColor};border-left:4px solid ${c.color};transition:transform 0.2s;cursor:pointer;`;
2527    el.onmouseenter = () => el.style.transform = 'translateX(4px)';
2528    el.onmouseleave = () => el.style.transform = 'translateX(0)';
2529    el.innerHTML = `
2530      <div style="display:flex;align-items:center;gap:10px;margin-bottom:10px;">
2531        <span style="font-size:20px;">${c.icon}</span>
2532        <span style="font-family:Syne,sans-serif;font-size:15px;font-weight:700;flex:1;">${c.titulo}</span>
2533        <span style="font-size:10px;padding:4px 10px;border-radius:100px;background:${urg.bg};color:${urg.color};font-weight:600;">${urg.text}</span>
2534      </div>
2535      <div style="font-size:11px;color:var(--text3);margin-bottom:8px;">${t('sinc_gatilho_prefix')}${c.gatilho}</div>
2536      <div style="font-size:13px;color:var(--text2);line-height:1.6;margin-bottom:8px;">🎯 ${c.acao}</div>
2537      ${c.impacto ? `<div style="font-size:12px;color:${c.color};font-weight:600;">📈 ${c.impacto}</div>` : ''}
2538      ${c.segmentos.length ? `<div style="display:flex;gap:4px;flex-wrap:wrap;margin-top:8px;">${c.segmentos.map(s => `<span style="font-size:10px;padding:2px 8px;border-radius:100px;background:var(--surface2);color:var(--text2);border:1px solid var(--border);">${s}</span>`).join('')}</div>` : ''}
2539      <div style="margin-top:12px;border-top:1px solid var(--border);padding-top:12px;"><button onclick="sincBriefingByIndex(${i})" style="padding:8px 18px;border-radius:100px;border:none;background:linear-gradient(135deg,${c.color},var(--blue));color:white;font-size:12px;font-weight:600;cursor:pointer;transition:transform 0.15s;" onmouseenter="this.style.transform='scale(1.03)'" onmouseleave="this.style.transform='scale(1)'">${t('sinc_briefing_btn')}</button><div id="sinc-briefing-${i}" style="display:none;margin-top:10px;padding:14px;border-radius:10px;background:var(--surface2);border:1px solid var(--border);"></div></div>`;
2540    cardsEl.appendChild(el);
2541  });
2542
2543  if (cards.length === 0) {
2544    cardsEl.innerHTML = '<div style="text-align:center;padding:40px;color:var(--text3);"><div style="font-size:36px;margin-bottom:12px;">⚡</div>'+t('sinc_empty')+'</div>';
2545  }
2546
2547  // Render timeline
2548  if (timelineEl && cards.length > 0) {
2549    const timeframes = [t('sinc_tf_agora'), t('sinc_tf_semana'), t('sinc_tf_2semanas'), t('sinc_tf_mes')];
2550    timeframes.forEach((tf, ti) => {
2551      const tfCards = ti === 0 ? cards.filter(c => c.urgencia === 'urgente' || c.urgencia === 'agora') :
2552                      ti === 1 ? cards.filter(c => c.urgencia === 'esta semana') :
2553                      ti === 2 ? cards.filter(c => c.urgencia === 'alta') : [];
2554      const row = document.createElement('div');
2555      row.style.cssText = 'display:flex;align-items:stretch;gap:0;';
2556      row.innerHTML = `
2557        <div style="width:120px;flex-shrink:0;padding:12px 0;text-align:right;padding-right:16px;border-right:2px solid ${tfCards.length > 0 ? 'var(--accent)' : 'var(--border)'};position:relative;">
2558          <div style="font-size:12px;font-weight:700;color:${tfCards.length > 0 ? 'var(--accent)' : 'var(--text3)'};">${tf}</div>
2559          ${tfCards.length > 0 ? '<div style="position:absolute;right:-5px;top:14px;width:8px;height:8px;border-radius:50%;background:var(--accent);"></div>' : ''}
2560        </div>
2561        <div style="flex:1;padding:8px 0 8px 16px;min-height:40px;">
2562          ${tfCards.map(c => `<div style="font-size:12px;color:var(--text2);padding:4px 0;">${c.icon} ${c.titulo}</div>`).join('') || '<div style="font-size:11px;color:var(--text3);padding:4px 0;">—</div>'}
2563        </div>`;
2564      timelineEl.appendChild(row);
2565    });
2566  }
2567}
2568
2569function sincBriefingByIndex(idx) {
2570  const card = (window._sincCards || [])[idx];
2571  if (!card) return;
2572  sincBriefing(idx, card.titulo, card.acao, card.tipo, (card.segmentos || []).join(', '));
2573}
2574
2575async function sincBriefing(idx, titulo, acao, tipo, segmentos) {
2576  const targetDiv = document.getElementById('sinc-briefing-' + idx);
2577  if (!targetDiv) { console.warn('[Briefing] Div not found: sinc-briefing-' + idx); return; }
2578  
2579  // Toggle: if already visible, collapse it
2580  if (targetDiv.style.display === 'block' && targetDiv.innerHTML && !targetDiv.innerHTML.includes(t('sinc_briefing_loading'))) {
2581    targetDiv.style.display = 'none';
2582    return;
2583  }
2584  targetDiv.style.display = 'block';
2585  targetDiv.innerHTML = '<div style="text-align:center;color:var(--text3);font-size:12px;">'+t('sinc_briefing_loading')+'</div>';
2586  
2587  const lang = G.lang || 'pt';
2588  const dest = G.dest || 'destino';
2589  const langName = {pt:'Português',en:'English',es:'Español',fr:'Français'}[lang] || 'Português';
2590  
2591  const prompt = await getPrompt('marketing-briefing', { dest, titulo, acao, tipo, segmentos, langName });
2592
2593  try {
2594    const raw = await callAIGlobal(prompt, 16384, true, { slug: 'marketing-briefing' });
2595    
2596    // Handle case where AI returns JSON instead of formatted text
2597    let displayText = raw;
2598    try {
2599      const jsonData = JSON.parse(raw.replace(/```json|```/g,'').trim());
2600      // Convert JSON to formatted text
2601      const formatValue = (v) => {
2602        if (Array.isArray(v)) return v.map(item => typeof item === 'object' ? Object.values(item).join(' — ') : item).join('<br>');
2603        if (typeof v === 'object' && v !== null) return Object.entries(v).map(([k,val]) => `<strong>${k}:</strong> ${val}`).join('<br>');
2604        return String(v);
2605      };
2606      displayText = Object.entries(jsonData).map(([key, val]) => {
2607        const label = key.replace(/_/g,' ').replace(/\b\w/g, c => c.toUpperCase());
2608        const emoji = key.includes('briefing') || key.includes('BRIEFING') ? '📋' : 
2609                      key.includes('objetivo') || key.includes('OBJETIVO') ? '🎯' :
2610                      key.includes('publico') || key.includes('PÚBLICO') ? '👥' :
2611                      key.includes('canal') || key.includes('CANAL') ? '📱' :
2612                      key.includes('copy') || key.includes('COPY') ? '✍️' :
2613                      key.includes('roteiro') || key.includes('ROTEIRO') ? '🎬' :
2614                      key.includes('orcamento') || key.includes('ORÇAMENTO') ? '💰' :
2615                      key.includes('duracao') || key.includes('DURAÇÃO') ? '📅' :
2616                      key.includes('kpi') || key.includes('KPI') ? '📊' : '•';
2617        return `<div style="margin-bottom:12px;"><div style="font-size:14px;font-weight:700;margin-bottom:4px;">${emoji} ${label}</div><div style="font-size:13px;color:var(--text2);padding-left:8px;">${formatValue(val)}</div></div>`;
2618      }).join('');
2619    } catch(e) {
2620      // Not JSON — format as text
2621      displayText = raw;
2622    }
2623    
2624    const formatted = displayText.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
2625      .replace(/\n/g, '<br>')
2626      .replace(/📋|🎯|👥|📱|✍️|🎬|💰|📅|📊/g, match => `<span style="font-size:16px;">${match}</span>`);
2627    
2628    targetDiv.innerHTML = `
2629      <div style="font-size:13px;color:var(--text2);line-height:1.7;">${formatted}</div>
2630      <div style="display:flex;gap:8px;margin-top:12px;">
2631        <button onclick="navigator.clipboard.writeText(this.closest('[id^=sinc-briefing]').querySelector('div').innerText);this.textContent='${t('sinc_briefing_copied')}';setTimeout(()=>this.textContent='${t('sinc_briefing_copy_btn')}',2000)" style="padding:8px 16px;border-radius:8px;border:1px solid var(--accent);background:transparent;color:var(--accent);font-size:11px;cursor:pointer;">${t('sinc_briefing_copy_btn')}</button>
2632      </div>`;
2633  } catch(e) {
2634    targetDiv.innerHTML = `<div style="color:var(--red);font-size:12px;">${t('sinc_briefing_error_prefix')}${e.message}</div>`;
2635  }
2636}
2637
2638async function sincStress(scenario) {
2639  const resultEl = document.getElementById('sinc-stress-result');
2640  if (!resultEl) return;
2641  resultEl.style.display = 'block';
2642  resultEl.innerHTML = '<div style="text-align:center;color:var(--text3);">'+t('sinc_stress_loading')+'</div>';
2643  
2644  // Cenários dinâmicos baseados nos dados macro reais
2645  var macroData = {};
2646  try { macroData = JSON.parse(localStorage.getItem('cp_macro_data') || '{}'); } catch(e) {}
2647  var brentAtual = macroData.brent || 80;
2648  var dolarAtual = macroData.dolar || 5.50;
2649  var brentStress = Math.round(brentAtual * 1.3); // +30%
2650  var dolarStress = (dolarAtual * 1.2).toFixed(2); // +20%
2651
2652  const scenarios = {
2653    brent100: 'O Petróleo Brent ultrapassou US$' + brentStress + '/barril (atual: US$' + brentAtual + '). Custos de passagens aéreas subiram significativamente. O que muda no plano de ação?',
2654    dolar6: 'O Dólar ultrapassou R$' + dolarStress + ' (atual: R$' + dolarAtual + '). Como isso afeta cada mercado emissor e o que devemos ajustar nas campanhas?',
2655    crise: 'A crise geopolítica no Oriente Médio se agravou significativamente. Rotas aéreas foram cortadas. Que ações emergenciais tomar?',
2656    viral: 'Um vídeo viral sobre o destino atingiu 10 milhões de views no TikTok. Como capitalizar nos próximos 7 dias?',
2657  };
2658  
2659  try {
2660    const prompt = await getPrompt('stress-simulator', {
2661      dest: G.dest, scenario: scenarios[scenario], macroContext: macroGetContextString(), lang: G.lang
2662    });
2663    console.log('📝 [stress] Prompt carregado do banco');
2664    const raw = await callAIGlobal(prompt, 600, false, { slug: 'stress-simulator' });
2665    
2666    // Handle JSON response from some providers
2667    let displayText = raw;
2668    try {
2669      const jsonData = JSON.parse(raw.replace(/```json|```/g,'').trim());
2670      // Convert JSON to readable text
2671      const flatten = (obj, prefix='') => {
2672        let result = '';
2673        for (const [key, val] of Object.entries(obj)) {
2674          const label = key.replace(/_/g,' ').replace(/\b\w/g, c => c.toUpperCase());
2675          if (typeof val === 'object' && val !== null && !Array.isArray(val)) {
2676            result += `<div style="margin-top:10px;"><strong>${label}:</strong></div>` + flatten(val, '  ');
2677          } else if (Array.isArray(val)) {
2678            result += `<div style="margin-top:6px;"><strong>${label}:</strong></div>` + val.map(v => `<div style="padding-left:12px;">• ${typeof v === 'object' ? Object.values(v).join(' — ') : v}</div>`).join('');
2679          } else {
2680            result += `<div style="margin-top:6px;"><strong>${label}:</strong> ${val}</div>`;
2681          }
2682        }
2683        return result;
2684      };
2685      displayText = flatten(jsonData);
2686    } catch(e) { /* not JSON, use as-is */ }
2687    
2688    resultEl.innerHTML = `<div style="font-size:13px;color:var(--text2);line-height:1.7;">${displayText.replace(/\*\*(.*?)\*\*/g,'<strong style="color:var(--text);">$1</strong>').replace(/\n/g,'<br>')}</div>`;
2689  } catch(e) {
2690    resultEl.innerHTML = `<div style="color:var(--red);">${t('sinc_stress_error_prefix')}${e.message}</div>`;
2691  }
2692}
2693
2694// Main Oracle invocation
2695
2696
2697// Continuous chat with Oracle — REMOVED, replaced by Pulse AI Widget
2698
2699// Reset Oracle to initial state
2700async function oracleGenerateActionPlan() {
2701  const btn = document.getElementById('oracle-action-plan-btn');
2702  const contentEl = document.getElementById('oracle-action-plan-content');
2703  btn.disabled = true;
2704  btn.textContent = '⏳ Gerando plano de ação...';
2705  contentEl.style.display = 'block';
2706  contentEl.innerHTML = '<div style="text-align:center;padding:20px;color:var(--text3);font-size:13px;">⏳ Analisando o debate e criando plano executivo...</div>';
2707
2708  const lang = G.lang || 'pt';
2709  const langName = {pt:'Português',en:'English',es:'Español',fr:'Français'}[lang] || 'Português';
2710  const dest = G.dest || 'destino';
2711  const debateSummary = oracleDebateHistory.map(m => m.content).join('\n');
2712  const synthesis = document.getElementById('consensus-synthesis')?.textContent || '';
2713
2714  const prompt = await getPrompt('oracle-action-plan', { dest, synthesis, debateSummary, langName });
2715
2716  try {
2717    const raw = await callAIGlobal(prompt, 2000, true, { slug: 'oracle-action-plan' });
2718    let clean = raw.replace(/```json|```/g, '').trim();
2719    // Robust JSON extraction — find the actual JSON object
2720    const jsonStart = clean.indexOf('{');
2721    const jsonEnd = clean.lastIndexOf('}');
2722    if (jsonStart >= 0 && jsonEnd > jsonStart) {
2723      clean = clean.substring(jsonStart, jsonEnd + 1);
2724    }
2725    const plan = JSON.parse(clean);
2726
2727    const custoColor = c => c === 'grátis' || c === 'Grátis' ? 'var(--green)' : c === 'baixo' || c === 'Baixo' ? 'var(--blue-true)' : c === 'médio' || c === 'Médio' ? 'var(--orange)' : 'var(--red)';
2728    const renderActions = (actions, showIndicador) => actions.map(a => `
2729      <div style="padding:12px;border-radius:10px;background:var(--surface);border:1px solid var(--border);margin-bottom:8px;">
2730        <div style="font-size:13px;font-weight:600;color:var(--text1);margin-bottom:6px;">🎯 ${a.acao}</div>
2731        <div style="display:flex;gap:12px;flex-wrap:wrap;font-size:11px;color:var(--text3);">
2732          <span>👤 ${a.responsavel}</span>
2733          <span style="color:${custoColor(a.custo)};font-weight:600;">💰 ${a.custo}</span>
2734          <span>📊 ${a.resultado || a.indicador || ''}</span>
2735        </div>
2736      </div>
2737    `).join('');
2738
2739    contentEl.innerHTML = `
2740      <div style="border-radius:14px;overflow:hidden;border:1px solid rgba(139,92,246,0.3);">
2741        <!-- Header -->
2742        <div style="padding:20px;background:linear-gradient(135deg,rgba(139,92,246,0.1),rgba(217,70,239,0.05));">
2743          <div style="font-family:'Syne',sans-serif;font-size:18px;font-weight:800;color:var(--text);margin-bottom:6px;">📋 ${plan.titulo}</div>
2744          <div style="font-size:13px;color:var(--text2);line-height:1.6;">${plan.resumo}</div>
2745        </div>
2746
2747        <!-- Quick Win -->
2748        <div style="padding:14px 20px;background:rgba(16,185,129,0.08);border-top:1px solid rgba(16,185,129,0.2);">
2749          <div style="font-size:11px;font-weight:700;color:var(--green);text-transform:uppercase;letter-spacing:1px;">⚡ Quick Win — Faça Hoje</div>
2750          <div style="font-size:14px;color:var(--text1);margin-top:4px;font-weight:600;">${plan.quick_win}</div>
2751        </div>
2752
2753        <div style="padding:20px;">
2754          <!-- Semana 1-2 -->
2755          <div style="margin-bottom:20px;">
2756            <div style="font-size:12px;font-weight:700;color:var(--violet);text-transform:uppercase;letter-spacing:1px;margin-bottom:10px;">📅 Semana 1-2 — Ações Imediatas</div>
2757            ${renderActions(plan.semana_1_2 || [])}
2758          </div>
2759
2760          <!-- Mês 1 -->
2761          <div style="margin-bottom:20px;">
2762            <div style="font-size:12px;font-weight:700;color:var(--blue-true);text-transform:uppercase;letter-spacing:1px;margin-bottom:10px;">📅 Mês 1 — Entregas</div>
2763            ${renderActions(plan.mes_1 || [])}
2764          </div>
2765
2766          <!-- Mês 2-3 -->
2767          <div style="margin-bottom:20px;">
2768            <div style="font-size:12px;font-weight:700;color:var(--orange);text-transform:uppercase;letter-spacing:1px;margin-bottom:10px;">📅 Mês 2-3 — Consolidação</div>
2769            ${renderActions(plan.mes_2_3 || [], true)}
2770          </div>
2771
2772          <!-- KPIs -->
2773          <div style="margin-bottom:16px;">
2774            <div style="font-size:12px;font-weight:700;color:var(--green);text-transform:uppercase;letter-spacing:1px;margin-bottom:10px;">📊 Indicadores de Sucesso</div>
2775            ${(plan.kpis || []).map(k => `
2776              <div style="display:flex;align-items:center;gap:10px;padding:8px 12px;border-radius:8px;background:var(--surface);border:1px solid var(--border);margin-bottom:6px;">
2777                <div style="font-size:13px;flex:1;color:var(--text2);">${k.metrica}</div>
2778                <div style="font-size:13px;font-weight:700;color:var(--green);">${k.meta}</div>
2779                <div style="font-size:11px;color:var(--text3);">${k.prazo}</div>
2780              </div>
2781            `).join('')}
2782          </div>
2783
2784          <!-- Risco -->
2785          <div style="padding:12px 16px;border-radius:10px;background:rgba(239,68,68,0.06);border:1px solid rgba(239,68,68,0.2);">
2786            <div style="font-size:11px;font-weight:700;color:var(--red);margin-bottom:4px;">⚠️ RISCO SE NÃO AGIR</div>
2787            <div style="font-size:13px;color:var(--text2);">${plan.risco_principal}</div>
2788          </div>
2789        </div>
2790
2791        <!-- Export -->
2792        <div style="padding:14px 20px;border-top:1px solid var(--border);display:flex;gap:8px;">
2793          <button onclick="oracleExportPlan()" style="flex:1;padding:10px;border-radius:10px;border:none;background:linear-gradient(135deg,var(--violet),var(--magenta));color:white;font-size:13px;font-weight:600;cursor:pointer;">📄 Exportar PDF</button>
2794          <button onclick="oracleGenerateActionPlan()" style="padding:10px 16px;border-radius:10px;border:1px solid var(--border);background:var(--surface);color:var(--text2);font-size:13px;
2794cursor:pointer;">🔄 Regenerar</button>
2795        </div>
2796      </div>`;
2797
2798    // Store plan for export
2799    window._oracleActionPlan = plan;
2800
2801  } catch(e) {
2802    contentEl.innerHTML = '<div style="text-align:center;padding:20px;color:var(--red);font-size:13px;">❌ Erro ao gerar plano: ' + e.message + '</div>';
2803  }
2804
2805  btn.disabled = false;
2806  btn.textContent = '📋 Gerar Plano de Ação Executivo';
2807}
2808
2809
2810
2811// ===== RECENT CONCORRENTES =====
2812function obLoadRecentConc() {
2813  try {
2814    const saved = JSON.parse(localStorage.getItem('ob-recent-dests') || '[]');
2815    // Also include any manually typed concorrentes
2816    const concSaved = JSON.parse(localStorage.getItem('ob-recent-concs') || '[]');
2817    const all = [...new Set([...concSaved, ...saved])].slice(0, 6);
2818    const panel = document.getElementById('ob-recent-conc-panel');
2819    const list = document.getElementById('ob-recent-conc-list');
2820    if (!panel || !list || !all.length) return;
2821    list.innerHTML = all.map(d =>
2822      `<button onclick="obFillConc('${d.replace(/'/g,"\'")}',event)" style="padding:5px 12px;border-radius:100px;border:1px solid var(--border);background:var(--surface2);color:var(--text2);font-size:12px;cursor:pointer;">${d}</button>`
2823    ).join('');
2824    panel.style.display = 'block';
2825  } catch(e) {}
2826}
2827
2828function obFillConc(val, evt) {
2829  // Fill next empty concorrente field
2830  for (let i = 1; i <= 3; i++) {
2831    const el = document.getElementById('ob-dest-conc' + i);
2832    if (el && !el.value.trim()) { el.value = val; break; }
2833  }
2834}
2835
2836function obSaveRecentConc(val, n) {
2837  val = (val || '').trim();
2838  if (!val || val.length < 3) return;
2839  try {
2840    const saved = JSON.parse(localStorage.getItem('ob-recent-concs') || '[]');
2841    const cleaned = saved.filter(x => x !== val && !val.startsWith(x) && !x.startsWith(val));
2842    const updated = [val, ...cleaned].slice(0, 6);
2843    localStorage.setItem('ob-recent-concs', JSON.stringify(updated));
2844  } catch(e) {}
2845}
2846
2847// ===== INTEGRAÇÕES EXTRAÍDAS PARA js/integrations/ =====
2848// serpapi.js, weather.js, exchangerate.js, apify.js, newsapi.js
2849// smart-tour-api.js, google-trends.js, tripadvisor.js, tavily.js, brave-search.js
2850// Carregadas via <script> no HTML antes do app.js
2851// =========================================================
2852
2853// ===== GOOGLE PLACES INTEGRATION =====
2854
2855// ===== INTEGRAÇÕES EXTRAÍDAS PARA js/integrations/ =====
2856// serpapi.js, weather.js, exchangerate.js, apify.js, newsapi.js
2857// smart-tour-api.js, google-trends.js, tripadvisor.js, tavily.js, brave-search.js
2858// Carregadas via <script> no HTML antes do app.js
2859// =========================================================
2860
2861// ===== CURATED INFLUENCERS — SUL =====
2862var CURATED_INFLUENCERS_SUL = []; // Dados migrados para tabela curated_influencers no Supabase
2863
2864
2865// ===== CURATED INFLUENCERS — NORDESTE =====
2866var CURATED_INFLUENCERS_NORDESTE = []; // Dados migrados para tabela curated_influencers no Supabase
2867
2868
2869// ===== CURATED INFLUENCERS — CENTRO-OESTE =====
2870var CURATED_INFLUENCERS_CO = []; // Dados migrados para tabela curated_influencers no Supabase
2871
2872
2873// ===== CURATED INFLUENCERS — NORTE =====
2874var CURATED_INFLUENCERS_NORTE = []; // Dados migrados para tabela curated_influencers no Supabase
2875
2876
2877// ===== CURATED INFLUENCERS — NACIONAIS (TOP + POR SETOR) =====
2878var CURATED_INFLUENCERS_NACIONAIS = []; // Dados migrados para tabela curated_influencers no Supabase
2879
2880
2881// ===== CURATED INFLUENCERS — EXTRAS NACIONAIS =====
2882var CURATED_INFLUENCERS_EXTRAS = []; // Dados migrados para tabela curated_influencers no Supabase
2883
2884
2885// ===== CURATED INFLUENCERS — NACIONAIS COMPLEMENTO =====
2886var CURATED_INFLUENCERS_EXTRA = []; // Dados migrados para tabela curated_influencers no Supabase
2887
2888// ===== INTEGRAÇÕES EXTRAÍDAS PARA js/integrations/ =====
2889// serpapi.js, weather.js, exchangerate.js, apify.js, newsapi.js
2890// smart-tour-api.js, google-trends.js, tripadvisor.js, tavily.js, brave-search.js
2891// Carregadas via <script> no HTML antes do app.js
2892// =========================================================
2893
2894// ===== CURATED INFLUENCERS — SUDESTE =====
2895var CURATED_INFLUENCERS_SUDESTE = []; // Dados migrados para tabela curated_influencers no Supabase
2896
2897// ===== CARREGAR INFLUENCIADORES DO SUPABASE =====
2898
2899/**
2900 * Busca influenciadores curados do Supabase filtrados por país/estado do destino.
2901 * Retorna: regionais (do estado) + nacionais (state = 'BR' ou vazio).
2902 * Cache por sessão: se já buscou para o mesmo estado, retorna do cache.
2903 */
2904var _curatedCache = {};
2905var _curatedInfluencersLoadFailed = false; // Flag para sinalizar falha total
2906
2907async function loadCuratedInfluencersForDest(dest) {
2908  // Parse destino: "Florianópolis, SC, Brasil" → country=BR, state=SC
2909  var parts = (dest || '').split(',').map(function(s) { return s.trim(); });
2910  var state = '', country = 'BR';
2911  if (parts.length >= 3) { country = parts[parts.length - 1]; state = parts[parts.length - 2]; }
2912  else if (parts.length === 2) { state = parts[1]; }
2913  var st = state.toUpperCase().trim();
2914  var cacheKey = st || '_all';
2915
2916  // Cache hit
2917  if (_curatedCache[cacheKey]) {
2918    console.log('[Influencers] 📦 Cache hit para estado:', st || 'nacional');
2919    _curatedInfluencersLoadFailed = false;
2920    return _curatedCache[cacheKey];
2921  }
2922
2923  var result = await withRetry(async function() {
2924    var supabase = await getSupabase();
2925
2926    var query = supabase
2927      .from('curated_influencers')
2928      .select('nome, handle, plataforma, seguidores, country, state, sector, nicho, evidencia')
2929      .eq('is_active', true)
2930      .order('seguidores_num', { ascending: false });
2931
2932    if (st && st !== 'BR') {
2933      query = query.or('state.eq.' + st + ',state.eq.BR,state.eq.,state.is.null');
2934    }
2935
2936    var resp = await query.limit(50);
2937    if (resp.error) throw new Error(resp.error.message || 'Supabase error');
2938    if (!resp.data) throw new Error('Nenhum dado retornado');
2939    return resp.data;
2940  }, { retries: 3, label: 'Influencers' });
2941
2942  if (!result.ok) {
2943    _curatedInfluencersLoadFailed = true;
2944    console.error('[Influencers] ❌ Falha total ao carregar influenciadores — seção será desabilitada');
2945    return [];
2946  }
2947
2948  _curatedInfluencersLoadFailed = false;
2949  var data = result.data;
2950
2951  // Distribuir nos arrays globais para compatibilidade com serpapi.js e renderInfluence
2952  var regionais = data.filter(function(i) { return i.state && i.state !== 'BR' && i.state !== ''; });
2953  var nacionais = data.filter(function(i) { return !i.state || i.state === 'BR' || i.state === ''; });
2954
2955    // Preencher arrays globais
2956    window.CURATED_INFLUENCERS_SUDESTE = [];
2957    window.CURATED_INFLUENCERS_SUL = [];
2958    window.CURATED_INFLUENCERS_NORDESTE = [];
2959    window.CURATED_INFLUENCERS_CO = [];
2960    window.CURATED_INFLUENCERS_NORTE = [];
2961    window.CURATED_INFLUENCERS_NACIONAIS = nacionais;
2962    window.CURATED_INFLUENCERS_EXTRA = [];
2963
2964    var regionMap = {
2965      SP:'SUDESTE', RJ:'SUDESTE', MG:'SUDESTE', ES:'SUDESTE',
2966      PR:'SUL', SC:'SUL', RS:'SUL',
2967      BA:'NORDESTE', CE:'NORDESTE', PE:'NORDESTE', RN:'NORDESTE', PB:'NORDESTE', AL:'NORDESTE', SE:'NORDESTE', MA:'NORDESTE', PI:'NORDESTE',
2968      DF:'CO', GO:'CO', MT:'CO', MS:'CO',
2969      AM:'NORTE', PA:'NORTE', AC:'NORTE', RO:'NORTE', RR:'NORTE', AP:'NORTE', TO:'NORTE',
2970    };
2971    regionais.forEach(function(item) {
2972      var region = regionMap[(item.state || '').toUpperCase()];
2973      if (region === 'SUDESTE') window.CURATED_INFLUENCERS_SUDESTE.push(item);
2974      else if (region === 'SUL') window.CURATED_INFLUENCERS_SUL.push(item);
2975      else if (region === 'NORDESTE') window.CURATED_INFLUENCERS_NORDESTE.push(item);
2976      else if (region === 'CO') window.CURATED_INFLUENCERS_CO.push(item);
2977      else if (region === 'NORTE') window.CURATED_INFLUENCERS_NORTE.push(item);
2978    });
2979
2980    _curatedCache[cacheKey] = data;
2981    console.log('[Influencers] ✅ ' + regionais.length + ' regionais + ' + nacionais.length + ' nacionais carregados do Supabase (estado: ' + (st || 'todos') + ')');
2982    return data;
2983}
2984
2985// ===== ADMIN PANEL =====
2986function adminTab(tab) {
2987  ['apis','provider','plan'].forEach(t => {
2988    document.getElementById('admin-panel-'+t).style.display = t===tab ? 'block' : 'none';
2989    const btn = document.getElementById('admin-tab-'+t);
2990    if (btn) {
2991      btn.style.color = t===tab ? 'var(--accent)' : 'var(--text2)';
2992      btn.style.borderBottom = t===tab ? '2px solid var(--accent)' : '2px solid transparent';
2993    }
2994  });
2995  if (tab === 'apis') adminRefreshStatus();
2996}
2997
2998var ADMIN_KEY_MAP = {
2999  serpapi:  { label: 'SerpAPI',           id: 'serp-status' },
3000  places:   { label: 'Google Places',     id: 'places-status' },
3001  youtube:  { label: 'YouTube Data',      id: 'youtube-status' },
3002  qwen:     { label: 'Qwen (Alibaba)',    id: 'qwen-status' },
3003  deepseek: { label: 'DeepSeek',          id: 'deepseek-status' },
3004  mistral:  { label: 'Mistral (França)',   id: 'mistral-status' },
3005  exchange: { label: 'ExchangeRate',      id: 'exchange-status' },
3006  news:     { label: 'NewsAPI',           id: 'news-status' },
3007  apify:    { label: 'Apify',             id: 'apify-status' },
3008  reddit:   { label: 'Reddit',            id: 'reddit-status' },
3009  tavily:   { label: 'Tavily',            id: 'tavily-status' },
3010  brave:    { label: 'Brave Search',      id: 'brave-status' },
3011};
3012
3013function adminKeyChanged(name) {
3014  // visual feedback while typing
3015}
3016
3017function adminSaveKey(name) {
3018  const val = document.getElementById('key-'+name)?.value?.trim();
3019  if (!val) return;
3020  localStorage.setItem('cp_key_'+name, val);
3021  adminRefreshStatus();
3022  const statusEl = document.getElementById(ADMIN_KEY_MAP[name]?.id);
3023  if (statusEl) {
3024    statusEl.textContent = '✅ Salva';
3025    statusEl.style.background = 'var(--accent-dim)';
3026    statusEl.style.color = 'var(--accent)';
3027  }
3028}
3029
3030async function adminTestKey(name) {
3031  const key = localStorage.getItem('cp_key_'+name);
3032  if (!key) { alert('Salve a key primeiro.'); return; }
3033  const statusEl = document.getElementById(ADMIN_KEY_MAP[name]?.id);
3034  if (statusEl) { statusEl.textContent = '⏳ Testando...'; }
3035
3036  try {
3037    if (name === 'serpapi') {
3038      const supabase = await getSupabase();
3039      const { data: { session } } = await supabase.auth.getSession();
3040      if (!session) throw new Error('Não autenticado');
3041      const res = await fetch(`${SUPABASE_URL}/functions/v1/api-proxy`, {
3042        method: 'POST',
3043        headers: {
3044          'Content-Type': 'application/json',
3045          'Authorization': `Bearer ${session.access_token}`,
3046          'apikey': SUPABASE_ANON_KEY
3047        },
3048        body: JSON.stringify({ service: 'serpapi', params: { query: 'turismo', num: 1 } })
3049      });
3050      const d = await res.json();
3051      if (d.error) throw new Error(d.error);
3052      if (statusEl) { statusEl.textContent = '✅ Funcionando'; statusEl.style.color='var(--accent)'; statusEl.style.background='var(--accent-dim)'; }
3053    } else if (name === 'exchange') {
3054      const res = await fetch(`https://v6.exchangerate-api.com/v6/${key}/latest/USD`);
3055      const d = await res.json();
3056      if (d.result !== 'success') throw new Error(d['error-type']);
3057      if (statusEl) { statusEl.textContent = '✅ Funcionando'; statusEl.style.color='var(--accent)'; statusEl.style.background='var(--accent-dim)'; }
3058    } else if (name === 'news') {
3059      const supabase = await getSupabase();
3060      const { data: { session } } = await supabase.auth.getSession();
3061      if (!session) throw new Error('Não autenticado');
3062      const res = await fetch(`${SUPABASE_URL}/functions/v1/api-proxy`, {
3063        method: 'POST',
3064        headers: {
3065          'Content-Type': 'application/json',
3066          'Authorization': `Bearer ${session.access_token}`,
3067          'apikey': SUPABASE_ANON_KEY
3068        },
3069        body: JSON.stringify({ service: 'news', params: { query: 'turismo', pageSize: 1, endpoint: 'everything' } })
3070      });
3071      const d = await res.json();
3072      if (d.error) throw new Error(d.error);
3073      if (statusEl) { statusEl.textContent = '✅ Funcionando'; statusEl.style.color='var(--accent)'; statusEl.style.background='var(--accent-dim)'; }
3074    } else if (name === 'apify') {
3075      const supabase = await getSupabase();
3076      const { data: { session } } = await supabase.auth.getSession();
3077      if (!session) throw new Error('Não autenticado');
3078      const res = await fetch(`${SUPABASE_URL}/functions/v1/api-proxy`, {
3079        method: 'POST',
3080        headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${session.access_token}`, 'apikey': SUPABASE_ANON_KEY },
3081        body: JSON.stringify({ service: 'apify', params: { actorId: 'apify~web-scraper', input: {}, sync: false } })
3082      });
3083      const d = await res.json();
3084      if (d.error) throw new Error(d.error);
3085      if (statusEl) { statusEl.textContent = '✅ Funcionando'; statusEl.style.color='var(--accent)'; statusEl.style.background='var(--accent-dim)'; }
3086    } else if (name === 'tavily') {
3087      const supabase = await getSupabase();
3088      const { data: { session } } = await supabase.auth.getSession();
3089      if (!session) throw new Error('Não autenticado');
3090      const res = await fetch(`${SUPABASE_URL}/functions/v1/api-proxy`, {
3091        method: 'POST',
3092        headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${session.access_token}`, 'apikey': SUPABASE_ANON_KEY },
3093        body: JSON.stringify({ service: 'tavily', params: { query: 'turismo Brasil', max_results: 1 } })
3094      });
3095      const d = await res.json();
3096      if (d.error) throw new Error(d.error);
3097      if (statusEl) { statusEl.textContent = '✅ Funcionando'; statusEl.style.color='var(--accent)'; statusEl.style.background='var(--accent-dim)'; }
3098    } else if (name === 'brave') {
3099      const supabase = await getSupabase();
3100      const { data: { session } } = await supabase.auth.getSession();
3101      if (!session) throw new Error('Não autenticado');
3102      const res = await fetch(`${SUPABASE_URL}/functions/v1/api-proxy`, {
3103        method: 'POST',
3104        headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${session.access_token}`, 'apikey': SUPABASE_ANON_KEY },
3105        body: JSON.stringify({ service: 'brave', params: { query: 'turismo Brasil', count: 1 } })
3106      });
3107      const d = await res.json();
3108      if (d.error) throw new Error(d.error);
3109      if (statusEl) { statusEl.textContent = '✅ Funcionando'; statusEl.style.color='var(--accent)'; statusEl.style.background='var(--accent-dim)'; }
3110    } else {
3111      if (statusEl) { statusEl.textContent = '⚠️ Teste manual'; statusEl.style.color='var(--yellow)'; }
3112    }
3113  } catch(e) {
3114    if (statusEl) { statusEl.textContent = '❌ Erro'; statusEl.style.color='var(--red)'; statusEl.style.background='rgba(239,68,68,0.1)'; }
3115    alert('Erro ao testar ' + name + ': ' + e.message);
3116  }
3117}
3118
3119function adminRefreshStatus() {
3120  // Update all status badges
3121  Object.entries(ADMIN_KEY_MAP).forEach(([name, cfg]) => {
3122    const key = localStorage.getItem('cp_key_'+name);
3123    const statusEl = document.getElementById(cfg.id);
3124    const inputEl = document.getElementById('key-'+name);
3125    if (key) {
3126      if (inputEl) inputEl.placeholder = '••••••••••••••••';
3127      if (statusEl) {
3128        statusEl.textContent = '✅ Configurada';
3129        statusEl.style.color = 'var(--accent)';
3130        statusEl.style.background = 'var(--accent-dim)';
3131      }
3132    } else {
3133      if (statusEl) {
3134        statusEl.textContent = 'Não configurada';
3135        statusEl.style.color = 'var(--text3)';
3136        statusEl.style.background = 'var(--surface2)';
3137      }
3138    }
3139  });
3140
3141  // Integration status bar
3142  const bar = document.getElementById('admin-integration-status');
3143  if (!bar) return;
3144  bar.innerHTML = '';
3145  Object.entries(ADMIN_KEY_MAP).forEach(([name, cfg]) => {
3146    const key = localStorage.getItem('cp_key_'+name);
3147    const dot = document.createElement('div');
3148    dot.style.cssText = `display:flex;align-items:center;gap:6px;padding:5px 12px;border-radius:100px;font-size:12px;background:${key?'var(--accent-dim)':'var(--surface)'};color:${key?'var(--accent)':'var(--text3)'};border:1px solid ${key?'rgba(34,211,238,0.25)':'var(--border)'};`;
3149    dot.innerHTML = `${key?'✅':'⭕'} ${cfg.label}`;
3150    bar.appendChild(dot);
3151  });
3152}
3153
3154function adminSetProvider(prov) {
3155  ['claude','openai','gemini','grok'].forEach(p => {
3156    const el = document.getElementById('adm-prov-'+p);
3157    if (el) { el.className = p===prov ? 'ob-period-btn sel' : 'ob-period-btn'; }
3158  });
3159  const labels = {claude:'API Key Claude (sk-ant-...)', openai:'API Key OpenAI (sk-...)', gemini:'API Key Gemini (AIza...)', grok:'API Key Grok (xai-...)'};
3160  const lbl = document.getElementById('adm-api-label');
3161  if (lbl) lbl.textContent = labels[prov] || 'API Key';
3162  const savedKey = localStorage.getItem('cp_key_' + prov) || '';
3163  const inp = document.getElementById('adm-api-key');
3164  if (inp) inp.value = savedKey;
3165  const badge = document.getElementById('adm-key-badge');
3166  if (badge) { badge.style.display = savedKey ? 'inline-flex' : 'none'; }
3167  // Provider global agora vive em system_settings (Supabase). Atualiza
3168  // em background — UI permanece responsiva. Espelha em window._GLOBAL_PROVIDER.
3169  if (typeof setGlobalAiProvider === 'function') setGlobalAiProvider(prov);
3170  adminShowKeysStatus();
3171}
3172
3173function adminSaveProvider() {
3174  const prov = window._GLOBAL_PROVIDER || 'openai';
3175  const key = document.getElementById('adm-api-key')?.value?.trim();
3176  if (key) {
3177    localStorage.setItem('cp_key_' + prov, key);
3178    localStorage.setItem('cp_key_ai', key);
3179    const badge = document.getElementById('adm-key-badge');
3180    if (badge) { badge.style.display = 'inline-flex'; }
3181  }
3182  adminShowKeysStatus();
3183  const saved = ['claude','openai','gemini','grok'].filter(p => localStorage.getItem('cp_key_'+p));
3184  alert('✅ Chave do ' + prov + ' salva!\n\nProvedores configurados: ' + saved.join(', '));
3185}
3186
3187function adminShowKeysStatus() {
3188  const statusEl = document.getElementById('adm-keys-status');
3189  if (!statusEl) return;
3190  const providers = {claude:'Claude', openai:'GPT-4o', gemini:'Gemini', grok:'Grok'};
3191  const colors = {claude:'#8b5cf6', openai:'#10b981', gemini:'#3b82f6', grok:'#f59e0b'};
3192  statusEl.innerHTML = Object.entries(providers).map(([key, label]) => {
3193    const hasKey = !!localStorage.getItem('cp_key_' + key);
3194    return `<span style="padding:3px 10px;border-radius:100px;font-size:11px;font-weight:600;
3195      background:${hasKey ? colors[key]+'22' : 'var(--surface2)'};
3196      color:${hasKey ? colors[key] : 'var(--text3)'};
3197      border:1px solid ${hasKey ? colors[key]+'44' : 'var(--border)'};">
3198      ${hasKey ? '✅' : '○'} ${label}</span>`;
3199  }).join('');
3200}
3201
3202
3203function adminDeleteKey(name) {
3204  if (!confirm('Remover a key de ' + name + '?')) return;
3205  localStorage.removeItem('cp_key_' + name);
3206  const input = document.getElementById('key-' + name);
3207  if (input) input.value = '';
3208  adminShowKeysStatus();
3209  console.log('[Admin] ✅ Key deleted:', name);
3210}
3211
3212function adminLoadPage() {
3213  adminRefreshStatus();
3214  const prov = window._GLOBAL_PROVIDER || 'openai';
3215  adminSetProvider(prov);
3216  adminShowKeysStatus();
3217}
3218
3219// ===== RENDER =====
3220// Páginas que precisam ser pré-carregadas antes do renderAll
3221const RENDER_PAGES = [
3222  'dashboard','crisis','profile','benchmark','report',
3223  'sinc','heatmap','influence','viral','emissores',
3224  'social','felicidade','can','input'
3225];
3226
3227async function renderAll(dest, concs, d, periodo) {
3228  const safe = (name, fn) => { try { fn(); } catch(e) { console.error('[render:'+name+']', e); } };
3229
3230  // Carrega todos os componentes necessários em paralelo antes de tentar acessar o DOM
3231  if (typeof loadComponent === 'function') {
3232    await Promise.all(RENDER_PAGES.map(p => loadComponent(p).catch(e => {
3233      console.warn('[renderAll] Falha ao carregar componente:', p, e.message);
3234    }
3234)));
3235  }
3236
3237  safe('Dashboard',   () => renderDashboard(dest, concs, d));
3238  safe('Opps',        () => renderClickableOpportunities(d));
3239  safe('Crisis',      () => renderCrisis(d));
3240  safe('Profile',     () => renderProfile(d));
3241  safe('Benchmark',   () => renderBenchmark(d));
3242  safe('Report',      () => renderReport(dest, d, periodo));
3243  safe('Sinc',        () => renderSincronicidade(d));
3244  safe('Heatmap',     () => renderHeatmap(d));
3245  safe('Influence',   () => renderInfluence(d));
3246  safe('Viral',       () => renderViral(d));
3247  safe('Emissores',   () => renderEmissores(d));
3248  safe('Social',      () => renderSocial(d));
3249  safe('Oracle',      () => renderOracle(d));
3250  safe('Felicidade',  () => renderFelicidade(d));
3251  safe('Can',         () => renderCan(d));
3252}
3253
3254function renderDashboard(dest, concs, d) {
3255  // O banner PRECISA do else: sem ele a crise da análise anterior ficava na tela ao
3256  // navegar o histórico (loadRecord não passa pelo _resetDashboardSkeleton), fazendo
3257  // uma cidade com crise "grudar" e reaparecer nas seguintes — contaminação visual.
3258  var _cb = document.getElementById('main-crisis-banner');
3259  var _ct = document.getElementById('main-crisis-text');
3260  if (d.crisis_level==='high'||d.crisis_level==='medium') {
3261    if (_cb) _cb.classList.remove('hidden');
3262    if (_ct) _ct.textContent = d.crisis_resumo || '';
3263  } else {
3264    if (_cb) _cb.classList.add('hidden');
3265    if (_ct) _ct.textContent = '';
3266  }
3267  const score = d.score||0;
3268  document.getElementById('kpi-score').textContent = score;
3269  document.getElementById('kpi-score-tag').textContent = score>=70?t('score_good'):score>=50?t('score_medium'):t('score_low');
3270  document.getElementById('kpi-score-tag').className = 'kpi-tag '+(score>=70?'tag-green':score>=50?'tag-blue':'tag-red');
3271  // KPI de tendência é CURTO (ex: "+18%"). A IA às vezes devolve uma frase descritiva;
3272  // 1) extrai o % inicial; 2) se já for curto, mantém; 3) senão, calcula a variação do
3273  // trend_data (1º vs último ponto da série principal). Nunca despeja a frase no card.
3274  var _trendRaw = String(d.tendencia_busca || '').trim();
3275  var _trendPct = _trendRaw.match(/[+\-]?\s*\d+(?:[.,]\d+)?\s*%/);
3276  var _trendVal;
3277  if (_trendPct) {
3278    _trendVal = _trendPct[0].replace(/\s+/g, '');
3279  } else if (_trendRaw && _trendRaw.length <= 14) {
3280    _trendVal = _trendRaw;
3281  } else {
3282    var _serie = (d.trend_data && d.trend_data[dest]) || [];
3283    if (_serie.length >= 2 && _serie[0]) {
3284      var _chg = Math.round((_serie[_serie.length - 1] - _serie[0]) / _serie[0] * 100);
3285      _trendVal = (_chg > 0 ? '+' : '') + _chg + '%';
3286    } else {
3287      _trendVal = '--';
3288    }
3289  }
3290  document.getElementById('kpi-trend').textContent = _trendVal;
3291  const td = d.tendencia_dir;
3292  document.getElementById('kpi-trend-tag').textContent = td==='up'?t('trend_up'):td==='down'?t('trend_down'):t('trend_neutral');
3293  document.getElementById('kpi-trend-tag').className='kpi-tag '+(td==='up'?'tag-green':td==='down'?'tag-red':'tag-muted');
3294  const pos = d.sentimento?.positivo||0;
3295  document.getElementById('kpi-sent').textContent = pos+'%';
3296  document.getElementById('kpi-sent-tag').textContent = pos>=70?t('sentiment_positive'):pos>=50?t('sentiment_mixed'):t('sentiment_negative');
3297  document.getElementById('kpi-sent-tag').className='kpi-tag '+(pos>=70?'tag-green':pos>=50?'tag-blue':'tag-red');
3298  document.getElementById('kpi-opp').textContent=(d.n_oportunidades||0)+' '+t('opps_active');
3299  renderTrend(dest, concs, d.trend_data);
3300  renderDonut(d.sentimento);
3301  document.getElementById('theme-cloud').innerHTML=(d.temas||[]).map(th=>{
3302    const c=th.sent==='pos'?'var(--accent)':th.sent==='neg'?'var(--red)':'var(--text3)';
3303    return `<div class="theme-pill"><div class="theme-dot" style="background:${c}"></div>${th.nome}<span class="theme-count">${th.count}</span></div>`;
3304  }).join('');
3305  renderDashboardInsights(d);
3306
3307  // Garante que os data-i18n do fragmento (incl. o card de clima) estejam no idioma
3308  // corrente — loadComponent() injeta o HTML mas não chama applyI18n() sozinho.
3309  if (typeof applyI18n === 'function') applyI18n();
3310
3311  // Auto-fetch weather data for the destination
3312  setTimeout(() => fetchWeatherData(), 500);
3313  // Dashboard não usa _hideModuleError → injeta o botão "Refazer análise" aqui.
3314  if (typeof _ensureRedoButton === 'function') _ensureRedoButton('dashboard');
3315}
3316
3317// ONDA B do dashboard: análise estratégica (insights). Renderiza o texto quando
3318// chega; mostra spinner enquanto window._bgLoading.dashInsights; senão, traço.
3319function renderDashboardInsights(d) {
3320  var el = document.getElementById('main-insights');
3321  if (!el) return;
3322  if (d && d.insights) {
3323    el.innerHTML = String(d.insights).replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>').replace(/\n/g, '<br>');
3324    return;
3325  }
3326  if (window._bgLoading && window._bgLoading.dashInsights) {
3327    el.innerHTML = '<span style="display:inline-flex;align-items:center;gap:8px;color:var(--text3);font-size:13px;"><span style="display:inline-block;width:14px;height:14px;border:2px solid var(--surface);border-top-color:var(--accent);border-radius:50%;animation:cp-spin 1s linear infinite;"></span>Gerando análise estratégica...</span>';
3328    return;
3329  }
3330  el.innerHTML = '<span style="color:var(--text3);font-size:13px;">—</span>';
3331}
3332
3333function renderTrend(dest, concs, td) {
3334  const svg=document.getElementById('trend-svg');
3335  var _tl=document.getElementById('trend-loading'); if(_tl) _tl.remove();
3336  svg.innerHTML='';
3337  const legend=document.getElementById('trend-legend');
3338  legend.innerHTML='';
3339  const colors=['#22d3ee','#8b5cf6','#f59e0b','#f59e0b'];
3340  const dests=[dest,...concs];
3341  const labels=td?.labels||[];
3342  const W=600,H=160,P=30,PB=28; // extra padding for x-axis labels
3343  // horizontal grid lines
3344  for(let i=0;i<=3;i++){
3345    const y=P+(H-P-PB)*i/3;
3346    const l=document.createElementNS('http://www.w3.org/2000/svg','line');
3347    l.setAttribute('x1',P);l.setAttribute('y1',y);l.setAttribute('x2',W-P);l.setAttribute('y2',y);
3348    l.setAttribute('stroke','rgba(255,255,255,0.04)');l.setAttribute('stroke-width','1');svg.appendChild(l);
3349  }
3350  // Update SVG viewBox to include label area
3351  svg.setAttribute('viewBox',`0 0 ${W} ${H}`);
3352  dests.forEach((d,i)=>{
3353    const vals=td?.[d]; if(!vals||!vals.length)return;
3354    const n=vals.length,mn=Math.min(...vals),mx=Math.max(...vals),rng=mx-mn||1;
3355    const pts=vals.map((v,j)=>{
3356      const x=P+(W-2*P)*j/(n-1);
3357      const y=P+(H-P-PB)*(1-(v-mn)/rng*0.85);
3358      return`${x},${y}`;
3359    }).join(' ');
3360    const firstPt=pts.split(' ')[0];const lastPt=pts.split(' ').slice(-1)[0];
3361    const fx=parseFloat(firstPt.split(',')[0]),fy=H-PB;
3362    const lx=parseFloat(lastPt.split(',')[0]);
3363    const area=document.createElementNS('http://www.w3.org/2000/svg','polygon');
3364    area.setAttribute('points',`${fx},${fy} ${pts} ${lx},${fy}`);
3365    area.setAttribute('fill',colors[i]);area.setAttribute('opacity',i===0?'0.08':'0.03');svg.appendChild(area);
3366    const poly=document.createElementNS('http://www.w3.org/2000/svg','polyline');
3367    poly.setAttribute('points',pts);poly.setAttribute('fill','none');poly.setAttribute('stroke',colors[i]);
3368    poly.setAttribute('stroke-width',i===0?'2.5':'1.5');poly.setAttribute('opacity',i===0?'1':'0.55');
3369    poly.setAttribute('stroke-linejoin','round');poly.setAttribute('stroke-linecap','round');
3370    svg.appendChild(poly);
3371    // dots on main line
3372    if(i===0) {
3373      vals.forEach((v,j)=>{
3374        const x=P+(W-2*P)*j/(n-1);
3375        const y=P+(H-P-PB)*(1-(v-mn)/rng*0.85);
3376        const c=document.createElementNS('http://www.w3.org/2000/svg','circle');
3377        c.setAttribute('cx',x);c.setAttribute('cy',y);c.setAttribute('r','3');
3378        c.setAttribute('fill',colors[0]);c.setAttribute('opacity','0.9');svg.appendChild(c);
3379      });
3380    }
3381  });
3382  // X-axis date labels
3383  if(labels.length>0) {
3384    const vals=td?.[dest]||[];
3385    const step=Math.max(1,Math.floor(labels.length/6));
3386    labels.forEach((lbl,j)=>{
3387      if(j%step!==0 && j!==labels.length-1) return;
3388      const x=P+(W-2*P)*j/(labels.length-1);
3389      const txt=document.createElementNS('http://www.w3.org/2000/svg','text');
3390      txt.setAttribute('x',x);txt.setAttribute('y',H-6);
3391      txt.setAttribute('text-anchor','middle');txt.setAttribute('fill','rgba(139,164,184,0.7)');
3392      txt.setAttribute('font-size','9');txt.textContent=lbl;svg.appendChild(txt);
3393      // tick
3394      const tick=document.createElementNS('http://www.w3.org/2000/svg','line');
3395      tick.setAttribute('x1',x);tick.setAttribute('y1',H-PB);tick.setAttribute('x2',x);tick.setAttribute('y2',H-PB+4);
3396      tick.setAttribute('stroke','rgba(255,255,255,0.1)');tick.setAttribute('stroke-width','1');svg.appendChild(tick);
3397    });
3398  }
3399  // Legend
3400  dests.forEach((d,i)=>{
3401    if(!td?.[d]?.length) return;
3402    const li=document.createElement('div');li.style.cssText='display:flex;align-items:center;gap:6px;font-size:12px;color:var(--text2)';
3403    li.innerHTML=`<div style="width:12px;height:3px;border-radius:2px;background:${colors[i]}"></div>${d}`;legend.appendChild(li);
3404  });
3405}
3406
3407function renderDonut(s) {
3408  const pos=s?.positivo||0,neu=s?.neutro||0,neg=s?.negativo||0,C=326.7;
3409  document.getElementById('d-pct').textContent=pos+'%';
3410  document.getElementById('donut-sub')?.setAttribute && null;
3411  setTimeout(()=>{
3412    const posLen=C*pos/100,neuLen=C*neu/100,negLen=C*neg/100;
3413    const pe=document.getElementById('d-pos');pe.style.strokeDasharray=`${posLen} ${C-posLen}`;pe.style.strokeDashoffset='0';
3414    const ne=document.getElementById('d-neu');ne.style.strokeDasharray=`${neuLen} ${C-neuLen}`;ne.style.strokeDashoffset=-posLen;
3415    const nge=document.getElementById('d-neg');nge.style.strokeDasharray=`${negLen} ${C-negLen}`;nge.style.strokeDashoffset=-(posLen+neuLen);
3416  },500);
3417  document.getElementById('sent-legend').innerHTML=`
3418    <div class="sent-row"><div class="sent-left"><div class="sent-dot" style="background:var(--accent)"></div>${t('sent_positive')}</div><div class="sent-pct">${pos}%</div></div>
3419    <div class="sent-row"><div class="sent-left"><div class="sent-dot" style="background:var(--text3)"></div>${t('sent_neutral')}</div><div class="sent-pct">${neu}%</div></div>
3420    <div class="sent-row"><div class="sent-left"><div class="sent-dot" style="background:var(--red)"></div>${t('sent_negative')}</div><div class="sent-pct">${neg}%</div></div>`;
3421}
3422
3423
3424function sendWhatsAppAlert() {
3425  const dest = G.dest || 'seu destino';
3426  const indicators = (G.lastData && G.lastData.crisis_indicators || []);
3427  const top = indicators.filter(i=>i.nivel==='high').map(i=>i.tema+'('+i.delta+')').join(', ') || 'Verificar dashboard';
3428  const msg = encodeURIComponent('🚨 *ALERTA DE CRISE — Consensus Pulse*\n\nDestino: *'+dest+'*\n\n🔴 Alertas Críticos: '+top+'\n\nAcesse o dashboard para ver o plano de ação completo.\n\n_Enviado pelo Consensus Pulse Early Warning System_');
3429  window.open('https://api.whatsapp.com/send?text='+msg, '_blank');
3430}
3431
3432
3433function renderProfile(d) {
3434  if (!_profileHasData(d)) {
3435    // Carregando em background (pós-dashboard) → spinner; senão → erro com retry.
3436    if (window._bgLoading && window._bgLoading.profile) { _showModuleLoading('profile', t('loading_label_profile')); return; }
3437    _showModuleError('profile', { icon: '👤', title: t('err_title_profile'), reason: _moduleIncompleteReason(d, 'profile'), buttonStyle: 'background:linear-gradient(135deg,var(--blue),var(--accent))', retryFn: _retryProfile });
3438    return;
3439  }
3440  _hideModuleError('profile');
3441  // Quando as origens são um proxy de interesse de busca (mercados emissores), e não
3442  // origem confirmada de visitantes, o profile-retry marca origens_proxy=true → aviso.
3443  const _origProxyNote = d.origens_proxy
3444    ? '<div style="font-size:11px;color:var(--text3);margin-bottom:12px;padding:7px 10px;border-radius:8px;background:rgba(245,158,11,0.08);border:1px solid rgba(245,158,11,0.25);line-height:1.45;">📊 <strong>Interesse de busca</strong> (mercados que mais pesquisam o destino) — proxy de demanda, não origem confirmada de visitantes.</div>'
3445    : '';
3446  // analysis-profile marca dado não-confirmado com o prefixo fixo em PT "Estimado: "/
3447  // "Inferido: " (ver docs/PROMPTS-ANALYSIS.md) — o VALOR já vem no idioma certo
3448  // (langName), só o rótulo do marcador precisa ser traduzido aqui no front.
3449  const _profileEstTag = (raw) => {
3450    if (raw == null) return { text: raw, tagKey: null };
3451    const s = String(raw);
3452    const m = s.match(/^\s*(Estimado|Inferido)\s*:\s*(.*)$/i);
3453    if (!m) return { text: s, tagKey: null };
3454    return { text: m[2], tagKey: m[1].toLowerCase() === 'inferido' ? 'profile_tag_inferred' : 'profile_tag_estimated' };
3455  };
3456  const _obEl=document.getElementById('origin-bars'); // card pode estar comentado (desativado temporariamente)
3457  if(_obEl)_obEl.innerHTML=_origProxyNote+(d.turista_origens||[]).map(o=>{
3458    const delta=o.delta||o.variacao||null;
3459    const deltaHtml=delta?`<span style="font-size:11px;font-weight:700;color:${delta>0?'var(--green)':'var(--red)'};">${delta>0?'▲+'+delta:('▼'+delta)}%</span>`:'';
3460    const _og = _profileEstTag(o.origem);
3461    const _ogTag = _og.tagKey ? `<span style="font-size:9px;padding:1px 6px;border-radius:100px;background:var(--surface3);color:var(--text3);margin-left:5px;">${t(_og.tagKey)}</span>` : '';
3462    return `<div class="origin-row"><div class="origin-name" style="display:flex;align-items:center;gap:6px;">${_og.text}${_ogTag} ${deltaHtml}</div><div class="origin-bar"><div class="origin-bar-fill" style="width:0%" data-w="${o.pct}%"></div></div><div class="origin-pct">${o.pct}%</div></div>`;
3463  }).join('');
3464  setTimeout(()=>document.querySelectorAll('.origin-bar-fill').forEach(el=>{el.style.width=el.dataset.w;}),300);
3465  const p=d.turista_perfil||{};
3466  const _displayProfileVal = (v) => {
3467    if (v == null) return '—';
3468    let s = String(v).trim();
3469    if (s === '') return '—';
3470    // Sentinel canônico + variantes de recusa que a IA às vezes improvisa no lugar
3471    // dele (ex.: "Não identificada nos dados fornecidos"). Normaliza tudo p/ "—".
3472    const low = s.toLowerCase();
3473    if (/^dados[_ ]indispon[ií]ve(l|is)\.?$/.test(low)) return '—';
3474    if (/^n[ãa]o (identificad[oa]|especificad[oa]|informad[oa]|dispon[ií]ve(l|is))( .*)?$/.test(low)) return '—';
3475    if (/^(sem dados|indispon[ií]ve(l|is)|não se aplica)( .*)?\.?$/.test(low)) return '—';
3476    if (/^(n\/a|n\.?a\.?|n\.?d\.?)$/.test(low)) return '—';
3477    const _tag = _profileEstTag(s);
3478    if (_tag.tagKey) return `${_tag.text} <span style="font-size:9px;padding:1px 6px;border-radius:100px;background:var(--surface3);color:var(--text3);">${t(_tag.tagKey)}</span>`;
3479    return s;
3480  };
3481  document.getElementById('profile-grid').innerHTML=[
3482    [t('prof_age'),p.faixa_etaria],
3483    [t('prof_ticket'),p.ticket_medio||p.ticket_media],
3484    [t('prof_stay'),p.estadia_media],
3485    [t('prof_dominant'),p.perfil_dominante],
3486    [t('prof_niche'),p.crescimento_nicho],
3487    [t('prof_channel'),p.canal_descoberta]
3488  ].map(([l,v])=>`<div class="profile-item"><div class="profile-item-label">${l}</div><div class="profile-item-val">${_displayProfileVal(v)}</div></div>`).join('');
3489  const interests=d.turista_interesses||[];
3490  if(interests.length){
3491    document.getElementById('interests-card').style.display='block';
3492    document.getElementById('interest-tags').innerHTML=interests.map(i=>`<div class="interest-tag" style="cursor:pointer;" onclick="filterProfileByInterest('${i}')" title="Filtrar análise por este interesse">${i} ▾</div>`).join('');
3493  }
3494  document.getElementById('profile-analysis').innerHTML=(d.turista_analise||'--').replace(/\*\*(.*?)\*\*/g,'<strong>$1</strong>').replace(/\n/g,'<br>');
3495  // Add actionable recommendation block if not already in text
3496  const paEl = document.getElementById('profile-analysis');
3497  if (paEl && d.turista_analise && !/Recomenda(c|ç)(a|ã)o|Recommendation|Recomendaci[oó]n/i.test(d.turista_analise)) {
3498    const origemTop = (d.turista_origens||[])[0];
3499    const origemNome = origemTop ? String(origemTop.origem).replace(/^\s*(Estimado|Inferido):\s*/i, '') : t('profile_rec_default_market');
3500    const recBody = t('profile_rec_text')
3501      .replace('{dest}', G.dest||t('profile_rec_default_market'))
3502      .replace('{origin}', `<strong style="color:var(--text1);">${origemNome}</strong>`)
3503      .replace('Instagram', '<strong style="color:var(--text1);">Instagram</strong>');
3504    const recHtml = `<div style="margin-top:14px;padding:12px 14px;border-radius:10px;background:rgba(34,211,238,0.07);border-left:3px solid var(--accent);">
3505      <div style="font-size:11px;text-transform:uppercase;letter-spacing:1px;color:var(--accent);font-weight:700;margin-bottom:5px;">${t('profile_rec_title')}</div>
3506      <div style="font-size:13px;color:var(--text2);line-height:1.6;">${recBody}</div>
3507    </div>`;
3508    paEl.innerHTML += recHtml;
3509  }
3510}
3511
3512function _benchmarkHasData(d) { return d.concorrentes && Array.isArray(d.concorrentes) && d.concorrentes.length > 0; }
3513
3514// ===== BENCHMARK — cadastro de concorrentes sob demanda =====
3515// O card "Onde os Concorrentes São Mais Fortes" só existe se houver concorrentes.
3516// Quando a análise rodou sem nenhum, exibir "--" parece dado faltando/erro. Aqui a
3517// gente diz o motivo e oferece cadastrar na hora, com o mesmo formulário do onboarding.
3518
3519// Sem concorrentes = a IA só devolveu o próprio destino E o estado não tem lista.
3520function _benchSemConcorrentes(d) {
3521  var outros = ((d && d.concorrentes) || []).filter(function (c) { return !c.principal; });
3522  return outros.length === 0 && !((G.concs || []).length);
3523}
3524
3525function _benchConcsEmptyHtml() {
3526  return '<div style="font-size:13px;line-height:1.6;color:var(--text2);">'
3527    + t('bench_no_competitors')
3528    + '</div>'
3529    + '<button onclick="_benchOpenConcsModal()" style="margin-top:14px;padding:10px 18px;border-radius:9px;border:none;'
3530    + 'background:var(--grad,linear-gradient(135deg,var(--accent),var(--blue)));color:#0a0e14;'
3531    + 'font-family:Syne,sans-serif;font-size:13px;font-weight:700;cursor:pointer;">'+t('bench_register_competitors_btn')+'</button>';
3532}
3533
3534// Cards que o _retryBenchmark REESCREVE por inteiro. A Presença Digital fica de fora
3535// de propósito: ela vem do fluxo de SEO (runDigitalPresenceAnalysis), não do retry.
3536var _BENCH_CARDS_AFETADOS = [
3537  'comp-list',                      // Ranking
3538  'opp-list',                       // Oportunidades Estratégicas
3539  'benchmark-narrative',            // Seu Diferencial Competitivo
3540  'benchmark-competitor-strength',  // Onde os Concorrentes São Mais Fortes
3541  'swot-card',                      // Matriz SWOT
3542];
3543
3544function _benchEnsureBusyCSS() {
3545  if (document.getElementById('cp-bench-busy-css')) return;
3546  var st = document.createElement('style');
3547  st.id = 'cp-bench-busy-css';
3548  st.textContent =
3549    '.cp-bench-busy{position:relative;}' +
3550    '.cp-bench-busy > *:not(.cp-bench-busy-veil){opacity:.18;filter:blur(1.5px);transition:opacity .2s ease;}' +
3551    '.cp-bench-busy-veil{position:absolute;inset:0;z-index:3;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:9px;padding:14px;text-align:center;border-radius:inherit;background:rgba(4,7,12,.32);}' +
3552    '.cp-bench-busy-spin{width:24px;height:24px;border-radius:50%;border:2px solid var(--border);border-top-color:var(--accent);animation:cp-bench-busy-rot .8s linear infinite;}' +
3553    '.cp-bench-busy-txt{font-size:11.5px;font-weight:700;color:var(--text2);line-height:1.4;max-width:220px;}' +
3554    '@keyframes cp-bench-busy-rot{to{transform:rotate(360deg);}}';
3555  (document.head || document.documentElement).appendChild(st);
3556}
3557
3558// Acende/apaga o estado "sendo refeito" em TODOS os cards que o retry vai reescrever,
3559// pra deixar claro ao usuário o alcance da mudança (não só o card de onde ele clicou).
3560function _benchCardsBusy(on, msg) {
3561  _benchEnsureBusyCSS();
3562  _BENCH_CARDS_AFETADOS.forEach(function (id) {
3563    var el = document.getElementById(id);
3564    if (!el) return;
3565    var card = (id === 'swot-card') ? el : (el.closest ? el.closest('.card') : null);
3566    if (!card) return;
3567    var veil = card.querySelector('.cp-bench-busy-veil');
3568    if (on) {
3569      // Card escondido (SWOT sem dados) não precisa de loading.
3570      if (card.offsetParent === null && card.style.display === 'none') return;
3571      card.classList.add('cp-bench-busy');
3572      if (!veil) {
3573        veil = document.createElement('div');
3574        veil.className = 'cp-bench-busy-veil';
3575        veil.innerHTML = '<div class="cp-bench-busy-spin"></div><div class="cp-bench-busy-txt"></div>';
3576        card.appendChild(veil);
3577      }
3578      var txt = veil.querySelector('.cp-bench-busy-txt');
3579      if (txt) txt.textContent = msg || 'Atualizando…';
3580    } else {
3581      card.classList.remove('cp-bench-busy');
3582      if (veil) veil.remove();
3583    }
3584  });
3585}
3586
3587function _benchConcModalClose() {
3588  var ov = document.getElementById('bench-concs-modal');
3589  if (ov) ov.remove();
3590}
3591
3592function _benchConcModalStatus(msg, tipo) {
3593  var el = document.getElementById('bench-concs-status');
3594  if (!el) return;
3595  var cor = tipo === 'erro' ? 'var(--red)' : 'var(--text2)';
3596  var bg  = tipo === 'erro' ? 'var(--red-dim,rgba(255,61,90,.1))' : 'var(--surface2)';
3597  el.style.display = 'block';
3598  el.style.color = cor;
3599  el.style.background = bg;
3600  el.textContent = msg;
3601}
3602
3603// onChange da UF de uma linha do modal (prefix = 'bc-conc1', ...). Local, para não
3604// disparar o obAutoSave() do onboarding.
3605async function _benchConcEstadoChange(prefix) {
3606  var estSel = document.getElementById(prefix + '-estado');
3607  await obFillCitySelect(document.getElementById(prefix + '-cidade'), estSel && estSel.value, 'Cidade');
3608}
3609
3610// Uma linha do formulário — cópia do bloco de concorrente do onboarding.
3611function _benchConcRowHtml(i) {
3612  var p = 'bc-conc' + i;
3613  return '<div style="padding:12px;border-radius:10px;border:1px solid var(--border);background:var(--surface2);">'
3614    + '<div style="display:grid;grid-template-columns:92px 1fr 130px;
3614gap:8px;margin-bottom:6px;">'
3615      + '<select class="ob-input" id="' + p + '-estado" style="font-size:12px;cursor:pointer;" onchange="_benchConcEstadoChange(\'' + p + '\')"><option value="">UF</option></select>'
3616      + '<select class="ob-input" id="' + p + '-cidade" style="font-size:12px;cursor:pointer;"><option value="">Cidade</option></select>'
3617      + '<select class="ob-input" id="' + p + '-tipo" style="font-size:12px;cursor:pointer;">'
3618        + '<option value="direto">⚔️ Direto</option><option value="geografico">📍 Geográfico</option>'
3619      + '</select>'
3620    + '</div>'
3621    + '<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;">'
3622      + '<input class="ob-input" id="' + p + '-ig" placeholder="📸 @instagram" style="font-size:12px;"/>'
3623      + '<input class="ob-input" id="' + p + '-site" placeholder="🌐 site.com.br" style="font-size:12px;"/>'
3624    + '</div>'
3625  + '</div>';
3626}
3627
3628async function _benchOpenConcsModal() {
3629  var prev = document.getElementById('bench-concs-modal'); if (prev) prev.remove();
3630  var ov = document.createElement('div');
3631  ov.id = 'bench-concs-modal';
3632  ov.style.cssText = 'position:fixed;inset:0;z-index:9999;background:rgba(4,7,12,.66);backdrop-filter:blur(4px);display:flex;align-items:center;justify-content:center;padding:20px;';
3633  ov.onclick = function (e) { if (e.target === ov) _benchConcModalClose(); };
3634  ov.innerHTML =
3635    '<div style="width:100%;max-width:560px;background:var(--surface,#12161d);border:1px solid var(--border);border-radius:16px;padding:24px;max-height:88vh;overflow:auto;">'
3636    + '<div style="display:flex;align-items:center;gap:8px;margin-bottom:4px;"><span style="font-size:20px;">⚔️</span>'
3637      + '<span style="font-family:Syne,sans-serif;font-size:18px;font-weight:800;color:var(--text);">Cadastrar concorrentes</span>'
3638      + '<span onclick="_benchConcModalClose()" style="margin-left:auto;cursor:pointer;color:var(--text3);font-size:20px;line-height:1;">✕</span></div>'
3639    + '<div style="font-size:12.5px;color:var(--text3);margin-bottom:16px;">Até 3 destinos concorrentes. Ao salvar, o benchmark é refeito com eles.</div>'
3640    + '<div style="display:flex;flex-direction:column;gap:12px;margin-bottom:16px;">'
3641      + _benchConcRowHtml(1) + _benchConcRowHtml(2) + _benchConcRowHtml(3)
3642    + '</div>'
3643    + '<div id="bench-concs-status" style="display:none;font-size:12.5px;line-height:1.5;padding:10px 12px;border-radius:9px;margin-bottom:16px;"></div>'
3644    + '<div style="display:flex;gap:10px;justify-content:flex-end;">'
3645      + '<button onclick="_benchConcModalClose()" style="padding:10px 18px;border-radius:9px;border:1px solid var(--border);background:transparent;color:var(--text2);font-size:13px;font-weight:600;cursor:pointer;">Cancelar</button>'
3646      + '<button id="bench-concs-save" onclick="_benchConcModalSave()" style="padding:10px 20px;border-radius:9px;border:none;background:var(--grad,linear-gradient(135deg,var(--accent),var(--blue)));color:#0a0e14;font-family:Syne,sans-serif;font-size:13px;font-weight:700;cursor:pointer;">Salvar e refazer benchmark</button>'
3647    + '</div>'
3648  + '</div>';
3649  document.body.appendChild(ov);
3650
3651  // Popula as UFs (loadEstados tem cache). obPopulateEstados() só conhece os ids do
3652  // onboarding, por isso preenchemos os nossos aqui.
3653  try {
3654    var ests = await loadEstados();
3655    if (ests && ests.length) {
3656      var opts = ests.map(function (e) { return '<option value="' + e.sigla + '" title="' + _obEsc(e.nome) + '">' + e.sigla + '</option>'; }).join('');
3657      [1, 2, 3].forEach(function (i) {
3658        var sel = document.getElementById('bc-conc' + i + '-estado');
3659        if (sel && !sel.dataset.filled) { sel.insertAdjacentHTML('beforeend', opts); sel.dataset.filled = '1'; }
3660      });
3661    }
3662  } catch (e) {
3663    _benchConcModalStatus('Não foi possível carregar a lista de estados. Recarregue a página.', 'erro');
3664  }
3665}
3666
3667async function _benchConcModalSave() {
3668  // Mesma leitura do onboarding (app.js — OB.concsData): o concorrente É a cidade.
3669  var concsData = [1, 2, 3].map(function (i) {
3670    var p = 'bc-conc' + i;
3671    var estado = (document.getElementById(p + '-estado') || {}).value || '';
3672    var cidade = (document.getElementById(p + '-cidade') || {}).value || '';
3673    var local = [cidade, estado].filter(Boolean).join(', ');
3674    return {
3675      nome: cidade,
3676      tipo: (document.getElementById(p + '-tipo') || {}).value || 'direto',
3677      ig: ((document.getElementById(p + '-ig') || {}).value || '').trim(),
3678      site: ((document.getElementById(p + '-site') || {}).value || '').trim(),
3679      estado: estado, cidade: cidade,
3680      nomeCompleto: local || cidade,
3681    };
3682  }).filter(function (c) { return c.cidade; });
3683
3684  if (!concsData.length) {
3685    _benchConcModalStatus('Selecione ao menos um concorrente (UF + cidade).', 'erro');
3686    return;
3687  }
3688
3689  var btn = document.getElementById('bench-concs-save');
3690  if (btn) { btn.disabled = true; btn.textContent = 'Salvando…'; btn.style.opacity = '.6'; btn.style.cursor = 'default'; }
3691
3692  G.concsData = concsData;
3693  G.concs = concsData.map(function (c) { return c.nomeCompleto; });
3694
3695  // Persiste para sobreviver ao reload: _meta_concsData volta em loadRecord (G.concsData)
3696  // e a coluna competitors vira G.concs.
3697  try {
3698    await _persistField('_meta_concsData', concsData, G.dest);
3699    var supa = (typeof getSupabase === 'function') ? await getSupabase() : null;
3700    var aid = sessionStorage.getItem('cp_active_analysis');
3701    if (supa && aid) {
3702      var upd = await supa.from('analysis_history').update({ competitors: G.concs }).eq('id', aid);
3703      if (upd && upd.error) console.warn('[Benchmark] competitors não persistido: ' + upd.error.message);
3704    }
3705  } catch (e) {
3706    console.warn('[Benchmark] falha ao persistir concorrentes: ' + (e && e.message));
3707  }
3708
3709  _benchConcModalClose();
3710
3711  // Todos os cards do benchmark serão reescritos com os novos concorrentes —
3712  // o loading vai em todos eles, não só no card de onde o usuário clicou.
3713  var nomes = concsData.map(function (c) { return c.nomeCompleto; }).join(', ');
3714  await _retryBenchmark('Refazendo com ' + nomes);
3715}
3716
3717function renderBenchmark(d) {
3718  if (!_benchmarkHasData(d)) {
3719    // Carregando em background (pós-dashboard) → spinner; senão → erro.
3720    if (window._bgLoading && window._bgLoading.benchmark) { _showModuleLoading('benchmark', t('loading_label_benchmark')); return; }
3721    _showModuleError('benchmark', { icon: '⚖️', title: t('err_title_benchmark'), reason: _moduleIncompleteReason(d, 'benchmark'), buttonStyle: 'background:linear-gradient(135deg,var(--accent),var(--blue))', retryFn: _retryBenchmark });
3722    return;
3723  }
3724  _hideModuleError('benchmark');
3725  const sorted=[...(d.concorrentes||[])].sort((a,b)=>b.score-a.score);
3726  const colors=['var(--accent)','var(--blue)','var(--orange)','var(--text2)'];
3727  
3728  // Build type badge lookup from G.concsData
3729  const typeMap = {};
3730  (G.concsData||[]).forEach(c => { if(c.nome) typeMap[c.nome.toLowerCase()] = c.tipo; });
3731  
3732  document.getElementById('comp-list').innerHTML=sorted.map((c,i)=>{
3733    const delta = c.delta_mes||null;
3734    const deltaHtml = delta ? `<span style="font-size:11px;font-weight:700;margin-left:5px;color:${delta>0?'var(--green)':'var(--red)'};">${delta>0?'▲+'+delta:'▼'+delta}</span>` : '';
3735    const liderBtn = i===0 && !c.principal ? `<button onclick="showLiderDetails()" style="font-size:10px;padding:2px 8px;border-radius:6px;border:1px solid var(--accent);background:transparent;color:var(--accent);cursor:pointer;margin-left:6px;">ver detalhes ▸</button>` : '';
3736    const tipo = typeMap[(c.nome||'').toLowerCase()];
3737    const tipoBadge = tipo === 'geografico' 
3738      ? '<span style="font-size:9px;padding:2px 6px;border-radius:100px;background:rgba(139,92,246,0.15);color:#a78bfa;margin-left:5px;">📍 Geográfico</span>'
3739      : tipo === 'direto' 
3740      ? '<span style="font-size:9px;padding:2px 6px;border-radius:100px;background:rgba(239,68,68,0.12);color:#f87171;margin-left:5px;">⚔️ Direto</span>'
3741      : '';
3742    return `<div class="comp-row"><div class="comp-num ${c.principal?'you':''}">${i+1}</div><div class="comp-info"><div class="comp-name" style="display:flex;align-items:center;flex-wrap:wrap;gap:4px;">${c.nome}${c.principal?' '+t('you'):''}${tipoBadge}${deltaHtml}${liderBtn}</div><div class="comp-bar"><div class="comp-bar-fill" style="width:0%;background:${colors[i]||'var(--text2)'}" data-w="${c.score}%"></div></div></div><div class="comp-score-val" style="color:${colors[i]||'var(--text2)'}">${c.score}</div></div>`;
3743  }).join('');
3744  setTimeout(()=>document.querySelectorAll('.comp-bar-fill').forEach(el=>{el.style.width=el.dataset.w;}),300);
3745  
3746  // Render Digital Presence table
3747  renderDigitalPresenceTable();
3748  
3749  // Render SWOT
3750  if (d.benchmark_swot || d.diferenciais_competitivos) {
3751    renderSWOT(d);
3752    const swotCard = document.getElementById('swot-card');
3753    if (swotCard) swotCard.style.display = 'block';
3754  }
3755  document.getElementById('opp-list').innerHTML=(d.oportunidades||[]).map(o=>
3756    `<div class="opp-row ${o.prioridade==='medium'?'med':o.prioridade==='low'?'low':''}"><div class="opp-ico">${o.icone||'💡'}</div><div class="opp-body"><h4>${o.titulo}</h4><p>${o.descricao}</p></div></div>`
3757  ).join('');
3758  document.getElementById('benchmark-narrative').innerHTML=(d.benchmark_narrativa||'--').replace(/\*\*(.*?)\*\*/g,'<strong>$1</strong>').replace(/\n/g,'<br>');
3759  const csEl=document.getElementById('benchmark-competitor-strength');
3760  if(csEl){
3761    const csCard = csEl.closest ? csEl.closest('.card') : null;
3762    // Sem concorrentes o card não tem o que comparar — explica e oferece cadastrar,
3763    // em vez de exibir "--" (que parece dado faltando). O cadastro (_benchOpenConcsModal)
3764    // usa select de UF do Brasil (loadEstados) — fora do Brasil o CTA não funciona, então
3765    // o card some inteiro em vez de oferecer uma ação quebrada.
3766    if(_benchSemConcorrentes(d)){
3767      const isBR = (typeof isCurrentDestBrasil !== 'function') || isCurrentDestBrasil();
3768      if(!isBR){ if(csCard) csCard.style.display='none'; }
3769      else { if(csCard) csCard.style.display=''; csEl.innerHTML=_benchConcsEmptyHtml(); }
3770    } else {
3771      if(csCard) csCard.style.display='';
3772      csEl.innerHTML=(d.benchmark_competitor_strength||'--').replace(/\*\*(.*?)\*\*/g,'<strong>$1</strong>').replace(/\n/g,'<br>');
3773    }
3774  }
3775}
3776
3777// ===== DIGITAL PRESENCE =====
3778function renderDigitalPresenceTable() {
3779  const card = document.getElementById('digital-presence-card');
3780  const table = document.getElementById('digital-presence-table');
3781  if (!card || !table) return;
3782  
3783  const hasData = G.empresaIG || G.empresaSite || (G.concsData && G.concsData.some(c => c.ig || c.site));
3784  card.style.display = 'block';
3785  
3786  if (!hasData) {
3787    table.innerHTML = '<div style="padding:20px;text-align:center;color:var(--text3);font-size:13px;line-height:1.6;">📸 Para ativar a análise de Presença Digital, preencha o <strong style="color:var(--accent);">Instagram</strong> e/ou <strong style="color:var(--accent);">Site</strong> do seu destino e concorrentes no formulário de análise.</div>';
3788    const btn = document.getElementById('btn-digital-analysis');
3789    if (btn) btn.style.display = 'none';
3790    return;
3791  }
3792  
3793  const dest = G.dest || '—';
3794  const rows = [
3795    { nome: dest, tipo: 'Você', ig: G.empresaIG || '', site: G.empresaSite || '', principal: true },
3796    ...(G.concsData||[]).map(c => ({
3797      nome: c.nome,
3798      tipo: c.tipo === 'geografico' ? '📍 Geográfico' : '⚔️ Direto',
3799      ig: c.ig || '',
3800      site: c.site || '',
3801      principal: false,
3802    })),
3803  ];
3804  
3805  const checkIcon = val => val ? '<span style="color:var(--green);">✅</span>' : '<span style="color:var(--red);">❌</span>';
3806  const igLink = val => val ? `<a href="https://instagram.com/${val.replace('@','')}" target="_blank" style="color:var(--accent);text-decoration:none;font-size:12px;">${val}</a>` : '<span style="color:var(--text3);font-size:12px;">—</span>';
3807  const siteLink = val => val ? `<a href="${val.startsWith('http') ? val : 'https://'+val}" target="_blank" style="color:var(--accent);text-decoration:none;font-size:12px;max-width:160px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:inline-block;">${val}</a>` : '<span style="color:var(--text3);font-size:12px;">—</span>';
3808  
3809  table.innerHTML = `
3810    <div style="overflow-x:auto;">
3811      <table style="width:100%;border-collapse:collapse;font-size:13px;">
3812        <thead>
3813          <tr style="border-bottom:1px solid var(--border);">
3814            <th style="text-align:left;padding:10px 8px;color:var(--text3);font-size:11px;text-transform:uppercase;">Nome</th>
3815            <th style="text-align:center;padding:10px 8px;color:var(--text3);font-size:11px;text-transform:uppercase;">Tipo</th>
3816            <th style="text-align:center;padding:10px 8px;color:var(--text3);font-size:11px;text-transform:uppercase;">📸 Instagram</th>
3817            <th style="text-align:center;padding:10px 8px;color:var(--text3);font-size:11px;text-transform:uppercase;">🌐 Site</th>
3818          </tr>
3819        </thead>
3820        <tbody>
3821          ${rows.map(r => `
3822            <tr style="border-bottom:1px solid var(--border);${r.principal ? 'background:rgba(34,211,238,0.04);' : ''}">
3823              <td style="padding:10px 8px;font-weight:${r.principal?'700':'500'};color:var(--text1);">
3824                ${r.nome}
3825                ${r.principal ? '<span style="font-size:9px;padding:2px 6px;border-radius:100px;background:var(--accent-dim);color:var(--accent);margin-left:6px;">VOCÊ</span>' : ''}
3826              </td>
3827              <td style="text-align:center;padding:10px 8px;font-size:11px;">${r.tipo}</td>
3828              <td style="text-align:center;padding:10px 8px;">${igLink(r.ig)}</td>
3829              <td style="text-align:center;padding:10px 8px;">${siteLink(r.site)}</td>
3830            </tr>
3831          `).join('')}
3832        </tbody>
3833      </table>
3834    </div>
3835  `;
3836}
3837
3838async function fetchPageSpeed(url) {
3839  if (!url) return null;
3840  const cleanUrl = url.startsWith('http') ? url : 'https://' + url;
3841  // PageSpeed via api-proxy (server-side)
3842  try {
3843    const supabase = await getSupabase();
3844    const { data: { session } } = await supabase.auth.getSession();
3845    if (!session) throw new Error('Não autenticado');
3846    
3847    const res = await fetch(SUPABASE_URL + '/functions/v1/api-proxy', {
3848      method: 'POST',
3849      headers: {
3850        'Content-Type': 'application/json',
3851        'Authorization': 'Bearer ' + session.access_token,
3852        'apikey': SUPABASE_ANON_KEY
3853      },
3854      body: JSON.stringify({
3855        service: 'pagespeed',
3856        params: { url: cleanUrl, strategy: 'desktop' }
3857      })
3858    });
3859    if (!res.ok) throw new Error('HTTP ' + res.status);
3860    const proxyData = await res.json();
3861    const d = proxyData.result || {};
3862    if (d.error) throw new Error(d.error.message || 'API error');
3863    const cats = d.lighthouseResult?.categories || {};
3864    const audits = d.lighthouseResult?.audits || {};
3865    return {
3866      url: cleanUrl,
3867      source: 'pagespeed',
3868      performance: Math.round((cats.performance?.score || 0) * 100),
3869      seo: Math.round((cats.seo?.score || 0) * 100),
3870      accessibility: Math.round((cats.accessibility?.score || 0) * 100),
3871      bestPractices: Math.round((cats['best-practices']?.score || 0) * 100),
3872      fcp: audits['first-contentful-paint']?.displayValue || '?',
3873      lcp: audits['largest-contentful-paint']?.displayValue || '?',
3874      speedIndex: audits['speed-index']?.displayValue || '?',
3875      cls: audits['cumulative-layout-shift']?.displayValue || '?',
3876      mobileViewport: audits['viewport']?.score === 1,
3877      metaDescription: audits['meta-description']?.score === 1,
3878    };
3879  } catch(e) {
3880    console.warn('[PageSpeed] API failed for', url, ':', e.message, '— trying site fetch fallback');
3881    // Fallback: fetch site HTML to analyze meta tags, title, mobile viewport etc.
3882    try {
3883      const siteRes = await fetch(cleanUrl, { mode: 'no-cors' }).catch(() => null);
3884      // no-cors won't give us content, but we can note the site exists
3885      return {
3886        url: cleanUrl,
3887        source: 'basic',
3888        exists: true,
3889        note: 'PageSpeed API indisponível. Habilite "PageSpeed Insights API" no Google Cloud Console (APIs & Services > Library).',
3890      };
3891    } catch(e2) {
3892      return { url: cleanUrl, source: 'basic', exists: false, note: 'Site não acessível' };
3893    }
3894  }
3895}
3896
3897async function runDigitalPresenceAnalysis() {
3898  const btn = document.getElementById('btn-digital-analysis');
3899  const analysisDiv = document.getElementById('digital-presence-analysis');
3900  if (!btn || !analysisDiv) return;
3901  
3902  btn.disabled = true;
3903  btn.textContent = t('dp_btn_fetching_real_data');
3904  analysisDiv.innerHTML = '<div style="text-align:center;padding:16px;color:var(--text3);">'+t('dp_analyzing_msg')+'</div>';
3905  
3906  // Resolve o destino de várias fontes. Sem dest, o template enviava {{dest}} cru
3907  // e a IA respondia pedindo o destino (texto com {{dest}} renderizado na tela).
3908  var dest = G.dest || window._userDestination || '';
3909  if (!dest && typeof getLockedDestination === 'function') {
3910    try { var _lk = getLockedDestination(); if (_lk && _lk.destination) dest = _lk.destination; } catch (_e) {}
3911  }
3912  if (!dest) {
3913    var _td = document.getElementById('topbar-dest');
3914    if (_td && _td.textContent && _td.textContent.trim()) dest = _td.textContent.trim();
3915  }
3916  if (!dest) {
3917    btn.disabled = false;
3918    btn.textContent = t('dp_btn_no_dest_label');
3919    analysisDiv.innerHTML = '<div style="color:var(--text3);font-size:13px;padding:14px;text-align:center;">
3919'+t('dp_no_dest_msg')+'</div>';
3920    return;
3921  }
3922  const lang = G.lang || 'pt';
3923  const langName = {pt:'Portuguese',en:'English',es:'Spanish',fr:'French'}[lang] || 'Portuguese';
3924  
3925  // ── BUILD SITES LIST ──
3926  const allSites = [
3927    { nome: dest, site: G.empresaSite || '', ig: G.empresaIG || '', tipo: 'Você', principal: true },
3928    ...(G.concsData||[]).map(c => ({ nome: c.nome, site: c.site || '', ig: c.ig || '', tipo: c.tipo, principal: false })),
3929  ].filter(s => s.nome);
3930  
3931  // ── STEP 1: Fetch Instagram profiles via Apify (via api-proxy) ──
3932  const igResults = {};
3933  
3934  {
3935    const igHandles = allSites.filter(s => s.ig).map(s => ({ nome: s.nome, handle: s.ig.replace('@','') }));
3936    if (igHandles.length > 0) {
3937      btn.textContent = t('dp_btn_fetching_ig');
3938      try {
3939        const usernames = igHandles.map(h => h.handle);
3940        const supabase = await getSupabase();
3941        const { data: { session } } = await supabase.auth.getSession();
3942        if (!session) throw new Error('Não autenticado');
3943
3944        // Step 1: Buscar perfis (followers, bio, verified)
3945        const igRes = await fetch(`${SUPABASE_URL}/functions/v1/api-proxy`, {
3946          method: 'POST',
3947          headers: {
3948            'Content-Type': 'application/json',
3949            'Authorization': `Bearer ${session.access_token}`,
3950            'apikey': SUPABASE_ANON_KEY
3951          },
3952          body: JSON.stringify({ service: 'apify', params: { actorId: 'apify~instagram-profile-scraper', input: { usernames, resultsLimit: igHandles.length }, sync: true, timeout: 180 } })
3953        });
3954        if (igRes.ok) {
3955          const igRaw = await igRes.json();
3956          const igData = igRaw.result || [];
3957          igData.forEach(p => {
3958            const match = igHandles.find(h => h.handle.toLowerCase() === (p.username||'').toLowerCase());
3959            if (match) {
3960              igResults[match.nome] = {
3961                username: p.username,
3962                followers: p.followersCount || 0,
3963                following: p.followsCount || 0,
3964                posts: p.postsCount || 0,
3965                bio: (p.biography || '').substring(0, 150),
3966                verified: p.verified || false,
3967                avgLikes: 0,
3968                avgComments: 0,
3969                engagementRate: '0.00',
3970              };
3971            }
3972          });
3973          console.log('[Instagram] ✅ Profiles loaded for', Object.keys(igResults).length, 'accounts');
3974        }
3975
3976        // Step 2: Buscar posts recentes para calcular engajamento
3977        if (Object.keys(igResults).length > 0) {
3978          btn.textContent = t('dp_btn_calc_engagement');
3979          const postsRes = await fetch(`${SUPABASE_URL}/functions/v1/api-proxy`, {
3980            method: 'POST',
3981            headers: {
3982              'Content-Type': 'application/json',
3983              'Authorization': `Bearer ${session.access_token}`,
3984              'apikey': SUPABASE_ANON_KEY
3985            },
3986            body: JSON.stringify({ service: 'apify', params: { actorId: 'apify~instagram-post-scraper', input: { username: usernames, resultsLimit: 12, dataDetailLevel: 'basicData', skipPinnedPosts: true }, sync: true, timeout: 180 } })
3987          });
3988          if (postsRes.ok) {
3989            const postsRaw = await postsRes.json();
3990            const postsData = postsRaw.result || [];
3991
3992            // Agrupar posts por username
3993            const postsByUser = {};
3994            postsData.forEach(post => {
3995              const u = (post.ownerUsername || '').toLowerCase();
3996              if (!u) return;
3997              if (!postsByUser[u]) postsByUser[u] = [];
3998              postsByUser[u].push(post);
3999            });
4000
4001            // Calcular engajamento para cada perfil
4002            Object.values(igResults).forEach(ig => {
4003              const userPosts = postsByUser[(ig.username || '').toLowerCase()] || [];
4004              if (userPosts.length > 0 && ig.followers > 0) {
4005                const totalLikes = userPosts.reduce((s, p) => s + (p.likesCount || 0), 0);
4006                const totalComments = userPosts.reduce((s, p) => s + (p.commentsCount || 0), 0);
4007                ig.avgLikes = Math.round(totalLikes / userPosts.length);
4008                ig.avgComments = Math.round(totalComments / userPosts.length);
4009                ig.engagementRate = (((ig.avgLikes + ig.avgComments) / ig.followers) * 100).toFixed(2);
4010                console.log('[Instagram] 📊', ig.username, '→ engagement:', ig.engagementRate + '% (avg', ig.avgLikes, 'likes +', ig.avgComments, 'comments from', userPosts.length, 'posts)');
4011              }
4012            });
4013          } else {
4014            console.warn('[Instagram] ⚠️ Post scraper failed, engagement will show N/D');
4015          }
4016        }
4017      } catch(e) { console.warn('[Instagram] Fetch failed:', e.message); }
4018    }
4019  }
4020  
4021  // ── STEP 2: Fetch PageSpeed data for ALL sites ──
4022  const pageSpeedResults = {};
4023  const sitesToAnalyze = allSites.filter(s => s.site);
4024  
4025  if (sitesToAnalyze.length > 0) {
4026    btn.textContent = `⏳ Analisando ${sitesToAnalyze.length} sites...`;
4027    // Sequential with delay to avoid 429 rate limit
4028    for (let i = 0; i < sitesToAnalyze.length; i++) {
4029      const s = sitesToAnalyze[i];
4030      btn.textContent = t('dp_btn_analyzing_site') + ' ' + (i+1) + '/' + sitesToAnalyze.length + '...';
4031      const data = await fetchPageSpeed(s.site);
4032      if (data) pageSpeedResults[s.nome] = data;
4033      if (i < sitesToAnalyze.length - 1) await new Promise(r => setTimeout(r, 2000)); // 2s delay
4034    }
4035    console.log('[PageSpeed] ✅ Results for', Object.keys(pageSpeedResults).length, 'sites');
4036  }
4037  
4038  // ── STEP 2: Build visual results table ──
4039  const hasPageSpeed = Object.keys(pageSpeedResults).length > 0;
4040  
4041  if (hasPageSpeed) {
4042    const hasRealScores = Object.values(pageSpeedResults).some(ps => ps.source === 'pagespeed');
4043    const scoreColor = v => v >= 90 ? 'var(--green)' : v >= 50 ? 'var(--orange)' : 'var(--red)';
4044    const scoreEmoji = v => v >= 90 ? '🟢' : v >= 50 ? '🟡' : '🔴';
4045    const checkMark = v => v ? '✅' : '❌';
4046
4047    let tableHtml = '';
4048    
4049    if (hasRealScores) {
4050      // Full PageSpeed table
4051      tableHtml = `
4052        <div style="font-size:12px;font-weight:700;color:var(--accent);text-transform:uppercase;letter-spacing:1px;margin-bottom:12px;">${t('dp_table_title_pagespeed')}</div>
4053        <div style="overflow-x:auto;">
4054          <table style="width:100%;border-collapse:collapse;font-size:12px;">
4055            <thead>
4056              <tr style="border-bottom:2px solid var(--border);">
4057                <th style="text-align:left;padding:8px;color:var(--text3);">${t('dp_th_site')}</th>
4058                <th style="text-align:center;padding:8px;color:var(--text3);">${t('dp_th_performance')}</th>
4059                <th style="text-align:center;padding:8px;color:var(--text3);">${t('dp_th_seo')}</th>
4060                <th style="text-align:center;padding:8px;color:var(--text3);">${t('dp_th_accessibility')}</th>
4061                <th style="text-align:center;padding:8px;color:var(--text3);">${t('dp_th_best_practices')}</th>
4062                <th style="text-align:center;padding:8px;color:var(--text3);">${t('dp_th_fcp')}</th>
4063                <th style="text-align:center;padding:8px;color:var(--text3);">${t('dp_th_lcp')}</th>
4064                <th style="text-align:center;padding:8px;color:var(--text3);">${t('dp_th_meta_desc')}</th>
4065              </tr>
4066            </thead>
4067            <tbody>`;
4068
4069      allSites.forEach(s => {
4070        const ps = pageSpeedResults[s.nome];
4071        if (!ps || ps.source !== 'pagespeed') {
4072          tableHtml += `<tr style="border-bottom:1px solid var(--border);"><td style="padding:8px;font-weight:500;">${s.nome}${s.principal ? ' <span style="font-size:9px;padding:2px 5px;border-radius:100px;background:var(--accent-dim);color:var(--accent);">'+t('you_badge')+'</span>' : ''}</td><td colspan="7" style="text-align:center;padding:8px;color:var(--text3);font-style:italic;">${s.site ? t('dp_row_error_site') : t('dp_row_no_site')}</td></tr>`;
4073          return;
4074        }
4075        tableHtml += `
4076          <tr style="border-bottom:1px solid var(--border);${s.principal ? 'background:rgba(34,211,238,0.04);' : ''}">
4077            <td style="padding:8px;font-weight:${s.principal?'700':'500'};">
4078              ${s.nome}${s.principal ? ' <span style="font-size:9px;padding:2px 5px;border-radius:100px;background:var(--accent-dim);color:var(--accent);">'+t('you_badge')+'</span>' : ''}
4079              <div style="font-size:10px;color:var(--text3);">${ps.url}</div>
4080            </td>
4081            <td style="text-align:center;padding:8px;font-weight:700;color:${scoreColor(ps.performance)};">${scoreEmoji(ps.performance)} ${ps.performance}</td>
4082            <td style="text-align:center;padding:8px;font-weight:700;color:${scoreColor(ps.seo)};">${scoreEmoji(ps.seo)} ${ps.seo}</td>
4083            <td style="text-align:center;padding:8px;font-weight:700;color:${scoreColor(ps.accessibility)};">${scoreEmoji(ps.accessibility)} ${ps.accessibility}</td>
4084            <td style="text-align:center;padding:8px;font-weight:700;color:${scoreColor(ps.bestPractices)};">${scoreEmoji(ps.bestPractices)} ${ps.bestPractices}</td>
4085            <td style="text-align:center;padding:8px;font-size:11px;">${ps.fcp}</td>
4086            <td style="text-align:center;padding:8px;font-size:11px;">${ps.lcp}</td>
4087            <td style="text-align:center;padding:8px;">${checkMark(ps.mobileViewport)}</td>
4088            <td style="text-align:center;padding:8px;">${checkMark(ps.metaDescription)}</td>
4089          </tr>`;
4090      });
4091
4092      tableHtml += '</tbody></table></div>';
4093
4094      const entries = Object.entries(pageSpeedResults).filter(([k,v]) => v.source === 'pagespeed');
4095      if (entries.length >= 2) {
4096        const bestPerf = [...entries].sort((a,b) => b[1].performance - a[1].performance)[0];
4097        const bestSeo = [...entries].sort((a,b) => b[1].seo - a[1].seo)[0];
4098        tableHtml += `<div style="margin-top:10px;font-size:11px;color:var(--text3);">${t('dp_best_performance')} <strong style="color:var(--text1);">${bestPerf[0]}</strong> (${bestPerf[1].performance}/100) · ${t('dp_best_seo')} <strong style="color:var(--text1);">${bestSeo[0]}</strong> (${bestSeo[1].seo}/100)</div>`;
4099      }
4100    } else {
4101      // PageSpeed not available — show sites table with basic info + enable instructions
4102      tableHtml = `
4103        <div style="font-size:12px;font-weight:700;color:var(--accent);text-transform:uppercase;letter-spacing:1px;margin-bottom:12px;">${t('dp_table_title_sites_compared')}</div>
4104        <div style="overflow-x:auto;margin-bottom:12px;">
4105          <table style="width:100%;border-collapse:collapse;font-size:12px;">
4106            <thead>
4107              <tr style="border-bottom:2px solid var(--border);">
4108                <th style="text-align:left;padding:8px;color:var(--text3);">${t('dp_th_name')}</th>
4109                <th style="text-align:left;padding:8px;color:var(--text3);">${t('dp_th_site')}</th>
4110                <th style="text-align:center;padding:8px;color:var(--text3);">${t('dp_th_instagram')}</th>
4111                <th style="text-align:center;padding:8px;color:var(--text3);">${t('dp_th_type')}</th>
4112              </tr>
4113            </thead>
4114            <tbody>
4115              ${allSites.map(s => `
4116                <tr style="border-bottom:1px solid var(--border);${s.principal ? 'background:rgba(34,211,238,0.04);' : ''}">
4117                  <td style="padding:8px;font-weight:${s.principal?'700':'500'};">${s.nome}${s.principal ? ' <span style="font-size:9px;padding:2px 5px;border-radius:100px;background:var(--accent-dim);color:var(--accent);">'+t('you_badge')+'</span>' : ''}</td>
4118                  <td style="padding:8px;">${s.site ? '<a href="'+(s.site.startsWith('http')?s.site:'https://'+s.site)+'" target="_blank" style="color:var(--accent);text-decoration:none;font-size:11px;">'+s.site+'</a>' : '<span style="color:var(--text3);">—</span>'}</td>
4119                  <td style="text-align:center;padding:8px;">${s.ig ? '<a href="https://instagram.com/'+s.ig.replace('@','')+'" target="_blank" style="color:var(--accent);text-decoration:none;">'+s.ig+'</a>' : '<span style="color:var(--text3);">—</span>'}</td>
4120                  <td style="text-align:center;padding:8px;font-size:11px;">${s.principal ? '—' : (s.tipo === 'geografico' ? t('dp_type_geografico') : t('dp_type_direto'))}</td>
4121                </tr>
4122              `).join('')}
4123            </tbody>
4124          </table>
4125        </div>`;
4126    }
4127
4128    analysisDiv.innerHTML = `<div style="padding:16px;border-radius:10px;background:var(--surface2);border:1px solid var(--border);margin-bottom:14px;">${tableHtml}</div><div id="digital-ai-analysis" style="text-align:center;padding:16px;color:var(--text3);">${t('dp_generating_ai_msg')}</div>`;
4129  }
4130  
4131  // ── STEP 3: Build enriched context for AI ──
4132  btn.textContent = t('dp_btn_analyzing_ai');
4133  
4134  let ctx = `YOUR BUSINESS: ${dest}`;
4135  if (G.empresaIG) ctx += ` | Instagram: ${G.empresaIG}`;
4136  if (G.empresaSite) ctx += ` | Site: ${G.empresaSite}`;
4137  ctx += '\n';
4138  (G.concsData||[]).forEach((c, i) => {
4139    ctx += `COMPETITOR ${i+1}: ${c.nome} | Type: ${c.tipo === 'geografico' ? 'GEOGRAPHIC' : 'DIRECT'}`;
4140    if (c.ig) ctx += ` | Instagram: ${c.ig}`;
4141    if (c.site) ctx += ` | Site: ${c.site}`;
4142    ctx += '\n';
4143  });
4144  
4145  // Add Instagram real data to context
4146  let igCtx = '';
4147  if (Object.keys(igResults).length > 0) {
4148    igCtx = '\n\nREAL INSTAGRAM DATA (from Apify API — VERIFIED numbers, use them as-is):';
4149    Object.entries(igResults).forEach(([nome, ig]) => {
4150      igCtx += `\n${nome}: @${ig.username} | Followers=${ig.followers.toLocaleString()} | Following=${ig.following.toLocaleString()} | Posts=${ig.posts} | AvgLikes=${ig.avgLikes} | AvgComments=${ig.avgComments} | Engagement=${ig.engagementRate}% | Bio="${ig.bio}"`;
4151    });
4152    igCtx += '\nIMPORTANT: These follower counts and engagement rates are REAL verified data. Use exact numbers in your analysis.';
4153  }
4154  
4155  // Add PageSpeed real data to context
4156  let psCtx = '';
4157  if (hasPageSpeed) {
4158    psCtx = '\n\nREAL PAGESPEED DATA (from Google PageSpeed Insights API — these are VERIFIED scores, use them as-is):';
4159    Object.entries(pageSpeedResults).forEach(([nome, ps]) => {
4160      psCtx += `\n${nome}: Performance=${ps.performance}/100, SEO=${ps.seo}/100, Accessibility=${ps.accessibility}/100, BestPractices=${ps.bestPractices}/100, FCP=${ps.fcp}, LCP=${ps.lcp}, SpeedIndex=${ps.speedIndex}, CLS=${ps.cls}, Mobile=${ps.mobileViewport?'YES':'NO'}, MetaDescription=${ps.metaDescription?'YES':'NO'}`;
4161    });
4162    psCtx += '\nIMPORTANT: These scores are REAL measured data. Base your analysis on them. Do NOT contradict these numbers.';
4163  }
4164  
4165  const pageSpeedNote = hasPageSpeed ? 'You have REAL PageSpeed data above. Use it to make specific, data-driven comparisons. Reference exact scores.' : 'No PageSpeed data available. Analyze based on general knowledge of the sites/profiles.';
4166  const prompt = await getPrompt('digital-presence', { dest, ctx, psCtx, igCtx, langName, pageSpeedNote });
4167
4168  try {
4169    const raw = await callAIGlobal(prompt, 16384, true, { slug: 'digital-presence' });
4170    // Parse AI response — supports both JSON and raw text fallback
4171    const ai = parseDigitalAnalysisResponse(raw);
4172    // Render as visual cards (from pages/benchmark-digital.js)
4173    renderDigitalAnalysisCards(ai, pageSpeedResults, igResults, dest);
4174    // Expõe os dados p/ o motor de relatório (report.js buildBenchmark). O export é
4175    // data-driven e não clona o DOM, então precisa desse estado global p/ incluir o SEO.
4176    window._benchmarkDigital = {
4177      analysisId: sessionStorage.getItem('cp_active_analysis') || null,
4178      dest: dest, pageSpeed: pageSpeedResults, instagram: igResults, ai: ai
4179    };
4180    btn.textContent = t('dp_btn_analyzed');
4181    btn.classList.add('dp-btn-done');
4182    btn.disabled = false;
4183    btn.onclick = () => { btn.classList.remove('dp-btn-done'); runDigitalPresenceAnalysis(); };
4184
4185    // Salvar no banco
4186    try {
4187      const supabase = await getSupabase();
4188      const { data: { session } } = await supabase.auth.getSession();
4189      const analysisId = sessionStorage.getItem('cp_active_analysis') || null;
4190      // Sem analysis_id, o upsert grava linha com analysis_id=NULL — órfã, que
4191      // loadSavedBenchmark (exige id não-nulo) NUNCA restaura, e que polui a tabela.
4192      // Só persiste quando há análise ativa; na tela o SEO já foi renderizado acima.
4193      if (session && analysisId) {
4194        await supabase.from('benchmark_analysis').upsert({
4195          user_id: session.user.id,
4196          analysis_id: analysisId,
4197          destination: dest,
4198          pagespeed_data: pageSpeedResults,
4199          instagram_data: igResults,
4200          ai_analysis: ai,
4201          updated_at: new Date().toISOString()
4202        }, { onConflict: 'user_id,analysis_id' });
4203        console.log('[Benchmark] ✅ Análise salva no banco');
4204      } else if (session && !analysisId) {
4205        console.warn('[Benchmark] ⚠️ SEO não persistido: sem análise ativa (cp_active_analysis nulo)');
4206      }
4207    } catch(saveErr) { console.warn('[Benchmark] ⚠️ Erro ao salvar:', saveErr.message); }
4208  } catch(e) {
4209    const targetDiv = document.getElementById('digital-ai-analysis') || analysisDiv;
4210    targetDiv.innerHTML = `<div style="padding:12px;color:var(--red);font-size:13px;">${t('dp_error_ai_prefix')}${e.message}</div>`;
4211    btn.textContent = t('dp_btn_retry');
4212    btn.disabled = false;
4213  }
4214}
4215
4216// ── Carregar benchmark salvo ao abrir a aba ──
4217async function loadSavedBenchmark() {
4218  const analysisDiv = document.getElementById('digital-presence-analysis');
4219  const btn = document.getElementById('btn-digital-analysis');
4220  if (!analysisDiv || !btn) return;
4221
4222  // Reset SÍNCRONO do card de SEO ANTES de qualquer await. Sem isto, ao abrir o
4223  // benchmark de uma análise nova (que ainda NÃO tem linha em benchmark_analysis),
4224  // os early-returns abaixo saíam sem limpar a tela e os cards da análise ANTERIOR
4225  // ficavam grudados no DOM (componente é cacheado) — "SEO sempre da mesma cidade".
4226  // Se achar linha salva pra esta análise, o fluxo abaixo re-popula normalmente.
4227  const _currentId = sessionStorage.getItem('cp_active_analysis') || null;
4228  if (window._benchmarkDigital && window._benchmarkDigital.analysisId !== _currentId) {
4229    window._benchmarkDigital = null;  // export não vaza SEO de outra análise
4230  }
4231  analysisDiv.innerHTML = '';
4232  btn.classList.remove('dp-btn-done');
4233  btn.disabled = false;
4234  btn.textContent = t('dp_btn_analyze');
4235  btn.onclick = () => runDigitalPresenceAnalysis();
4236
4237  try {
4238    const supabase = await getSupabase();
4239    const { data: { session } } = await supabase.auth.getSession();
4240    if (!session) return;
4241
4242    const analysisId = _currentId;
4243    if (!analysisId) return;
4244
4245    const { data, error } = await supabase
4246      .from('benchmark_analysis')
4247      .select('*')
4248      .eq('user_id', session.user.id)
4249      .eq('analysis_id', analysisId)
4250      .single();
4251
4252    if (error || !data) return;
4253
4254    console.log('[Benchmark] 📦 Restaurando análise salva de', data.destination);
4255
4256    const pageSpeedResults = data.pagespeed_data || {};
4257    const igResults = data.instagram_data || {};
4258    const ai = data.ai_analysis || {};
4259    const dest = data.destination || G.dest || '';
4260
4261    // Renderizar tabela do PageSpeed
4262    const hasPageSpeed = Object.keys(pageSpeedResults).length > 0;
4263    const hasRealScores = Object.values(pageSpeedResults).some(ps => ps.source === 'pagespeed');
4264    if (hasPageSpeed && hasRealScores) {
4265      const scoreColor = v => { const n = parseInt(v)||0; return n >= 90 ? 'var(--green)' : n >= 50 ? 'var(--orange)' : 'var(--red)'; };
4266      const scoreEmoji = v => { const n = parseInt(v)||0; return n >= 90 ? '🟢' : n >= 50 ? '🟡' : '🔴'; };
4267      const checkMark = v => v ? '✅' : '❌';
4268      const allSites = [
4269        { nome: dest, principal: true },
4270        ...(G.concsData||[]).map(c => ({ nome: c.nome, principal: false })),
4271      ];
4272      let tableHtml = `<div style="font-size:12px;font-weight:700;color:var(--accent);text-transform:uppercase;letter-spacing:1px;margin-bottom:12px;">${t('dp_table_title_pagespeed')}</div><div style="overflow-x:auto;"><table style="width:100%;border-collapse:collapse;font-size:12px;"><thead><tr style="border-bottom:2px solid var(--border);"><th style="text-align:left;padding:8px;color:var(--text3);">${t('dp_th_site')}</th><th style="text-align:center;padding:8px;color:var(--text3);">${t('dp_th_performance')}</th><th style="text-align:center;padding:8px;color:var(--text3);">${t('dp_th_seo')}</th><th style="text-align:center;padding:8px;color:var(--text3);">${t('dp_th_accessibility')}</th><th style="text-align:center;padding:8px;color:var(--text3);">${t('dp_th_best_practices')}</th><th style="text-align:center;padding:8px;color:var(--text3);">${t('dp_th_fcp')}</th><th style="text-align:center;padding:8px;color:var(--text3);">${t('dp_th_lcp')}</th><th style="text-align:center;padding:8px;color:var(--text3);">${t('dp_th_meta_desc')}</th></tr></thead><tbody>`;
4273      allSites.forEach(s => {
4274        const ps = pageSpeedResults[s.nome];
4275        if (!ps || ps.source !== 'pagespeed') return;
4276        tableHtml += `<tr style="border-bottom:1px solid var(--border);${s.principal?'background:rgba(34,211,238,0.04);':''}"><td style="padding:8px;font-weight:${s.principal?'700':'500'};">${s.nome}${s.principal?' <span style="font-size:9px;padding:2px 5px;border-radius:100px;background:var(--accent-dim);color:var(--accent);">'+t('you_badge')+'</span>':''}<div style="font-size:10px;color:var(--text3);">${ps.url||''}</div></td><td style="text-align:center;padding:8px;font-weight:700;color:${scoreColor(ps.performance)};">${scoreEmoji(ps.performance)} ${ps.performance}</td><td style="text-align:center;padding:8px;font-weight:700;color:${scoreColor(ps.seo)};">${scoreEmoji(ps.seo)} ${ps.seo}</td><td style="text-align:center;padding:8px;font-weight:700;color:${scoreColor(ps.accessibility)};">${scoreEmoji(ps.accessibility)} ${ps.accessibility}</td><td style="text-align:center;padding:8px;font-weight:700;color:${scoreColor(ps.bestPractices)};">${scoreEmoji(ps.bestPractices)} ${ps.bestPractices}</td><td style="text-align:center;padding:8px;font-size:11px;">${ps.fcp||'?'}</td><td style="text-align:center;padding:8px;font-size:11px;">${ps.lcp||'?'}</td><td style="text-align:center;padding:8px;">${checkMark(ps.metaDescription)}</td></tr>`;
4277      });
4278      tableHtml += '</tbody></table></div>';
4279      analysisDiv.innerHTML = `<div style="padding:16px;border-radius:10px;background:var(--surface2);border:1px solid var(--border);margin-bottom:14px;">${tableHtml}</div><div id="digital-ai-analysis"></div>`;
4280    }
4281
4282    // Renderizar cards da IA
4283    renderDigitalAnalysisCards(ai, pageSpeedResults, igResults, dest);
4284    // Expõe os dados p/ o motor de relatório (report.js buildBenc
4284hmark) — mesma chave
4285    // (analysis_id) usada acima na busca, p/ o export incluir o SEO restaurado.
4286    window._benchmarkDigital = {
4287      analysisId: analysisId, dest: dest, pageSpeed: pageSpeedResults, instagram: igResults, ai: ai
4288    };
4289
4290    btn.textContent = t('dp_btn_analyze_again');
4291    btn.classList.add('dp-btn-done');
4292    btn.disabled = false;
4293    btn.onclick = () => {
4294      if (!confirm(t('dp_confirm_redo'))) return;
4295      btn.classList.remove('dp-btn-done');
4296      runDigitalPresenceAnalysis();
4297    };
4298  } catch(e) {
4299    console.warn('[Benchmark] Erro ao carregar salvo:', e.message);
4300  }
4301}
4302
4303function renderReport(dest, d, periodo) {
4304  const now=new Date();
4305  const langLocale={pt:'pt-BR',en:'en-US',es:'es-ES',fr:'fr-FR'}[G.lang]||'pt-BR';
4306  document.getElementById('report-date').textContent=now.toLocaleDateString(langLocale,{weekday:'long',day:'numeric',month:'long',year:'numeric'});
4307  document.getElementById('report-city').textContent=dest;
4308  document.getElementById('report-period').textContent=`${t('kpi_score')} — ${periodo} ${G.lang==='pt'?'dias':G.lang==='en'?'days':G.lang==='es'?'días':'jours'}`;
4309  document.getElementById('report-footer-dest').textContent=dest+' — Consensus Pulse';
4310  const footerEl = document.getElementById('report-footer-dest');
4311  if (footerEl && !document.getElementById('powered-by-badge')) {
4312    const badge = document.createElement('div');
4313    badge.id = 'powered-by-badge';
4314    badge.style.cssText = 'font-size:10px;color:var(--text3);margin-top:4px;opacity:.7;';
4315    badge.innerHTML = 'Powered by <strong>Consensus Framework™</strong> · UN Tourism AI Global Challenge Winner 2025';
4316    footerEl.parentNode.appendChild(badge);
4317  }
4318  
4319  // BASIC TIER — scores + highlights
4320  const pos=d.sentimento?.positivo||0;
4321  document.getElementById('report-scores').innerHTML=`
4322    <div class="report-score-box"><div class="report-score-val">${d.score||'--'}</div><div class="report-score-lbl">${t('kpi_score')}</div></div>
4323    <div class="report-score-box"><div class="report-score-val">${pos}%</div><div class="report-score-lbl">${t('kpi_sentiment')}</div></div>
4324    <div class="report-score-box"><div class="report-score-val">${d.n_oportunidades||'--'}</div><div class="report-score-lbl">${t('kpi_opp')}</div></div>`;
4325  const r=d.relatorio||{};
4326  document.getElementById('report-positivos').innerHTML=(r.positivos||[]).map(p=>`<li>${p}</li>`).join('');
4327  document.getElementById('report-alertas').innerHTML=(r.alertas||[]).map(a=>`<li>${a}</li>`).join('');
4328  document.getElementById('report-recos').innerHTML=(r.recomendacoes||[]).map(r=>`<li>${r}</li>`).join('');
4329  // Benchmark in report
4330  const reportBench = document.getElementById('report-benchmark');
4331  if (reportBench) {
4332    const sorted = [...(d.concorrentes||[])].sort((a,b) => b.score - a.score);
4333    const typeMap = {};
4334    (G.concsData||[]).forEach(cc => { if(cc.nome) typeMap[cc.nome.toLowerCase()] = cc.tipo; });
4335    reportBench.innerHTML = sorted.map((cc, i) => {
4336      const tipo = typeMap[(cc.nome||'').toLowerCase()];
4337      const tipoBadge = tipo === 'geografico' ? ' (📍 Geográfico)' : tipo === 'direto' ? ' (⚔️ Direto)' : '';
4338      return `<div style="display:flex;align-items:center;gap:8px;padding:6px 0;border-bottom:1px solid #eee;"><strong style="width:20px;color:#00A5B8;">#${i+1}</strong><span style="flex:1;">${cc.nome}${cc.principal ? ' (VOCÊ)' : ''}${tipoBadge}</span><strong>${cc.score}/100</strong></div>`;
4339    }).join('') + (d.benchmark_narrativa ? '<div style="margin-top:10px;font-size:13px;line-height:1.6;">' + (d.benchmark_narrativa||'').replace(/\*\*(.*?)\*\*/g,'<strong>$1</strong>') + '</div>' : '');
4340  }
4341  
4342  // PREMIUM TIER — heatmap, influencers, viral, happiness
4343  // ===== PLAN CHECK: Only show premium sections if user has premium+ plan =====
4344  const reportPlan = getUserPlan();
4345  const reportUnlockPremium = (reportPlan === 'premium' || reportPlan === 'oracle' || reportPlan === 'enterprise');
4346  const reportUnlockOracle = (reportPlan === 'oracle' || reportPlan === 'enterprise');
4347
4348  // Garantir que seções estão ocultas por padrão
4349  document.getElementById('report-premium-section').style.display = 'none';
4350  document.getElementById('report-oracle-section').style.display = 'none';
4351
4352  const hasPremium = reportUnlockPremium && (d.heatmap_areas || d.influencers || d.viral_predictions || d.feliz_score);
4353  if (hasPremium) {
4354    document.getElementById('report-premium-section').style.display = 'block';
4355    
4356    // Heatmap top areas
4357    const rHeatmap = document.getElementById('report-heatmap');
4358    if (rHeatmap && d.heatmap_areas) {
4359      rHeatmap.innerHTML = (d.heatmap_areas||[]).slice(0,6).map(a => {
4360        const col = (a.score||50)>=70?'var(--accent)':(a.score||50)>=40?'var(--yellow)':'var(--red)';
4361        return `<div style="display:flex;align-items:center;gap:10px;padding:8px 0;border-bottom:1px solid var(--border);">
4362          <span style="font-size:14px;">${a.emoji||'📍'}</span>
4363          <span style="flex:1;font-size:13px;">${a.nome}</span>
4364          <span style="font-size:11px;color:var(--text3);">${a.principais_mencoes||''}</span>
4365          <span style="font-family:Syne,sans-serif;font-size:14px;font-weight:700;color:${col};">${a.score||'--'}</span>
4366        </div>`;
4367      }).join('');
4368    }
4369    
4370    // Influencers
4371    const rInflu = document.getElementById('report-influencers');
4372    if (rInflu) {
4373      const curated = getCuratedInfluencers(G.sector || 'destination', G.dest || '');
4374      // Se curated está vazio e o Supabase falhou, NÃO usar dados do LLM (d.influencers)
4375      const infList = curated.length > 0 ? curated : (_curatedInfluencersLoadFailed ? [] : (d.influencers || []));
4376      if (infList.length === 0) {
4377        rInflu.innerHTML = '<div style="padding:12px;color:var(--text3);font-size:12px;font-style:italic;">⚠️ Dados de influenciadores indisponíveis</div>';
4378      } else {
4379        rInflu.innerHTML = infList.slice(0,5).map(inf => 
4380        `<div style="display:flex;align-items:center;gap:10px;padding:8px 0;border-bottom:1px solid var(--border);">
4381          <span style="font-size:14px;">${inf.emoji||'👤'}</span>
4382          <span style="font-size:13px;font-weight:600;">${inf.nome}</span>
4383          <span style="font-size:11px;color:var(--text3);">@${inf.handle} · ${inf.plataforma}</span>
4384          <span style="font-size:11px;padding:2px 8px;border-radius:100px;background:var(--accent-dim);color:var(--accent);">${inf.nicho}</span>
4385          <span style="font-family:Syne,sans-serif;font-size:13px;font-weight:700;color:var(--accent);margin-left:auto;">${inf.engagement_score}%</span>
4386        </div>`
4387      ).join('');
4388      }
4389    }
4390    
4391    // Viral predictions
4392    const rViral = document.getElementById('report-viral');
4393    if (rViral && d.viral_predictions) {
4394      rViral.innerHTML = (d.viral_predictions||[]).slice(0,3).map(v => 
4395        `<div style="display:flex;align-items:center;gap:10px;padding:8px 0;border-bottom:1px solid var(--border);">
4396          <span style="font-size:14px;">${v.emoji||'🚀'}</span>
4397          <span style="flex:1;font-size:13px;">${v.titulo}</span>
4398          <span style="font-size:11px;color:var(--text3);">${v.janela||''}</span>
4399          <span style="font-family:Syne,sans-serif;font-size:13px;font-weight:700;color:var(--accent);">${v.probabilidade}%</span>
4400        </div>`
4401      ).join('');
4402    }
4403    
4404    // Happiness index
4405    const rHappy = document.getElementById('report-happiness');
4406    if (rHappy && d.feliz_score) {
4407      const hScore = d.feliz_score;
4408      const hCol = hScore>=70?'var(--accent)':hScore>=40?'var(--yellow)':'var(--red)';
4409      const hLabel = hScore>=70?(d.feliz_label||'Satisfeito'):hScore>=40?'Neutro':'Insatisfeito';
4410      let html = `<div style="display:flex;align-items:center;gap:16px;margin-bottom:12px;">
4411        <div style="font-family:Syne,sans-serif;font-size:36px;font-weight:800;color:${hCol};">${hScore}</div>
4412        <div><div style="font-size:14px;font-weight:700;">${hLabel}</div><div style="font-size:11px;color:var(--text3);">Índice de Felicidade Real</div></div>
4413      </div>`;
4414      if (d.feliz_emotions) {
4415        html += (d.feliz_emotions||[]).slice(0,4).map(e => 
4416          `<div style="display:flex;align-items:center;gap:8px;padding:6px 0;">
4417            <span>${e.emoji||'💭'}</span><span style="font-size:12px;flex:1;">${e.nome}</span>
4418            <span style="font-size:12px;font-weight:600;color:var(--text2);">${e.pct}%</span>
4419          </div>`
4420        ).join('');
4421      }
4422      rHappy.innerHTML = html;
4423    }
4424  }
4425  
4426  // ORACLE TIER — markets, narratives, oracle alerts, can
4427  const hasOracle = reportUnlockOracle && (d.emissores_oportunidade || d.oracle_alerts || d.can_briefing);
4428  const emissores = d.emissores_ALWAYS_RETURN_ALL_4_DO_NOT_SKIP || d.emissores || [];
4429  if (reportUnlockOracle && (hasOracle || emissores.length > 0)) {
4430    document.getElementById('report-oracle-section').style.display = 'block';
4431    
4432    // Market Opportunity Ranking
4433    const rMarkets = document.getElementById('report-markets');
4434    if (rMarkets && d.emissores_oportunidade) {
4435      const sorted = [...(d.emissores_oportunidade||[])].sort((a,b) => (parseInt(b.opportunity_score)||0) - (parseInt(a.opportunity_score)||0));
4436      const medals = ['🥇','🥈','🥉','4️⃣','5️⃣'];
4437      rMarkets.innerHTML = sorted.map((m,i) => {
4438        const sc = parseInt(m.opportunity_score)||0;
4439        const col = sc>=70?'var(--accent)':sc>=45?'var(--yellow)':'var(--red)';
4440        return `<div style="display:flex;align-items:center;gap:12px;padding:10px 0;border-bottom:1px solid var(--border);">
4441          <span style="font-size:16px;">${medals[i]||'🔹'}</span>
4442          <span style="font-size:14px;">${m.flag||'🌍'}</span>
4443          <span style="font-size:13px;font-weight:700;flex:1;">${m.pais}</span>
4444          <span style="font-size:11px;color:var(--text3);">${m.turistas_atuais||''} · ${m.gasto_medio||''}</span>
4445          <span style="font-size:11px;color:var(--accent);font-weight:600;">${m.impacto_anual||''}</span>
4446          <span style="font-size:11px;color:var(--green);font-weight:700;">${m.potencial_adicional||''}</span>
4447          <span style="font-family:Syne,sans-serif;font-size:16px;font-weight:800;color:${col};min-width:40px;text-align:right;">${sc}</span>
4448        </div>`;
4449      }).join('');
4450    }
4451    
4452    // Narratives
4453    const rNarr = document.getElementById('report-narratives');
4454    const posNarr = d.emissores_narrativas_positivas || [];
4455    const negNarr = d.emissores_narrativas_negativas || [];
4456    if (rNarr && (posNarr.length || negNarr.length)) {
4457      let html = '';
4458      if (posNarr.length) {
4459        html += `<div style="font-size:12px;font-weight:700;color:var(--accent);margin-bottom:8px;">📈 Em crescimento</div>`;
4460        html += posNarr.map(n => `<div style="display:flex;align-items:center;gap:8px;padding:6px 0;border-bottom:1px solid var(--border);"><span>${n.emoji||'📈'}</span><span style="flex:1;font-size:12px;">${n.narrativa}</span><span style="font-size:11px;color:var(--accent);font-weight:600;">${n.crescimento}</span></div>`).join('');
4461      }
4462      if (negNarr.length) {
4463        html += `<div style="font-size:12px;font-weight:700;color:var(--red);margin:12px 0 8px;">⚠️ Narrativas de risco</div>`;
4464        html += negNarr.map(n => `<div style="display:flex;align-items:center;gap:8px;padding:6px 0;border-bottom:1px solid var(--border);"><span>${n.emoji||'⚠️'}</span><span style="flex:1;font-size:12px;">${n.narrativa}</span><span style="font-size:11px;color:var(--red);font-weight:600;">${n.crescimento}</span></div>`).join('');
4465      }
4466      rNarr.innerHTML = html;
4467    }
4468    
4469    // Oracle alerts
4470    const rOracle = document.getElementById('report-oracle');
4471    if (rOracle && d.oracle_alerts) {
4472      rOracle.innerHTML = (d.oracle_alerts||[]).slice(0,3).map(a => 
4473        `<div style="padding:10px 0;border-bottom:1px solid var(--border);">
4474          <div style="display:flex;align-items:center;gap:8px;margin-bottom:4px;">
4475            <span style="font-size:14px;">${a.emoji||'⚡'}</span>
4476            <span style="font-size:13px;font-weight:700;">${a.titulo}</span>
4477            <span style="font-size:10px;padding:2px 8px;border-radius:100px;background:rgba(139,92,246,0.1);color:var(--violet);">${a.confianca||'--'}%</span>
4478          </div>
4479          <div style="font-size:12px;color:var(--text2);">${a.descricao || ''}</div>
4480          <div style="font-size:11px;color:var(--accent);margin-top:4px;">→ ${a.acao||''}</div>
4481        </div>`
4482      ).join('');
4483    }
4484    
4485    // Can briefing
4486    const rCan = document.getElementById('report-can');
4487    if (rCan && d.can_briefing) {
4488      rCan.innerHTML = `<div style="font-size:13px;color:var(--text2);line-height:1.7;">${(d.can_briefing||'').replace(/\*\*(.*?)\*\*/g,'<strong style="color:var(--text);">$1</strong>').replace(/\n/g,'<br>')}</div>`;
4489    } else if (rCan && d.can_msg) {
4490      rCan.innerHTML = `<div style="font-size:13px;color:var(--text2);line-height:1.7;">${(d.can_msg||'').replace(/\*\*(.*?)\*\*/g,'<strong style="color:var(--text);">$1</strong>').replace(/\n/g,'<br>')}</div>`;
4491    }
4492  }
4493}
4494
4495
4496function renderHeatmap(d) {
4497  if (!_heatmapHasData(d)) {
4498    // Sob demanda: módulo não rodou no onboarding → mostra CTA convidativo.
4499    // Só vira erro real se o user clicar no CTA e a chamada falhar.
4500    if (_showModuleCTA('heatmap')) return;
4501    _showModuleError('heatmap', { icon: '🗺️', title: t('err_title_heatmap'), buttonStyle: 'background:linear-gradient(135deg,var(--purple),var(--blue))', retryFn: _retryHeatmap });
4502    return;
4503  }
4504  _hideModuleError('heatmap');
4505  // Clear stale data from previous analysis
4506  ['heatmap-list','silent-crisis-list'].forEach(id=>{const el=document.getElementById(id);if(el)el.innerHTML='';});
4507  const list = document.getElementById('heatmap-list');
4508  
4509  if (!d.heatmap_areas || d.heatmap_areas.length === 0) {
4510    list.innerHTML = '<div style="padding:24px;text-align:center;color:var(--text3);font-size:13px;">⏳ Dados de mapa de calor não disponíveis nesta análise. Tente executar uma nova análise.</div>';
4511    return;
4512  }
4513  
4514  // Ordem crua (sem reordenar por score): heatmap_areas já vem na mesma ordem da lista de
4515  // atrativos (curadoria do admin ou fallback automático) — a IA gera 1 entrada por atrativo,
4516  // NESTA ORDEM (ver buildAtrativosFixosCtx). Score é inferido pela IA a partir de rating+reviews,
4517  // não é um dado real comparável entre lugares — reordenar por ele não tem base sólida.
4518  const sortedAreas = [...(d.heatmap_areas||[])];
4519  
4520  sortedAreas.forEach((a, idx) => {
4521    // Nota real do Google Places (a mesma que o admin vê/cadastrou) — não é mais um score
4522    // 0-100 inventado pela IA. Sem "tendência" (subindo/caindo): não há dado histórico real
4523    // por trás dessa seta, então ela saiu do card (ver _retryHeatmap).
4524    const hasRating = a.google_rating != null && !isNaN(parseFloat(a.google_rating));
4525    const rating = hasRating ? parseFloat(a.google_rating) : 0;
4526    const col = !hasRating ? 'var(--text3)' : (rating>=4.5?'var(--accent)':rating>=3.5?'var(--yellow)':'var(--red)');
4527    const barPct = hasRating ? Math.min((rating/5)*100, 100) : 0;
4528    const ratingLabel = hasRating ? rating.toFixed(1) : '—';
4529    // Card só expande quando existe resumo de reviews reais coletados pra este atrativo —
4530    // sem review de texto, não tem o que mostrar no drill-down (ver _retryHeatmap).
4531    const clickable = !!(a.resumo_reviews && a.resumo_reviews.trim());
4532    const rawReviews = Array.isArray(a.reviews) ? a.reviews : [];
4533    const el = document.createElement('div');
4534    el.style.cssText='display:flex;align-items:center;gap:12px;padding:12px 14px;border-radius:10px;background:var(--surface2);border:1px solid var(--border);';
4535    // 2 toggles independentes, cada um só no seu próprio botão (não no card inteiro nem um
4536    // dentro do outro) — clicar/selecionar texto em qualquer lugar não fecha o painel, e dá
4537    // pra copiar o texto dos reviews sem o clique acidentalmente recolher tudo.
4538    const detailsBtn = clickable
4539      ? `<button type="button" data-heatmap-toggle="${idx}" style="font-size:10px;color:var(--text3);background:none;border:1px solid var(--border);border-radius:100px;padding:2px 8px;cursor:pointer;">Detalhes ▾</button>`
4540      : '';
4541    const reviewsList = rawReviews.map(r => {
4542      const stars = '★'.repeat(Math.round(r.stars||0)) + '☆'.repeat(Math.max(0,5-Math.round(r.stars||0)));
4543      const dt = r.date ? new Date(r.date).toLocaleDateString('pt-BR') : '';
4544      return `<div style="padding:8px 0;border-top:1px solid var(--border);">
4545        <div style="font-size:11px;color:var(--yellow);">${stars} <span style="color:var(--text3);">${_secEsc(r.name||'anônimo')}${dt?' · '+dt:''}</span></div>
4546        <div style="font-size:12px;color:var(--text2);line-height:1.5;margin-top:2px;user-select:text;">${_secEsc(r.text||'')}</div></div>`;
4547    }).join('');
4548    const reviewsToggle = rawReviews.length
4549      ? `<button type="button" data-heatmap-reviews-toggle="${idx}" style="margin-top:8px;font-size:10px;color:var(--accent);background:none;border:none;padding:0;cursor:pointer;">Ver os ${rawReviews.length} reviews ▾</button>
4550         <div id="heatmap-reviews-${idx}" style="display:none;">${reviewsList}</div>`
4551      : '';
4552    el.innerHTML=`<div style="font-size:18px;">${a.emoji||'📍'}</div><div style="flex:1;"><div style="display:flex;align-items:center;gap:6px;margin-bottom:6px;flex-wrap:wrap;"><div style="font-size:13px;font-weight:600;">${a.nome}</div>${a.fator_principal ? `<span style="font-size:9px;padding:2px 6px;border-radius:100px;background:var(--surface3);color:var(--text3);margin-left:auto;">${a.fator_principal}</span>` : ''}${detailsBtn}</div><div style="height:4px;background:var(--border);border-radius:2px;"><div style="width:0%;height:100%;border-radius:2px;background:${col};transition:width 1s ease;" data-w="${barPct}%"></div></div><div style="font-size:11px;color:var(--text2);margin-top:4px;">${a.principais_mencoes||''}</div>${clickable ? `<div id="heatmap-drill-${idx}" style="display:none;margin-top:8px;padding:8px 10px;border-radius:7px;background:var(--surface3);border-left:2px solid ${col};"><div style="font-size:11px;color:var(--text3);margin-bottom:4px;">💬 O que os reviews dizem</div><div style="font-size:12px;color:var(--text2);line-height:1.5;user-select:text;">${a.resumo_reviews}</div>${reviewsToggle}</div>` : ''}</div><div style="font-family:Syne,sans-serif;font-size:16px;font-weight:700;color:${col};">${ratingLabel}<span style="font-size:10px;color:var(--text3);font-weight:400;">${hasRating?'/5':''}</span></div>`;
4553    const detailsBtnEl = el.querySelector('[data-heatmap-toggle="'+idx+'"]');
4554    if (detailsBtnEl) detailsBtnEl.onclick = () => { const drill = document.getElementById('heatmap-drill-'+idx); if(drill) drill.style.display = drill.style.display==='none'?'block':'none'; };
4555    const reviewsBtnEl = el.querySelector('[data-heatmap-reviews-toggle="'+idx+'"]');
4556    if (reviewsBtnEl) reviewsBtnEl.onclick = () => {
4557      const list = document.getElementById('heatmap-reviews-'+idx);
4558      if (!list) return;
4559      const open = list.style.display !== 'none';
4560      list.style.display = open ? 'none' : 'block';
4561      reviewsBtnEl.textContent = open ? `Ver os ${rawReviews.length} reviews ▾` : 'Ocultar reviews ▴';
4562    };
4563    list.appendChild(el);
4564  });
4565  setTimeout(()=>list.querySelectorAll('[data-w]').forEach(el=>el.style.width=el.dataset.w),300);
4566  const sc = document.getElementById('silent-crisis-list');
4567  (d.silent_crisis||[]).forEach(c=>{
4568    const el=document.createElement('div');
4569    el.style.cssText='padding:12px 14px;border-radius:10px;background:var(--surface2);border:1px solid var(--border);border-left:3px solid var(--red);';
4570    el.innerHTML=`<div style="font-size:13px;font-weight:600;color:var(--red);margin-bottom:4px;">⚠️ ${c.tema}</div><div style="font-size:12px;color:var(--text2);">${c.descricao}</div><div style="font-size:11px;color:var(--text3);margin-top:4px;">${t('growth_label')}: <span style="color:var(--red);font-weight:600;">${c.crescimento}</span></div>`;
4571    sc.appendChild(el);
4572  });
4573  document.getElementById('heatmap-analysis').innerHTML=(d.heatmap_analise||'--').replace(/\*\*(.*?)\*\*/g,'<strong>$1</strong>').replace(/\n/g,'<br>');
4574  
4575  // Sector Ratings — nota REAL (cache) ou "sem dados". Clique abre modal de estabelecimentos+reviews.
4576  const sectorGrid = document.getElementById('sector-ratings-grid');
4577  if (sectorGrid) {
4578    const ratings = (d.sector_ratings && d.sector_ratings.length)
4579      ? d.sector_ratings
4580      : SECTOR_TAXONOMY.map(s => ({ emoji:s.emoji, setor:s.setor, nota:null }));
4581    renderSectorRatings(sectorGrid, ratings);
4582    // Sem dados pré-configurados → coletar na hora (igual admin); só este card em loading.
4583    if (!_sectorRatingsHaveData(ratings)) ensureSectorRatings(d, G.dest || '');
4584  }
4585}
4586
4587// Renderiza o grid de avaliação média por setor (extraído de renderHeatmap p/ reuso).
4588function renderSectorRatings(grid, ratings) {
4589  if (!grid) return;
4590  grid.innerHTML = '';
4591  (ratings || []).forEach(s => {
4592    const hasData = s.nota != null && !isNaN(parseFloat(s.nota));
4593    const nota = hasData ? parseFloat(s.nota) : 0;
4594    const barColor = nota >= 4.5 ? 'var(--accent)' : nota >= 3.5 ? 'var(--yellow)' : 'var(--red)';
4595    const barWidth = hasData ? Math.min((nota / 5) * 100, 100) : 0;
4596    const sub = hasData
4597      ? (s.estabCount ? t('sector_locais_avaliacoes').replace('{n}', s.estabCount).replace('{m}', s.reviewCount||0) : t('sector_media_real'))
4598      : t('sector_sem_dados');
4599    // Clique só abre o modal (nomes/nota/reviews por estabelecimento) quando pelo menos 1 lugar do
4600    // setor tem texto de review coletado — o admin busca isso sob demanda (custo extra), então a
4601    // maioria dos setores não tem. Nota e contagem de avaliações aparecem sempre, com ou sem clique.
4602    const clickable = !!s.hasReviewText;
4603    const el = document.createElement('div');
4604    el.style.cssText = 'padding:12px;border-radius:10px;background:var(--surface2);border:1px solid var(--border);text-align:center;transition:transform .22s ease,border-color .22s ease,box-shadow .22s ease;'
4605      + (clickable ? 'cursor:pointer;' : '');
4606    if (clickable) {
4607      el.onmouseenter = () => { el.style.transform='translateY(-3px)'; el.style.borderColor='var(--border2)'; el.style.boxShadow='0 12px 32px rgba(0,0,0,.38),0 0 0 1px var(--cyan-soft),0 0 26px rgba(34,211,238,.12)'; };
4608      el.onmouseleave = () => { el.style.transform=''; el.style.borderColor='var(--border)'; el.style.boxShadow=''; };
4609      el.onclick = () => openSectorModal(s.setor);
4610    }
4611    el.innerHTML = `
4612        <div style="font-size:20px;margin-bottom:6px;">${s.emoji || '⭐'}</div>
4613        <div style="font-family:Syne,sans-serif;font-size:22px;font-weight:800;color:${hasData?'var(--text)':'var(--text3)'};">${hasData ? nota.toFixed(2) : '—'}<span style="font-size:11px;color:var(--text3);font-weight:400;">/5</span></div>
4614        <div style="height:3px;background:var(--border);border-radius:2px;margin:6px 0;"><div style="width:${barWidth}%;height:100%;border-radius:2px;background:${barColor};transition:width 1s;"></div></div>
4615        <div style="font-size:11px;color:var(--text2);font-weight:600;">${_sectorLabel(s.setor)}</div>
4616        <div style="font-size:9px;color:var(--text3);margin-top:3px;">${sub}${clickable ? ' · '+t('sector_click_hint')+' ▾' : ''}</div>`;
4617    grid.appendChild(el);
4618  });
4619}
4620
4621function _sectorRatingsHaveData(ratings) {
4622  return Array.isArray(ratings) && ratings.some(s => s.nota != null && !isNaN(parseFloat(s.nota)));
4623}
4624
4625// Coleta sob demanda quando a cidade não tem dados de setor pré-configurados.
4626// NÃO coleta mais no BROWSER (era frágil e só rodava com a tela aberta). Agora ENFILEIRA a
4627// cidade em sector_collect_queue — o cron + Edge Function `sector-collect` coleta em BACKGROUND
4628// (continua mesmo com a tela fechada) e salva em sector_establishments_cache. Aqui só pintamos
4629// "coletando…" e fazemos POLLING do cache; quando o dado existir, renderiza e persiste.
4630async function ensureSectorRatings(d, dest) {
4631  const parts = (typeof _destParts === 'function') ? _destParts(dest) : { cityNorm: (dest||'').split(',')[0].trim().toLowerCase() };
4632  const cityNorm = parts.cityNorm;
4633  if (!cityNorm) return;
4634  window._sectorCollectTried = window._sectorCollectTried || {};
4635  if (window._sectorCollectTried[cityNorm]) return;
4636  window._sectorCollectTried[cityNorm] = true;
4637
4638  const grid = document.getElementById('sector-ratings-grid');
4639  if (!grid) return;
4640  grid.innerHTML = '<div style="grid-column:1/-1;padding:24px;text-align:center;color:var(--text3);font-size:13px;">'
4641    + '<div style="font-size:22px;margin-bottom:8px;">⏳</div>'
4642    + '<div id="sector-collect-msg">'+t('sector_collect_msg')+'<br>'
4643    + '<span style="font-size:11px;color:var(--text3);">'+t('sector_collect_hint')+'</span></div></div>';
4644
4645  // 1) Enfileira a cidade p/ o worker de background (fire-and-forget; RLS exige autenticado).
4646  try {
4647    const supabase = await getSupabase();
4648    if (supabase) {
4649      let _uid = null;
4650      try { const { data: { session } } = await supabase.auth.getSession(); _uid = (session && session.user && session.user.id) || null; } catch (_e) {}
4651      const res = await supabase.from('sector_collect_queue').upsert(
4652        { destination: cityNorm, dest_full: dest, status: 'pending', attempts: 0, last_error: null, requested_by: _uid, updated_at: new Date().toISOString() },
4653        { onConflict: 'destination' }
4654      );
4655      if (res && res.error) console.warn('[Setores] enfileirar falhou:', res.error.message);
4656    }
4657  } catch (e) { console.warn('[Setores] enfileirar falhou:', (e && e.message) || e); }
4658
4659  // 2) Polling do cache até os dados chegarem (ou timeout).
4660  _startSectorPolling(d, dest, cityNorm);
4661}
4662
4663// Polling: lê loadSectorRatings a cada 18s; quando houver dados, renderiza + persiste e para.
4664// Timeout ~12 min → mostra botão "tentar de novo". Aborta se a análise mudar (run-token) — sem
4665// vazar timers. Persiste em _lastAnalysisData mesmo se o usuário saiu do heatmap (reabre pronto).
4666function _startSectorPolling(d, dest, cityNorm) {
4667  if (window._sectorPollTimer) { clearInterval(window._sectorPollTimer); window._sectorPollTimer = null; }
4668  const myToken = window._analysisRunToken || 0;
4669  const startedAt = Date.now();
4670  const TIMEOUT_MS = 12 * 60 * 1000;
4671  const POLL_MS = 18 * 1000;
4672  // Handle LOCAL deste polling — stop() só limpa o PRÓPRIO timer e só zera a global se ainda for o
4673  // nosso (um tick obsoleto de uma análise anterior nunca derruba o timer da análise nova).
4674  let myTimer = null;
4675  const stop = () => { if (myTimer) clearInterval(myTimer); if (window._sectorPollTimer === myTimer) window._sectorPollTimer = null; myTimer = null; };
4676  const aborted = () => (window._analysisRunToken || 0) !== myToken; // nova análise/restore
4677  const tick = async () => {
4678    if (aborted()) { stop(); return; }
4679    if (Date.now() - startedAt > TIMEOUT_MS) {
4680      stop();
4681      if (window._sectorCollectTried) delete window._sectorCollectTried[cityNorm]; // permite re-tentar
4682      const g = document.getElementById('sector-ratings-grid');
4683      if (g) _sectorCollectShowEmpty(g);
4684      return;
4685    }
4686    try {
4687      const _sec = await loadSectorRatings(dest);
4688      // RECHECK pós-await: se a análise mudou durante o await, NÃO escreve dados da cidade antiga
4689      // no registro/objeto da análise nova (evita merge_analysis_field no cp_active_analysis errado).
4690      if (aborted()) { stop(); return; }
4691      if (_sec && _sectorRatingsHaveData(_sec.ratings)) {
4692        stop();
4693        window._sectorEstablishments = _sec.rowsBySector;
4694        const g = document.getElementById('sector-ratings-grid');
4695        if (g) renderSectorRatings(g, _sec.ratings);
4696        const dd = window._lastAnalysisData || d;
4697        if (dd) {
4698          dd.sector_ratings = _sec.ratings;
4699          window._lastAnalysisData = dd;
4700          try {
4701            const supabase = await getSupabase();
4702            const analysisId = sessionStorage.getItem('cp_active_analysis');
4703            if (analysisId) await supabase.rpc('merge_analysis_field', { p_analysis_id: analysisId, p_field: 'sector_ratings', p_value: JSON.stringify(_sec.ratings) });
4704          } catch (_e) {}
4705        }
4706      }
4707    } catch (e) { console.warn('[Setores] polling:', (e && e.message) || e); }
4708  };
4709  tick(); // checagem imediata (backend pode já ter coletado)
4710  myTimer = setInterval(tick, POLL_MS);
4711  window._sectorPollTimer = myTimer;
4712}
4713
4714function _sectorCollectShowEmpty(grid) {
4715  renderSectorRatings(grid, SECTOR_TAXONOMY.map(s => ({ emoji:s.emoji, setor:s.setor, nota:null })));
4716  const btn = document.createElement('div');
4717  btn.style.cssText = 'grid-column:1/-1;text-align:center;margin-top:10px;';
4718  btn.innerHTML = '<button onclick="retrySectorRatings()" style="padding:7px 16px;border-radius:8px;border:1px solid var(--purple);background:transparent;color:var(--purple);font-weight:700;font-size:12px;cursor:pointer;">'+t('sector_retry_btn')+'</button>';
4719  grid.appendChild(btn);
4720}
4721
4722window.retrySectorRatings = function() {
4723  const dest = (typeof G !== 'undefined' && G.dest) ? G.dest : '';
4724  const parts = (typeof _destParts === 'function') ? _destParts(dest) : { cityNorm: (dest||'').split(',')[0].trim().toLowerCase() };
4725  if (window._sectorCollectTried) delete window._sectorCollectTried[parts.cityNorm];
4726  ensureSectorRatings(window._lastAnalysisData || {}, dest);
4727};
4728
4729// ===== Modal de estabelecimentos por setor (reviews + infos) =====
4730async function openSectorModal(setor){
4731  const modal = document.getElementById('sector-modal');
4732  const body  = document.getElementById('sector-modal-body');
4733  const title = document.getElementById('sector-modal-title');
4734  if (!modal || !body) return;
4735  let rows = (window._sectorEstablishments && window._sectorEstablishments[setor]) || null;
4736  // Lazy-load: reload sem ter rodado _retryHeatmap nesta sessão.
4737  if (!rows) {
4738    body.innerHTML = '<div style="padding:24px;text-align:center;color:var(--text3);font-size:13px;">Carregando…</div>';
4739    modal.style.display = 'flex';
4740    try { const _sec = await loadSectorRatings(G.dest || ''); if (_sec) { window._sectorEstablishments = _sec.rowsBySector; rows = _sec.rowsBySector[setor]; } } catch(_e){}
4741  }
4742  const tax = SECTOR_TAXONOMY.find(t => t.setor === setor) || { emoji:'⭐', setor:setor };
4743  if (title) title.innerHTML = `${tax.emoji} ${_secEsc(setor)}`;
4744  if (!rows || !rows.length) {
4745    body.innerHTML = '<div style="padding:24px;text-align:center;color:var(--text3);font-size:13px;">Sem dados coletados para este setor.<br>Peça ao admin para usar "⭐ Coletar dados por setor" na cidade.</div>';
4746    modal.style.display = 'flex';
4747    return;
4748  }
4749  const sorted = rows.slice().sort((a,b) => (b.reviews_count||0) - (a.reviews_count||0));
4750  body.innerHTML = sorted.map(r => {
4751    const addr = [r.street, r.city].filter(Boolean).join(' · ');
4752    const revs = (r.reviews||[]).map(rv => {
4753      const stars = '★'.repeat(Math.round(rv.stars||0)) + '☆'.repeat(Math.max(0,5-Math.round(rv.stars||0)));
4754      const dt = rv.date ? new Date(rv.date).toLocaleDateString('pt-BR') : '';
4755      return `<div style="padding:8px 0;border-top:1px solid var(--border);">
4756        <div style="font-size:11px;color:var(--yellow);">${stars} <span style="color:var(--text3);">${_secEsc(rv.name||'anon')}${dt?' · '+dt:''}</span></div>
4757        <div style="font-size:12px;color:var(--text2);line-height:1.5;margin-top:2px;">${_secEsc(rv.text||'')}</div></div>`;
4758    }).join('') || '<div style="font-size:11px;color:var(--text3);padding:6px 0;">Sem reviews coletados.</div>';
4759    return `<div style="margin-bottom:14px;padding:14px;border-radius:12px;background:var(--surface2);border:1px solid var(--border);">
4760      <div style="display:flex;justify-content:space-between;align-items:flex-start;gap:10px;">
4761        <div><div style="font-size:14px;font-weight:700;color:var(--text);">${_secEsc(r.title||'—')}</div>
4762        <div style="font-size:11px;color:var(--text3);margin-top:2px;">${_secEsc(r.category||'')}${addr?' · '+_secEsc(addr):''}</div></div>
4763        <div style="white-space:nowrap;font-family:Syne,sans-serif;font-size:15px;font-weight:700;color:var(--accent);">⭐ ${r.total_score!=null?r.total_score:'—'} <span style="font-size:10px;color:var(--text3);font-weight:400;">(${r.reviews_count||0})</span></div>
4764      </div>
4765      <div style="margin-top:8px;">${revs}</div>
4766    </div>`;
4767  }).join('');
4768  modal.style.display = 'flex';
4769}
4770function closeSectorModal(){ const m = document.getElementById('sector-modal'); if (m) m.style.display = 'none'; }
4771
4772// ===== CURATED INFLUENCER DATABASE =====
4773// Priority: state-level > country-level > AI-generated
4774// Sector: destination (tourism) — dados carregados do Supabase via loadCuratedInfluencersForDest()
4775var CURATED_INFLUENCERS = {
4776  destination: { BR: { _states: {}, _country: [] } },
4777  hotel: { BR: { _states: {}, _country: [] } },
4778  restaurant: { BR: { _states: {}, _country: [] } },
4779  attraction: { BR: { _states: {}, _country: [] } },
4780  agency: { BR: { _states: {}, _country: [] } },
4781};
4782
4783function getCuratedInfluencers(sector, dest) {
4784  // Parse dest: "Florianópolis, SC, Brasil" or "Florianópolis, SC, BRASIL"
4785  const parts = (dest || '').split(',').map(s => s.trim());
4786  let country = '', state = '';
4787  
4788  if (parts.length >= 3) {
4789    country = parts[parts.length - 1];
4790    state = parts[parts.length - 2];
4791  } else if (parts.length === 2) {
4792    state = parts[1];
4793  }
4794  
4795  // Normalize country to 2-letter code
4796  const countryMap = {
4797    'brasil':'BR', 'brazil':'BR', 'br':'BR',
4798    'estados unidos':'US', 'united states':'US', 'usa':'US', 'us':'US',
4799    'argentina':'AR', 'ar':'AR',
4800    'portugal':'PT', 'pt':'PT',
4801    'espanha':'ES', 'spain':'ES', 'españa':'ES',
4802    'frança':'FR', 'france':'FR',
4803    'colombia':'CO',
4804    'chile':'CL',
4805    'mexico':'MX', 'méxico':'MX',
4806    'uruguai':'UY', 'uruguay':'UY',
4807    'peru':'PE', 'perú':'PE',
4808    'italia':'IT', 'italy':'IT',
4809  };
4810  const cc = countryMap[country.toLowerCase()] || country.toUpperCase().slice(0,2);
4811  const st = state.toUpperCase().trim();
4812  
4813  const sectorDb = CURATED_INFLUENCERS[sector] || CURATED_INFLUENCERS.destination;
4814  const countryDb = sectorDb[cc];
4815  if (!countryDb) return [];
4816  
4817  let result = [];
4818  
4819  // State-level first
4820  if (st && countryDb._states && countryDb._states[st]) {
4821    result = result.concat(countryDb._states[st]);
4822  }
4823  
4824  // Then country-level
4825  if (countryDb._country) {
4826    result = result.concat(countryDb._country);
4827  }
4828  
4829  // Limit to 5 (2 state + up to 3 country, or up to 5 country if no state match)
4830  return result.slice(0, 5);
4831}
4832
4833function getInfluencerUrl(inf) {
4834  const h = (inf.handle||'').replace('@','') || (inf.nome||'influencer').replace(/\s+/g,'').toLowerCase();
4835  const p = (inf.plataforma||'instagram').toLowerCase();
4836  if(p==='tiktok') return `https://www.tiktok.com/@${h}`;
4837  if(p==='youtube') return `https://www.youtube.com/@${h}`;
4838  if(p==='x'||p==='twitter') return `https://x.com/${h}`;
4839  return `https://www.instagram.com/${h}/`;
4840}
4841function getPlatformIcon(p) {
4842  const icons = {instagram:'📸',tiktok:'🎵',youtube:'▶️',x:'𝕏',twitter:'𝕏'};
4843  return icons[(p||'instagram').toLowerCase()]||'📱';
4844}
4845
4846// ===== INFLUENCER ROI ANALYZER =====
4847async function analyzeInfluencer() {
4848  const handle = document.getElementById('inf-analyze-handle').value.trim().replace('@','');
4849  const platform = document.getElementById('inf-analyze-platform').value;
4850  const resultDiv = document.getElementById('inf-analyzer-result');
4851  if (!handle) return alert('Digite o @ do influenciador');
4852  
4853  resultDiv.style.display = 'block';
4854  resultDiv.innerHTML = '<div style="text-align:center;padding:20px;color:var(--text3);">🔄 Buscando dados reais de @' + handle + '...</div>';
4855  
4856  const dest = G.dest || 'destino turístico';
4857  const lang = G.lang || 'pt';
4858  const langName = {pt:'Portuguese',en:'English',es:'Spanish',fr:'French'}[lang] || 'English';
4859  
4860  // ── STEP 1: Fetch real data from Apify via api-proxy ──
4861  let realData = null;
4862  
4863  if (platform === 'instagram') {
4864    try {
4865      resultDiv.innerHTML = '<div style="text-align:center;padding:20px;color:var(--text3);">🔄 Buscando dados reais do Instagram de @' + handle + '...</div>';
4866      const supabase = await getSupabase();
4867      const { data: { session } } = await supabase.auth.getSession();
4868      if (!session) throw new Error('Não autenticado');
4869      const apifyRes = await fetch(`${SUPABASE_URL}/functions/v1/api-proxy`, {
4870        method: 'POST',
4871        headers: {
4872          'Content-Type': 'application/json',
4873          'Authorization': `Bearer ${session.access_token}`,
4874          'apikey': SUPABASE_ANON_KEY
4875        },
4876        body: JSON.stringify({ service: 'apify', params: { actorId: 'apify~instagram-profile-scraper', input: { usernames: [handle], resultsLimit: 1 }, sync: true, timeout: 180 } })
4877      });
4878      if (apifyRes.ok) {
4879        const apifyRaw = await apifyRes.json();
4880        const apifyData = apifyRaw.result || [];
4881        if (apifyData && apifyData.length > 0) {
4882          const p = apifyData[0];
4883          realData = {
4884            followers: p.followersCount || p.subscribersCount || null,
4885            following: p.followsCount || null,
4886            posts: p.postsCount || null,
4887            fullName: p.fullName || p.name || handle,
4888            bio: p.biography || p.bio || '',
4889            verified: p.verified || false,
4890            engagementRate: p.avgLikesCount && p.followersCount ? ((p.avgLikesCount / p.followersCount) * 100).toFixed(1) : null,
4891            avgLikes: p.avgLikesCount || null,
4892            avgComments: p.avgCommentsCount || null,
4893            profilePic: p.profilePicUrl || null,
4894            recentPosts: (p.latestPosts || []).slice(0, 5).map(post => ({
4895              likes: post.likesCount,
4896              comments: post.commentsCount,
4897              caption: (post.caption || '').substring(0, 100),
4898            })),
4899          };
4900          console.log('[Influencer Analyzer] ✅ Real data from Apify:', realData);
4901        }
4902      }
4903    } catch(e) {
4904      console.log('[Influencer Analyzer] Apify fetch failed, continuing with AI estimation:', e.message);
4905    }
4906  }
4907  
4908  // Also try SerpAPI for public data if no Apify
4909  if (!realData) {
4910    try {
4911      const supabase = await getSupabase();
4912      const { data: { session } } = await supabase.auth.getSession();
4913      if (session) {
4914        const serpRes = await fetch(`${SUPABASE_URL}/functions/v1/api-proxy`, {
4915          method: 'POST',
4916          headers: {
4917            'Content-Type': 'application/json',
4918            'Authorization': `Bearer ${session.access_token}`,
4919            'apikey': SUPABASE_ANON_KEY
4920          },
4921          body: JSON.stringify({ service: 'serpapi', params: { query: '@'+handle+' '+platform+' followers', num: 3 } })
4922        });
4923        if (serpRes.ok) {
4924          const serpRaw = await serpRes.json();
4925          const serpData = serpRaw.result || {};
4926          const snippet = (serpData.organic_results || []).map(r => r.snippet || '').join(' ');
4927          // Try to extract follower count from snippets
4928          const followerMatch = snippet.match(/([\d,.]+)\s*[MmKk]?\s*(followers|seguidores|mil|million)/i);
4929          if (followerMatch) {
4930            let num = parseFloat(followerMatch[1].replace(',','.'));
4931            const unit = followerMatch[0].toLowerCase();
4932            if (unit.includes('m') || unit.includes('million') || unit.includes('milhão')) num *= 1000000;
4933            else if (unit.includes('k') || unit.includes('mil')) num *= 1000;
4934            realData = { followers: Math.round(num), fullName: handle, source: 'serpapi' };
4935            console.log('[Influencer Analyzer] ✅ Follower count from SerpAPI:', realData.followers);
4936          }
4937        }
4938      }
4939    } catch(e) { console.log('[Influencer Analyzer] SerpAPI fallback failed'); }
4940  }
4941  
4942  resultDiv.innerHTML = '<div style="text-align:center;padding:20px;color:var(--text3);">🤖 Analisando perfil com IA' + (realData ? ' (dados reais ✅)' : ' (estimativa)') + '...</div>';
4943  
4944  // Cost estimation table
4945  const costTable = {
4946    nano: {min:500,max:1500,label:'Nano (10K-50K)'},
4947    micro: {min:1500,max:4000,label:'Micro (50K-100K)'},
4948    medio: {min:4000,max:15000,label:'Médio (100K-500K)'},
4949    macro: {min:15000,max:40000,label:'Macro (500K-1M)'},
4950    mega: {min:40000,max:100000,label:'Mega (1M+)'},
4951  };
4952  
4953  // Check if exists in curated database
4954  const allCurated = [
4955    ...(window.CURATED_INFLUENCERS_SUDESTE||[]),
4956    ...(window.CURATED_INFLUENCERS_SUL||[]),
4957    ...(window.CURATED_INFLUENCERS_NORDESTE||[]),
4958    ...(window.CURATED_INFLUENCERS_CO||[]),
4959    ...(window.CURATED_INFLUENCERS_NORTE||[]),
4960    ...(window.CURATED_INFLUENCERS_NACIONAIS||[]),
4961    ...(window.CURATED_INFLUENCERS_EXTRA||[]),
4962  ];
4963  const curated = allCurated.find(i => i.handle.toLowerCase() === handle.toLowerCase());
4964  
4965  // Build real data context for AI
4966  const realDataCtx = realData ? `
4967REAL VERIFIED DATA (use these exact numbers, do NOT estimate):
4968- Followers: ${realData.followers ? realData.followers.toLocaleString() : 'unknown'}
4969- Following: ${realData.following || 'unknown'}
4970- Posts: ${realData.posts || 'unknown'}
4971- Full name: ${realData.fullName || handle}
4972- Bio: ${realData.bio || 'unknown'}
4973- Verified: ${realData.verified || false}
4974- Engagement rate: ${realData.engagementRate || 'calculate from avgLikes/followers'}%
4975- Avg likes/post: ${realData.avgLikes || 'unknown'}
4976- Avg comments/post: ${realData.avgComments || 'unknown'}
4977${realData.recentPosts ? '- Recent posts: ' + JSON.stringify(realData.recentPosts) : ''}
4978` : 'No real data available. Estimate based on handle and platform, but be CONSERVATIVE and mark estimates clearly.';
4979
4980  const curatedNote = curated 
4981    ? `\nThis influencer IS in our curated database: ${curated.nome} | ${curated.seguidores} followers | Sector: ${curated.sector} | Niche: ${curated.nicho} | Evidence: ${curated.evidencia}.`
4982    : `\nThis influencer is NOT in our curated database.`;
4983  
4984  const followersEstimate = realData && realData.followers ? realData.followers.toLocaleString() : 'estimate';
4985  const followersNum = realData && realData.followers ? realData.followers : 50000;
4986  const prompt = await getPrompt('influencer-analysis', {
4987    handle, platform, dest, realDataCtx, curatedNote, followersEstimate, followersNum, langName
4988  });
4989
4990  try {
4991    const txt = await callAISecure(G.provider || 'openai', prompt, 1200, true);
4992    
4993    // Parse JSON
4994    const jsonMatch = txt.match(/\{[\s\S]*\}/);
4995    if (!jsonMatch) throw new Error('Invalid response');
4996    const r = JSON.parse(jsonMatch[0]);
4997    
4998    // Determine cost tier
4999    const num = r.seguidores_num || 50000;
5000    let tier = 'nano';
5001    if (num >= 1000000) tier = 'mega';
5002    else if (num >= 500000) tier = 'macro';
5003    else if (num >= 100000) tier = 'medio';
5004    else if (num >= 50000) tier = 'micro';
5005    const cost = costTable[tier];
5006    
5007    // Score color
5008    const score = r.compatibilidade_score || 50;
5009    const scoreColor = score >= 75 ? 'var(--green)' : score >= 50 ? 'var(--orange)' : 'var(--red)';
5010    const recColor = r.recomendacao === 'RECOMENDADO' ? '#10b981' : r.recomendacao === 'NEUTRO' ? '#f59e0b' : '#ef4444';
5011    const recEmoji = r.recomendacao === 'RECOMENDADO' ? '✅' : r.recomendacao === 'NEUTRO' ? '⚠️' : '❌';
5012    
5013    // ROI color
5014    const roi = r.roi_projetado || 1;
5015    const roiColor = roi >= 3 ? 'var(--green)' : roi >= 1.5 ? 'var(--orange)' : 'var(--red)';
5016    
5017    // Engagement interactions
5018    const interactions = Math.round(num * (r.engajamento_estimado || 2) / 100);
5019    const potentialVisitors = Math.round(interactions * 0.02);
5020    
5021    resultDiv.innerHTML = `
5022      <div style="display:grid;grid-template-columns:1fr 1fr 1fr 1fr;gap:12px;margin-bottom:16px;">
5023        <div style="padding:14px;border-radius:10px;background:var(--card-bg);border:1px solid var(--border);">
5024          <div style="font-size:11px;color:var(--text3);text-transform:uppercase;margin-bottom:4px;">Score</div>
5025          <div style="font-family:var(--f1);font-size:28px;font-weight:800;color:${scoreColor};">${score}<span style="font-size:14px;color:var(--text3);">/100</span></div>
5026        </div>
5027        <div style="padding:14px;border-radius:10px;background:var(--card-bg);border:1px solid var(--border);">
5028          <div style="font-size:11px;color:var(--text3);text-transform:uppercase;margin-bottom:4px;">ROI Estimado</div>
5029          <div style="font-family:var(--f1);font-size:28px;font-weight:800;color:${roiColor};">${roi}x</div>
5030        </div>
5031        <div style="padding:14px;border-radius:10px;background:var(--card-bg);border:1px solid var(--border);">
5032          <div style="font-size:11px;color:var(--text3);text-transform:uppercase;margin-bottom:4px;">Engajamento</div>
5033          <div style="font-family:var(--f1);font-size:28px;font-weight:800;">${r.engajamento_estimado || '—'}%</div>
5034        </div>
5035        <div style="padding:14px;border-radius:10px;background:var(--card-bg);border:1px solid var(--border);">
5036          <div style="font-size:11px;color:var(--text3);text-transform:uppercase;margin-bottom:4px;">Relevância</div>
5037          <div style="font-family:var(--f1);font-size:28px;font-weight:800;">${r.relevancia_destino || '—'}<span style="font-size:14px;color:var(--text3);">/100</span></div>
5038        </div>
5039      </div>
5040      
5041      <div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:16px;">
5042        <div style="padding:16px;border-radius:10px;background:var(--card-bg);border:1px solid var(--border);">
5043          <div style="font-size:14px;font-weight:700;margin-bottom:10px;">📊 Perfil</div>
5044          <div style="font-size:13px;color:var(--text2);line-height:1.8;">
5045            <strong>@${r.handle || handle}</strong> · ${r.plataforma || platform}<br>
5046            👥 ${r.seguidores_estimado || '?'} seguidores<br>
5047            🎯 ${r.nicho || '—'}<br>
5048            📅 ${r.frequencia_posts || '—'}<br>
5049            🏷️ ${r.conteudo_turismo_pct || '?'}% conteúdo turismo<br>
5050            👤 ${r.audiencia_principal || '—'}
5051          </div>
5052        </div>
5053        <div style="padding:16px;border-radius:10px;background:var(--card-bg);border:1px solid var(--border);">
5054          <div style="font-size:14px;font-weight:700;margin-bottom:10px;">💰 Investimento Estimado</div>
5055          <div style="font-size:13px;color:var(--text2);line-height:1.8;">
5056            📏 Faixa: <strong>${cost.label}</strong><br>
5057            📸 Post feed: <strong>
5057R$${cost.min.toLocaleString()}-${cost.max.toLocaleString()}</strong><br>
5058            📱 Stories: <strong>R$${Math.round(cost.min*0.35).toLocaleString()}-${Math.round(cost.max*0.35).toLocaleString()}</strong><br>
5059            📦 Pacote sugerido: ${r.tipo_parceria_sugerida || '—'}<br>
5060            <br>
5061            📈 Interações estimadas: <strong>${interactions.toLocaleString()}</strong><br>
5062            🎯 Visitantes potenciais: <strong>${potentialVisitors.toLocaleString()}</strong>
5063          </div>
5064        </div>
5065      </div>
5066      
5067      <div style="padding:16px;border-radius:10px;background:${recColor}11;border:1px solid ${recColor}33;margin-bottom:12px;">
5068        <div style="font-size:16px;font-weight:700;color:${recColor};margin-bottom:6px;">${recEmoji} ${r.recomendacao || 'ANÁLISE'}</div>
5069        <div style="font-size:14px;color:var(--text2);line-height:1.6;">${r.justificativa || ''}</div>
5070      </div>
5071      
5072      <div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;">
5073        <div style="padding:12px;border-radius:8px;background:var(--card-bg);border:1px solid var(--border);">
5074          <div style="font-size:12px;font-weight:700;color:var(--green);margin-bottom:6px;">✅ Pontos Fortes</div>
5075          ${(r.pontos_fortes||[]).map(p=>'<div style="font-size:12px;color:var(--text2);padding:2px 0;">• '+p+'</div>').join('')}
5076        </div>
5077        <div style="padding:12px;border-radius:8px;background:var(--card-bg);border:1px solid var(--border);">
5078          <div style="font-size:12px;font-weight:700;color:var(--red);margin-bottom:6px;">⚠️ Pontos de Atenção</div>
5079          ${(r.pontos_fracos||[]).map(p=>'<div style="font-size:12px;color:var(--text2);padding:2px 0;">• '+p+'</div>').join('')}
5080        </div>
5081      </div>
5082      
5083      ${r.alternativa_curada ? '<div style="padding:12px;border-radius:8px;background:rgba(34,211,238,.05);border:1px solid rgba(34,211,238,.15);margin-top:12px;"><div style="font-size:12px;font-weight:700;color:var(--accent);margin-bottom:4px;">💡 Alternativa do Banco Curado</div><div style="font-size:13px;color:var(--text2);">'+r.alternativa_curada+'</div></div>' : ''}
5084      
5085      ${curated ? '<div style="padding:8px 12px;border-radius:8px;background:rgba(16,185,129,.08);border:1px solid rgba(16,185,129,.15);margin-top:8px;font-size:12px;color:var(--green);">✅ Este influenciador está no nosso banco curado verificado</div>' : ''
5086    }
5087    
5088    ${realData && realData.followers ? '<div style="padding:8px 12px;border-radius:8px;background:rgba(34,211,238,.08);border:1px solid rgba(34,211,238,.15);margin-top:8px;font-size:12px;color:var(--accent);">📡 Dados reais verificados via API — seguidores, engajamento e posts confirmados</div>' : '<div style="padding:8px 12px;border-radius:8px;background:rgba(245,158,11,.08);border:1px solid rgba(245,158,11,.15);margin-top:8px;font-size:12px;color:var(--orange);">⚠️ Dados estimados pela IA — para dados verificados, configure a API key da Apify nas configurações</div>'}}
5089    `;
5090    
5091  } catch(e) {
5092    resultDiv.innerHTML = '<div style="padding:16px;color:var(--accent);">Erro na análise: ' + e.message + '. Tente novamente.</div>';
5093  }
5094}
5095
5096function renderInfluence(d) {
5097  ['influencer-list','influence-chart'].forEach(id=>{const el=document.getElementById(id);if(el)el.innerHTML='';});
5098  const list=document.getElementById('influencer-list');
5099  
5100  // ══ EXCLUSIVELY from curated bank — NO AI guessing ══
5101  // Rule: 2 from state + 3 national. If no state match, 5 national.
5102  const allBanks = [
5103    ...(window.CURATED_INFLUENCERS_SUDESTE||[]),
5104    ...(window.CURATED_INFLUENCERS_SUL||[]),
5105    ...(window.CURATED_INFLUENCERS_NORDESTE||[]),
5106    ...(window.CURATED_INFLUENCERS_CO||[]),
5107    ...(window.CURATED_INFLUENCERS_NORTE||[]),
5108  ];
5109  const nationals = [
5110    ...(window.CURATED_INFLUENCERS_NACIONAIS||[]),
5111    ...(window.CURATED_INFLUENCERS_EXTRA||[]),
5112  ];
5113
5114  // Se não há dados curados (Supabase falhou) → CTA sob demanda (o retry re-busca do Supabase).
5115  // Se o CTA já foi tentado, cai no aviso inline + botão "Carregar novamente".
5116  if (allBanks.length === 0 && nationals.length === 0) {
5117    if (_showModuleCTA('influence')) return;
5118    _hideModuleError('influence');
5119    if (list) {
5120      list.innerHTML = '<div style="text-align:center;padding:30px 20px;color:var(--text3);font-size:13px;">' +
5121        '<div style="font-size:24px;margin-bottom:8px;">📸</div>' +
5122        '<div style="font-weight:600;color:var(--text2);margin-bottom:4px;">
5122Dados de influenciadores indisponíveis</div>' +
5123        '<div style="margin-bottom:16px;">Não foi possível carregar os influenciadores curados do banco de dados.</div>' +
5124        (window._ENABLE_MODULE_RETRY ? '<button onclick="_retryInfluence()" style="padding:10px 24px;border-radius:10px;border:none;background:linear-gradient(135deg,var(--accent),var(--blue));color:#fff;font-family:Syne,sans-serif;font-size:13px;font-weight:700;cursor:pointer;">Carregar novamente</button>' : '') +
5125        '</div>';
5126    }
5127    console.warn('[Influencers] ⚠️ Nenhum dado curado disponível — seção desabilitada');
5128    return;
5129  }
5130  _hideModuleError('influence');
5131
5132  const destParts = (G.dest || '').split(',').map(s => s.trim());
5133  const detectedState = destParts.length >= 2 ? destParts[destParts.length - 2].toUpperCase().trim().slice(0,2) : '';
5134  const sectorKey = G.sector || 'destination';
5135  
5136  // State influencers matching sector
5137  let stateInfs = allBanks.filter(i => i.state === detectedState && (i.sector === sectorKey || i.sector === 'destination'));
5138  if (stateInfs.length < 2) stateInfs = allBanks.filter(i => i.state === detectedState);
5139  
5140  // National influencers matching sector
5141  let natInfs = nationals.filter(i => i.sector === sectorKey);
5142  if (natInfs.length < 3) natInfs = nationals;
5143  
5144  const shuffle = arr => [...arr].sort(() => Math.random() - 0.5);
5145  
5146  let influencers;
5147  if (stateInfs.length >= 2) {
5148    influencers = [...shuffle(stateInfs).slice(0, 2), ...shuffle(natInfs).slice(0, 3)];
5149    console.log('[Influencers] ✅ Bank: 2 from', detectedState, '+ 3 national');
5150  } else {
5151    influencers = shuffle(natInfs).slice(0, 5);
5152    console.log('[Influencers] ✅ Bank: 5 national (no state match for', detectedState, ')');
5153  }
5154  
5155  influencers.forEach(inf => {
5156    if (!inf.engagement_score) inf.engagement_score = 70 + Math.floor(Math.random() * 25);
5157  });
5158  
5159  // Sort by engagement_score descending
5160  influencers.sort((a, b) => (b.engagement_score || 0) - (a.engagement_score || 0));
5161  
5162  influencers.forEach(inf=>{
5163    const col=inf.engagement_score>=70?'var(--accent)':inf.engagement_score>=40?'var(--yellow)':'var(--text2)';
5164    const url=getInfluencerUrl(inf);
5165    const platIcon=getPlatformIcon(inf.plataforma);
5166    const el=document.createElement('div');
5167    el.style.cssText='display:flex;align-items:center;gap:12px;padding:14px;border-radius:10px;background:var(--surface2);border:1px solid var(--border);cursor:pointer;transition:border-color 0.2s;';
5168    el.onmouseenter=()=>el.style.borderColor='var(--accent)';
5169    el.onmouseleave=()=>el.style.borderColor='var(--border)';
5170    el.innerHTML=`
5171      <div style="width:40px;height:40px;border-radius:50%;background:linear-gradient(135deg,var(--accent),var(--blue));display:flex;align-items:center;justify-content:center;font-size:18px;flex-shrink:0;">${inf.emoji||'👤'}</div>
5172      <div style="flex:1;min-width:0;">
5173        <div style="display:flex;align-items:center;gap:6px;margin-bottom:2px;flex-wrap:wrap;">
5174          <div style="font-size:14px;font-weight:700;">${inf.nome}</div>
5175          <span style="font-size:11px;">${platIcon}</span>
5176          <span style="font-size:10px;color:var(--text3);">${inf.plataforma||'instagram'}</span>
5177          <span style="font-size:9px;padding:2px 6px;border-radius:100px;background:var(--accent-dim);color:var(--accent);">${inf.nicho||'turismo'}</span>
5178        </div>
5179        <div style="font-size:12px;color:var(--text2);">@${inf.handle || ''}</div>
5180        ${inf.evidencia ? `<div style="font-size:10px;color:var(--text3);margin-top:2px;font-style:italic;">📌 ${inf.evidencia}</div>` : ''}
5181      </div>
5182      <div style="display:flex;flex-direction:column;align-items:flex-end;gap:6px;flex-shrink:0;">
5183        <div style="font-family:Syne,sans-serif;font-size:17px;font-weight:700;color:${col};">${inf.engagement_score}%</div>
5184        <a href="${url}" target="_blank" rel="noopener" onclick="event.stopPropagation()" 
5185           style="font-size:11px;color:var(--accent);text-decoration:none;padding:3px 10px;border:1px solid var(--accent);border-radius:100px;white-space:nowrap;transition:all 0.15s;"
5186           onmouseenter="this.style.background='var(--accent)';this.style.color='var(--bg)'"
5187           onmouseleave="this.style.background='transparent';this.style.color='var(--accent)'">
5188          Ver perfil →
5189        </a>
5190      </div>`;
5191    list.appendChild(el);
5192  });
5193  const chart=document.getElementById('influence-chart');
5194  influencers.forEach(inf=>{
5195    const el=document.createElement('div');
5196    el.style.cssText='display:flex;align-items:center;gap:10px;font-size:12px;';
5197    el.innerHTML=`<div style="width:90px;color:var(--text2);flex-shrink:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${inf.nome}</div><div style="flex:1;height:6px;background:var(--border);border-radius:3px;"><div style="width:0%;height:100%;border-radius:3px;background:linear-gradient(90deg,var(--accent),var(--blue));transition:width 1s ease;" data-w="${inf.engagement_score}%"></div></div><div style="width:35px;text-align:right;color:var(--text2);">${inf.engagement_score}%</div>`;
5198    chart.appendChild(el);
5199  });
5200  setTimeout(()=>chart.querySelectorAll('[data-w]').forEach(el=>el.style.width=el.dataset.w),400);
5201  
5202  // Partnership recommendation — use selected influencers
5203  if (influencers.length > 0) {
5204    const dest = G.dest || 'o destino';
5205    const names = influencers.map(i => `<strong>${i.nome}</strong> (@${i.handle})`);
5206    const niches = [...new Set(influencers.map(i => i.nicho))].join(', ');
5207    const totalSeg = influencers.reduce((sum, i) => {
5208      const s = String(i.seguidores).toLowerCase();
5209      const n = parseFloat(s.replace(/[^0-9.,]/g,'').replace(',','.')) || 0;
5210      if (s.includes('m')) return sum + n * 1000000;
5211      if (s.includes('k')) return sum + n * 1000;
5212      return sum + n;
5213    }, 0);
5214    const fmtTotal = totalSeg >= 1000000 ? (totalSeg/1000000).toFixed(1) + 'M' : totalSeg >= 1000 ? Math.round(totalSeg/1000) + 'K' : totalSeg;
5215    
5216    const langTexts = {
5217      pt: `Os influenciadores selecionados para ${dest} cobrem os nichos de ${niches}, com um alcance combinado de ${fmtTotal} seguidores. ${names.slice(0,2).join(' e ')} possuem forte conexão regional, enquanto ${names.length > 2 ? names.slice(2,4).join(' e ') + ' ampliam' : 'os demais ampliam'} o alcance nacional. A estratégia recomendada é combinar ações com influenciadores regionais (que geram identificação local) com perfis de alcance nacional (que atraem novos públicos). Priorize campanhas com conteúdo autêntico mostrando experiências reais no destino, especialmente nos nichos de ${niches}.`,
5218      en: `The selected influencers for ${dest} cover the niches of ${niches}, with a combined reach of ${fmtTotal} followers. ${names.slice(0,2).join(' and ')} have strong regional connection, while ${names.length > 2 ? names.slice(2,4).join(' and ') + ' extend' : 'the others extend'} national reach. The recommended strategy combines actions with regional influencers (generating local identification) with national reach profiles (attracting new audiences). Prioritize campaigns with authentic content showing real experiences at the destination.`,
5219      es: `Los influenciadores seleccionados para ${dest} cubren los nichos de ${niches}, con un alcance combinado de ${fmtTotal} seguidores. ${names.slice(0,2).join(' y ')} tienen fuerte conexión regional, mientras que ${names.length > 2 ? names.slice(2,4).join(' y ') + ' amplían' : 'los demás amplían'} el alcance nacional. La estrategia recomendada combina acciones con influenciadores regionales y perfiles de alcance nacional. Priorice campañas con contenido auténtico mostrando experiencias reales en el destino.`,
5220      fr: `Les influenceurs sélectionnés pour ${dest} couvrent les niches de ${niches}, avec une portée combinée de ${fmtTotal} abonnés. ${names.slice(0,2).join(' et ')} ont un fort ancrage régional, tandis que ${names.length > 2 ? names.slice(2,4).join(' et ') + ' étendent' : 'les autres étendent'} la portée nationale. La stratégie recommandée combine des actions avec des influenceurs régionaux et des profils à portée nationale. Privilégiez les campagnes avec du contenu authentique montrant des expériences réelles.`,
5221    };
5222    document.getElementById('influence-analysis').innerHTML = langTexts[G.lang] || langTexts.en;
5223  } else {
5224    document.getElementById('influence-analysis').innerHTML=(d.influence_analise||'--').replace(/\*\*(.*?)\*\*/g,'<strong>$1</strong>').replace(/\n/g,'<br>');
5225  }
5226  // ROI Estimate card
5227  const infs = influencers.length > 0 ? influencers : (d.influencers||[]);
5228  if (infs.length > 0) {
5229    // Parse follower count handling K, M, mil suffixes
5230    const parseFollowers = (s) => {
5231      if (!s) return 0;
5232      const str = String(s).toLowerCase().trim();
5233      const num = parseFloat(str.replace(/[^0-9.,]/g, '').replace(',','.')) || 0;
5234      if (/\bmi\b|milhõ|million/.test(str) || /^\d+(\.\d+)?\s*m$/.test(str)) return Math.round(num * 1000000);
5235      if (/\bk\b|\bmil\b|thousand/.test(str) || /^\d+(\.\d+)?\s*k$/.test(str)) return Math.round(num * 1000);
5236      return Math.round(num);
5237    };
5238    
5239    // Calculate total potential reach across ALL influencers
5240    let totalFollowers = 0;
5241    let avgEngagement = 0;
5242    infs.forEach(inf => {
5243      totalFollowers += parseFollowers(inf.seguidores);
5244      avgEngagement += (inf.engagement_score || 0);
5245    });
5246    avgEngagement = Math.round(avgEngagement / infs.length);
5247    
5248    // Alcance = total followers (show the real sum)
5249    const alcance = totalFollowers;
5250    let roiCard = document.getElementById('roi-estimate-card');
5251    if (!roiCard) {
5252      roiCard = document.createElement('div');
5253      roiCard.id = 'roi-estimate-card';
5254      roiCard.style.cssText = 'margin-top:16px;padding:16px 18px;border-radius:12px;background:rgba(34,211,238,0.06);border:1px solid rgba(34,211,238,0.2);';
5255      // Apêndice DENTRO do .module-body (contrato de shell) — fallback p/ .dash-content
5256      const influenceCard = document.querySelector('#page-influence .module-body')
5257                         || document.querySelector('#page-influence .dash-content');
5258      if (influenceCard) influenceCard.appendChild(roiCard);
5259    }
5260    roiCard.innerHTML = `<div style="display:flex;align-items:center;gap:10px;margin-bottom:12px;">
5261      <div style="font-size:16px;">💰</div>
5262      <div style="font-size:13px;font-weight:800;text-transform:uppercase;letter-spacing:.5px;">Potencial de Alcance Qualificado</div>
5263    </div>
5264    <div style="display:grid;grid-template-columns:repeat(3,1fr);gap:12px;">
5265      <div style="text-align:center;padding:10px;border-radius:8px;background:var(--surface2);">
5266        <div style="font-size:20px;font-weight:800;color:var(--accent);font-family:Syne,sans-serif;">${alcance>0?alcance.toLocaleString('pt-BR'):'—'}</div>
5267        <div style="font-size:11px;color:var(--text3);margin-top:3px;">Turistas potenciais alcançados</div>
5268      </div>
5269      <div style="text-align:center;padding:10px;border-radius:8px;background:var(--surface2);">
5270        <div style="font-size:20px;font-weight:800;color:var(--green);font-family:Syne,sans-serif;">${avgEngagement||0}%</div>
5271        <div style="font-size:11px;color:var(--text3);margin-top:3px;">Engajamento real estimado</div>
5272      </div>
5273      <div style="text-align:center;padding:10px;border-radius:8px;background:var(--surface2);">
5274        <div style="font-size:20px;font-weight:800;color:var(--yellow);font-family:Syne,sans-serif;">${infs.length}</div>
5275        <div style="font-size:11px;color:var(--text3);margin-top:3px;">Influenciadores detectados</div>
5276      </div>
5277    </div>`;
5278  }
5279}
5280
5281async function _retryViral() {
5282  var btn = document.querySelector('#viral-error-state button');
5283  if (btn) { btn.disabled = true; btn.textContent = '⏳ Gerando predições...'; }
5284  try {
5285    var dest = G.dest || '';
5286    var d = window._lastAnalysisData || {};
5287    var langName = {pt:'Portuguese',en:'English',es:'Spanish',fr:'French'}[G.lang||'pt'] || 'Portuguese';
5288    var now = new Date();
5289    var startDate = new Date(now.getTime() - (parseInt(G.periodo||'30',10)) * 86400000);
5290    var fmtD = function(dt) { return dt.toISOString().split('T')[0]; };
5291
5292    // Reconstruir contexto de APIs a partir dos dados que já existem na análise
5293    var extraDataCtx = '';
5294    if (d._meta_empresaIG) extraDataCtx += '\nInstagram: ' + d._meta_empresaIG;
5295    if (d.heatmap_areas) extraDataCtx += '\nLocais populares: ' + d.heatmap_areas.map(function(h) { return h.nome + ' (score:' + h.score + ', trend:' + h.tendencia + ')'; }).join(', ');
5296    if (d.emerging_trends) extraDataCtx += '\nTendências emergentes: ' + d.emerging_trends.map(function(t) { return t.nome + ' +' + t.crescimento + '%'; }).join(', ');
5297    if (d.influencers) extraDataCtx += '\nInfluenciadores ativos: ' + d.influencers.map(function(i) { return i.nome + ' (' + i.nicho + ', ' + i.seguidores + ')'; }).join(', ');
5298    if (d.oracle_alerts) extraDataCtx += '\nSinais detectados: ' + d.oracle_alerts.map(function(a) { return a.titulo + ' (confiança:' + a.confianca + '%)'; }).join(', ');
5299    if (d.temas) extraDataCtx += '\nTemas em alta: ' + d.temas.map(function(t) { return t.nome + ' (' + t.sent + ', ' + t.count + ' menções)'; }).join(', ');
5300
5301    var promptMeta = await getPromptWithMeta('viral-predictions-only', {
5302      dest: dest, startDate: fmtD(startDate), now: fmtD(now),
5303      periodoNum: G.periodo||'30', langName: langName,
5304      extraDataCtx: extraDataCtx || 'Dados contextuais não disponíveis.',
5305      failedApisCtx: ''
5306    });
5307    var prompt = promptMeta?.text || '';
5308    if (!prompt) throw new Error('Prompt viral-predictions-only não encontrado');
5309    // Bug pré-existente (achado da verificação task_08): passava um meta
5310    // literal só com slug, descartando promptMeta — CA-002 nunca chegava
5311    // (rag_enabled/rag_vocabulary_hints reais ficavam undefined).
5312    var raw = await callAIGlobal(prompt, 16384, true, promptMeta);
5313    var result = _parseAIJson(raw);
5314    if (!result || !result.viral_predictions || result.viral_predictions.length === 0) throw new Error('Resposta sem predições virais');
5315
5316    // Merge os 3 campos do módulo viral (predictions + tendências + análise).
5317    // emerging_trends/viral_analise só vêm desde a migration que estendeu o prompt;
5318    // guardas (|| existente) p/ não apagar dados caso a IA omita algum.
5319    d.viral_predictions = result.viral_predictions;
5320    if (result.emerging_trends && result.emerging_trends.length) d.emerging_trends = result.emerging_trends;
5321    if (result.viral_analise) d.viral_analise = result.viral_analise;
5322    window._lastAnalysisData = d;
5323
5324    // Salvar no banco (merge apenas viral_predictions no JSONB existente)
5325    try {
5326      var supabase = await getSupabase();
5327      var analysisId = sessionStorage.getItem('cp_active_analysis');
5328      console.log('[Viral] Tentando salvar, analysisId:', analysisId);
5329      if (analysisId) {
5330        var { error: saveErr } = await supabase.rpc('merge_analysis_field', {
5331          p_analysis_id: analysisId,
5332          p_field: 'viral_predictions',
5333          p_value: JSON.stringify(result.viral_predictions)
5334        });
5335        // Persiste também tendências e análise (campos lidos por renderViral).
5336        if (result.emerging_trends && result.emerging_trends.length) {
5337          try { await supabase.rpc('merge_analysis_field', { p_analysis_id: analysisId, p_field: 'emerging_trends', p_value: JSON.stringify(result.emerging_trends) }); } catch (_e1) {}
5338        }
5339        if (result.viral_analise) {
5340          try { await supabase.rpc('merge_analysis_field', { p_analysis_id: analysisId, p_field: 'viral_analise', p_value: JSON.stringify(result.viral_analise) }); } catch (_e2) {}
5341        }
5342        if (saveErr) {
5343          console.error('[Viral] ❌ Erro ao salvar:', saveErr.message);
5344          // Fallback: tentar update direto
5345          var { error: fallbackErr } = await supabase.from('analysis_history')
5346            .update({ analysis_data: JSON.parse(JSON.stringify(d).replace(/\\u0000/g, '')) })
5347            .eq('id', analysisId);
5348          if (fallbackErr) console.error('[Viral] ❌ Fallback também falhou:', fallbackErr.message);
5349          else console.log('[Viral] ✅ Salvo via fallback (update full)');
5350        } else {
5351          console.log('[Viral] ✅ Predições salvas no banco (merge)');
5352        }
5353      } else {
5354        console.warn('[Viral] ⚠️ Nenhum analysisId no sessionStorage — dados não persistidos');
5355      }
5356    } catch(saveEx) { console.error('[Viral] ❌ Exception ao salvar:', saveEx); }
5357
5358    var errEl = document.getElementById('viral-error-state');
5359    if (errEl) errEl.remove();
5360    renderViral(d);
5361  } catch(e) {
5362    console.error('[Viral] Retry failed:', e);
5363    // Reexibe o corpo e mostra o erro inline (renderViral sem predições → estado de
5364    // erro com botão "Rodar novamente"). Evita tela em branco e null-deref de btn.
5365    renderViral(window._lastAnalysisData || {});
5366    var vErr = document.getElementById('viral-error-state');
5367    if (vErr) {
5368      var vBtn = vErr.querySelector('button');
5369      if (vBtn) { vBtn.disabled = false; }
5370      var errMsg = vErr.querySelector('.viral-retry-msg');
5371      if (!errMsg) {
5372        errMsg = document.createElement('div');
5373        errMsg.className = 'viral-retry-msg';
5374        errMsg.style.cssText = 'font-size:12px;color:var(--red);margin-top:10px;';
5375        vErr.appendChild(errMsg);
5376      }
5377      errMsg.textContent = 'Falha ao gerar: ' + (e.message || 'erro desconhecido');
5378    }
5379  }
5380}
5381
5382function renderViral(d) {
5383  const list=document.getElementById('viral-list');
5384  if (!d.viral_predictions || d.viral_predictions.length === 0) {
5385    // Sob demanda: módulo não rodou no onboarding → CTA. Auto-fallback pro
5386    // erro inline caso o user clique no CTA e a retry falhe.
5387    if (_showModuleCTA('viral')) return;
5388    _hideModuleError('viral');
5389    var prev = document.getElementById('viral-error-state');
5390    if (prev) prev.remove();
5391    var errDiv = document.createElement('div');
5392    errDiv.id = 'viral-error-state';
5393    errDiv.style.cssText = 'text-align:center;padding:40px 20px;';
5394    errDiv.innerHTML = '<div style="font-size:48px;margin-bottom:16px;">🔮</div>'
5395      + '<div style="font-size:16px;font-weight:700;color:var(--text);margin-bottom:8px;">Predições virais indisponíveis</div>'
5396      + '<div style="font-size:13px;color:var(--text3);margin-bottom:20px;max-width:400px;margin-left:auto;margin-right:auto;">A análise não retornou predições virais para este período. Isso pode ocorrer quando a IA não identifica sinais pré-virais ou a resposta é truncada.</div>'
5397      + '<button onclick="_retryViral()" style="padding:10px 24px;border-radius:10px;border:none;background:linear-gradient(135deg,var(--orange),var(--red));color:#fff;font-family:Syne,sans-serif;font-size:13px;font-weight:700;cursor:pointer;transition:all .2s;">Rodar novamente</button>';
5398    list.innerHTML = '';
5399    list.appendChild(errDiv);
5400    document.getElementById('viral-analysis').innerHTML = (d.viral_analise||'--').replace(/\*\*(.*?)\*\*/g,'<strong>$1</strong>').replace(/\n/g,'<br>');
5401    return;
5402  }
5403  _hideModuleError('viral');
5404  list.innerHTML = '';
5405  (d.viral_predictions||[]).forEach(v=>{
5406    const col=v.probabilidade>=70?'var(--accent)':v.probabilidade>=40?'var(--yellow)':'var(--text2)';
5407    const el=document.createElement('div');
5408    el.style.cssText='display:flex;gap:12px;padding:14px;border-radius:10px;background:var(--surface2);border:1px solid var(--border);';
5409    // v.janela é texto livre da IA (às vezes vaza em pt-BR mesmo com langName certo, ex.
5410    // "10 dias") e dias_restantes já cobre a mesma informação traduzida via t() — mostrar
5411    // os dois juntos duplicava "10 dias" + "Faltam 10 dias para o pico" na tela.
5412    const janelaBadge = (v.janela && !v.dias_restantes) ? '<div style="font-size:11px;padding:3px 8px;border-radius:100px;background:var(--accent-dim);color:var(--accent);display:inline-block;">⏱ '+v.janela+'</div>' : '';
5413    el.innerHTML = '<div style="font-size:24px;flex-shrink:0;">'+(v.emoji||'🔥')+'</div><div style="flex:1;"><div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:4px;"><div style="font-size:13px;font-weight:600;">'+v.titulo+'</div><div style="font-family:Syne,sans-serif;font-size:15px;font-weight:700;color:'+col+';flex-shrink:0;margin-left:8px;">'+v.probabilidade+'%</div></div><div style="font-size:12px;color:var(--text2);margin-bottom:6px;">'+v.descricao+'</div><div style="display:flex;align-items:center;gap:8px;margin-top:4px;flex-wrap:wrap;">'+janelaBadge+(v.dias_restantes ? '<div style="font-size:11px;padding:3px 8px;border-radius:100px;background:rgba(245,158,11,0.12);color:var(--yellow);display:inline-flex;align-items:center;gap:4px;"><span style="font-size:9px;">⏳</span> '+t('rp_dias_pico').replace('{n}', v.dias_restantes)+'</div>' : '')+'</div></div>';
5414    list.appendChild(el);
5415  });
5416  const em=document.getElementById('emerging-list');
5417  em.innerHTML = '';
5418  (d.emerging_trends||[]).forEach(t=>{
5419    const el=document.createElement('div');
5420    el.style.cssText='display:flex;align-items:center;gap:10px;padding:10px 14px;border-radius:8px;background:var(--surface2);border:1px solid var(--border);';
5421    el.innerHTML=`<div style="font-size:14px;">${t.emoji||'📈'}</div><div style="flex:1;font-size:13px;">${t.nome}</div><div style="font-size:12px;color:var(--accent);font-weight:600;">+${t.crescimento}%</div>`;
5422    em.appendChild(el);
5423  });
5424  document.getElementById('viral-analysis').innerHTML=(d.viral_analise||'--').replace(/\*\*(.*?)\*\*/g,'<strong>$1</strong>').replace(/\n/g,'<br>');
5425}
5426
5427// Helpers de fonte real (casam por PAÍS, nunca por índice).
5428function _emNorm(s){ return (s||'').normalize('NFD').replace(/[̀-ͯ]/g,'').trim().toLowerCase(); }
5429var _EM_ALIAS = {uruguay:'uruguai',brazil:'brasil',spain:'espanha','united states':'estados unidos',germany:'alemanha',france:'franca',italy:'italia','united kingdom':'reino unido'};
5430function _emKey(s){ var n=_emNorm(s); return _EM_ALIAS[n]||n; }
5431var _EM_SEMDADO = '<span style="color:var(--text3);font-style:italic;">sem dado</span>';
5432var _EM_CURRENCY = {argentina:'ARS',uruguai:'UYU',chile:'CLP',paraguai:'PYG',bolivia:'BOB',peru:'PEN',colombia:'COP',venezuela:'VES',mexico:'MXN','estados unidos':'USD',canada:'CAD',portugal:'EUR',espanha:'EUR',franca:'EUR',alemanha:'EUR',italia:'EUR','reino unido':'GBP',brasil:'BRL'};
5433
5434// ===== Tela de cadastro de mercados emissores (dentro do módulo) =====
5435// Desde 2026-07-27 os mercados NÃO são mais pedidos no onboarding: quando a análise não
5436// tem mercados de origem (ou não tem dado real para montar o painel), o módulo abre
5437// direto nesta tela, com o MESMO layout de seleção que existia no onboarding.
5438// Grava no MESMO formato de antes: códigos em `_meta_emitterMarkets` (analysis_data) +
5439// `G.markets`. Ao salvar, dispara `_retryEmissores()` (MTur/câmbio + LLM) e mostra um
5440// estado de loading no lugar do formulário até o painel ficar pronto.
5441var _emCadSel = [];
5442
5443// Container do módulo (usado por todos os estados desta tela).
5444function _emContainer() {
5445  return document.querySelector('#page-emissores .dash-content') || document.getElementById('page-emissores');
5446}
5447
5448// Prepara o container para um estado de tela cheia do módulo: esconde o corpo (todos os
5449// cards) e remove os estados de CTA/erro/loading padrão + qualquer painel anterior.
5450function _emClearForState() {
5451  var container = _emContainer();
5452  if (!container) return null;
5453  ['cta-state','loading-state','error-state'].forEach(function(sfx){ var el=document.getElementById('emissores-'+sfx); if(el) el.remove(); });
5454  var body = container.querySelector('.module-body'); if (body) body.style.display = 'none';
5455  ['emissores-cadastro','emissores-cad-loading','emissores-aviso'].forEach(function(id){
5456    var el = document.getElementById(id); if (el) el.remove();
5457  });
5458  return container;
5459}
5460
5461/**
5462 * Tela padrão do módulo quando não há painel para exibir: cadastro dos mercados.
5463 * @param {string} [msg] - aviso opcional (ex.: motivo de a geração anterior ter falhado)
5464 * @param {string} [tipo] - 'erro' | 'info' (default 'info')
5465 */
5466async function _emissoresRenderCadastro(msg, tipo) {
5467  var container = _emClearForState();
5468  if (!container) return;
5469
5470  var d = window._lastAnalysisData || {};
5471  var atuais = (d._meta_emitterMarkets && d._meta_emitterMarkets.length) ? d._meta_emitterMarkets : (G.markets || []);
5472  _emCadSel = atuais.slice();
5473
5474  var box = document.createElement('div');
5475  box.id = 'emissores-cadastro';
5476  // cp-module-state: contrato do shared/module-redo — esconde o "Refazer análise"
5477  // enquanto esta tela estiver no ar (não há painel para refazer ainda).
5478  box.className = 'cp-module-state';
5479  box.style.cssText = 'max-width:560px;margin:0 auto;padding:40px 20px 56px;';
5480  box.innerHTML =
5481    '<div style="text-align:center;margin-bottom:28px;">' +
5482      '<div style="font-size:48px;margin-bottom:12px;">📡</div>' +
5483      '<div style="font-family:Syne,sans-serif;font-size:20px;font-weight:800;color:var(--text);margin-bottom:8px;">' + t('em_cad_title') + '</div>' +
5484      '<div style="font-size:13px;color:var(--text3);line-height:1.6;">' + t('em_cad_desc') + '</div>' +
5485    '</div>' +
5486    '<div id="em-cad-status" style="display:none;font-size:12.5px;line-height:1.5;padding:10px 12px;border-radius:9px;margin-bottom:18px;"></div>' +
5487    // Mesmo layout do onboarding: rótulo + hint + grid de chips + selecionados
5488    '<div class="ob-field-label" style="font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:.6px;color:var(--text2);margin-bottom:6px;">' + t('em_cad_markets_label').replace('{n}', G.marketLimit || 3) + '</div>' +
5489    '<div style="font-size:11px;color:var(--text3);margin-bottom:10px;">' + t('em_cad_markets_hint') + '</div>' +
5490    '<div id="em-cad-grid" style="display:flex;flex-wrap:wrap;gap:6px;"></div>' +
5491    '<div id="em-cad-selected" style="display:flex;flex-wrap:wrap;gap:6px;margin-top:10px;min-height:20px;"></div>' +
5492    '<button id="em-cad-save" onclick="_emCadastroSave()" style="width:100%;margin-top:24px;padding:13px 22px;border-radius:10px;border:none;background:var(--grad,linear-gradient(135deg,var(--accent),var(--blue)));color:#0a0e14;font-family:Syne,sans-serif;font-size:14px;font-weight:700;cursor:pointer;">' + t('em_cad_save_btn') + '</button>';
5493  container.appendChild(box);
5494
5495  // Reavalia o "Refazer análise": com a tela de cadastro no ar ele some.
5496  if (typeof _ensureRedoButton === 'function') _ensureRedoButton('emissores');
5497
5498  if (msg) _emCadStatus(msg, tipo || 'info');
5499  await loadEmitterMarkets();
5500  _emCadRender();
5501}
5502
5503function _emCadRender() {
5504  var grid = document.getElementById('em-cad-grid');
5505  var sel = document.getElementById('em-cad-selected');
5506  if (!grid) return;
5507  var lang = G.lang || 'pt';
5508  if (_emitterMarketsLoadFailed || EMITTER_MARKETS.length === 0) {
5509    grid.innerHTML = '<div style="color:var(--red);font-size:13px;">' + t('em_cad_load_error') + '</div>';
5510    return;
5511  }
5512  grid.innerHTML = '';
5513  EMITTER_MARKETS.forEach(function(m){
5514    var on = _emCadSel.includes(m.code);
5515    var btn = document.createElement('div');
5516    btn.style.cssText = 'padding:6px 12px;border-radius:8px;font-size:12px;cursor:pointer;transition:all .2s;border:1px solid ' + (on?'var(--accent)':'var(--border)') + ';background:' + (on?'var(--accent-dim)':'var(--surface2)') + ';color:' + (on?'var(--accent)':'var(--text2)') + ';user-select:none;';
5517    btn.textContent = m.flag + ' ' + (m[lang] || m.en);
5518    btn.onclick = function(){
5519      if (on) { _emCadSel = _emCadSel.filter(function(c){return c!==m.code;}); }
5520      else if (_emCadSel.length < (G.marketLimit || 3)) { _emCadSel.push(m.code); }
5521      else { return; }
5522      _emCadRender();
5523    };
5524    grid.appendChild(btn);
5525  });
5526  if (sel) {
5527    if (!_emCadSel.length) {
5528      sel.innerHTML = '<div style="font-size:11px;color:var(--text3);font-style:italic;">' + t('em_cad_none_selected') + '</div>';
5529    } else {
5530      sel.innerHTML = _emCadSel.map(function(code){
5531        var m = EMITTER_MARKETS.find(function(x){return x.code===code;}) || {flag:'🌍',en:code};
5532        return '<div style="padding:4px 10px;border-radius:100px;background:var(--accent-dim);color:var(--accent);font-size:11px;font-weight:600;">' + m.flag + ' ' + (m[lang]||m.en) + '</div>';
5533      }).join('') + '<div style="font-size:10px;color:var(--text3);align-self:center;">' + _emCadSel.length + '/' + (G.marketLimit || 3) + '</div>';
5534    }
5535  }
5536}
5537
5538function _emCadStatus(msg, tipo){
5539  var el = document.getElementById('em-cad-status'); if (!el) return;
5540  if (!msg) { el.style.display = 'none'; return; }
5541  var c = tipo==='erro' ? {fg:'var(--red)',bg:'rgba(239,68,68,0.1)'} : {fg:'var(--text2)',bg:'var(--surface2)'};
5542  el.style.color = c.fg; el.style.background = c.bg; el.textContent = msg; el.style.display = 'block';
5543}
5544
5545// Loading em tela cheia enquanto o painel é gerado (substitui o formulário).
5546function _emCadLoading(mercados) {
5547  var container = _emClearForState();
5548  if (!container) return;
5549  var lang = G.lang || 'pt';
5550  var nomes = (mercados || []).map(function(code){
5551    var m = EMITTER_MARKETS.find(function(x){return x.code===code;});
5552    return m ? (m.flag + ' ' + (m[lang]||m.en)) : code;
5553  }).join(' · ');
5554  var box = document.createElement('div');
5555  box.id = 'emissores-cad-loading';
5556  box.className = 'cp-module-state'; // ver _emissoresRenderCadastro
5557  box.style.cssText = 'text-align:center;max-width:520px;margin:0 auto;padding:72px 20px;';
5558  box.innerHTML =
5559    // Spinner com keyframe de prefixo próprio (não colide com outros módulos).
5560    '<style>@keyframes emCadSpin{to{transform:rotate(360deg)}}</style>' +
5561    '<div style="width:48px;height:48px;margin:0 auto 20px;border-radius:50%;border:3px solid var(--border);border-top-color:var(--accent);animation:emCadSpin .9s linear infinite;"></div>' +
5562    '<div style="font-family:Syne,sans-serif;font-size:17px;font-weight:800;color:var(--text);margin-bottom:8px;">' + t('em_cad_loading_title') + '</div>' +
5563    '<div style="font-size:13px;color:var(--text3);line-height:1.6;">' + t('em_cad_loading_desc') + '</div>' +
5564    (nomes ? '<div style="font-size:12px;color:var(--accent);margin-top:16px;font-weight:600;">' + nomes + '</div>' : '');
5565  container.appendChild(box);
5566  if (typeof _ensureRedoButton === 'function') _ensureRedoButton('emissores');
5567}
5568
5569async function _emCadastroSave(){
5570  var sel = _emCadSel.slice();
5571  if (!sel.length) { _emCadStatus(t('em_cad_select_error'), 'erro'); return; }
5572
5573  // Grava no MESMO formato do onboarding: G.markets + _meta_emitterMarkets na análise.
5574  G.markets = sel;
5575  var d = window._lastAnalysisData || {};
5576  d._meta_emitterMarkets = sel;
5577  window._lastAnalysisData = d;
5578
5579  _emCadLoading(sel);
5580  try {
5581    var supabase = await getSupabase();
5582    var analysisId = sessionStorage.getItem('cp_active_analysis');
5583    if (analysisId) await supabase.rpc('merge_analysis_field', { p_analysis_id: analysisId, p_field: '_meta_emitterMarkets', p_value: JSON.stringify(sel) });
5584
5585    // Gera o painel e ESPERA. Em caso de sucesso, o próprio _retryEmissores chama
5586    // renderEmissores() e o painel substitui o loading.
5587    var ok = false;
5588    if (typeof _retryEmissores === 'function') ok = await _retryEmissores();
5589    var ld = window._lastAnalysisData || {};
5590    if (!ok) {
5591      // Falhou antes de chegar aos dados (prompt/LLM/rede). Mercados já foram salvos.
5592      await _emissoresRenderCadastro('Não foi possível gerar o painel agora. Os mercados foram salvos — tente novamente em instantes.', 'erro');
5593    } else if (!(ld.emissores_mtur && ld.emissores_mtur.available)) {
5594      await _emissoresRenderCadastro('Não há dados oficiais de chegadas (MTur) para estes mercados neste destino, então o painel não pôde ser montado. Os mercados foram salvos — tente outros mercados ou um destino brasileiro.', 'erro');
5595    }
5596  } catch(e) {
5597    console.error('[Emissores] cadastro de mercados falhou:', e);
5598    await _emissoresRenderCadastro('Erro ao gerar o painel: ' + (e && e.message ? e.message : 'tente novamente') + '.', 'erro');
5599  }
5600}
5601
5602// Restaura o corpo do módulo e remove as telas de cadastro/loading (há dado real para exibir).
5603function _emissoresMostraCorpo() {
5604  var container = _emContainer();
5605  if (!container) return;
5606  ['emissores-cadastro','emissores-cad-loading','emissores-aviso'].forEach(function(id){
5607    var el = document.getElementById(id); if (el) el.remove();
5608  });
5609  var body = container.querySelector('.module-body'); if (body) body.style.display = '';
5610}
5611
5612function renderEmissores(d) {
5613  // RESET: o container #page-emissores é reusado entre análises. Todo render começa
5614  // limpo (remove aviso antigo, restaura o corpo) para não empilhar estados de views
5615  // anteriores. Quem precisar do aviso o reexibe abaixo.
5616  _emissoresMostraCorpo();
5617
5618  if (!_emissoresHasData(d)) {
5619    // Sem painel gerado → SEMPRE a tela de cadastro (nunca o CTA genérico "Mapear
5620    // mercados emissores": ele não dizia QUAIS mercados e não deixava escolher).
5621    // A tela já vem pré-marcada com os mercados desta análise, se houver, e o botão
5622    // "Salvar e gerar painel" roda a análise. Ver docs/MODULES-MAP.md.
5623    _emissoresRenderCadastro();
5624    return;
5625  }
5626  _hideModuleError('emissores');
5627
5628  // Sem dado REAL de chegadas (MTur) não há participação/câmbio/sazonalidade para exibir.
5629  // Regra do módulo: nesses casos NÃO montamos um painel vazio — volta pro cadastro,
5630  // explicando por que o painel não pôde ser montado com os mercados atuais.
5631  var _mturOk = d.emissores_mtur && d.emissores_mtur.available && Array.isArray(d.emissores_mtur.per_market);
5632  if (!_mturOk) { _emissoresRenderCadastro('Não há dados oficiais de chegadas (MTur) para os mercados desta análise neste destino. Ajuste os mercados emissores e gere o painel novamente.', 'erro'); return; }
5633
5634  const emissores = d.emissores_ALWAYS_RETURN_ALL_4_DO_NOT_SKIP||d.emissores||[];
5635  const mtur = (d.emissores_mtur && d.emissores_mtur.available && Array.isArray(d.emissores_mtur.per_market)) ? d.emissores_mtur : null;
5636  const fxRates = (d.emissores_fx && Array.isArray(d.emissores_fx.rates)) ? d.emissores_fx.rates : [];
5637  const mByPais = (name) => mtur ? (mtur.per_market.find(x => _emKey(x.pais) === _emKey(name)) || null) : null;
5638  const fxByMoeda = (moeda) => fxRates.find(r => r.moeda === moeda) || null;
5639  const cols=['var(--accent)','var(--blue)','var(--orange)','var(--yellow)','var(--green)'];
5640  const MES_ABBR = {pt:['Jan','Fev','Mar','Abr','Mai','Jun','Jul','Ago','Set','Out','Nov','Dez'],en:['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'],es:['Ene','Feb','Mar','Abr','May','Jun','Jul','Ago','Sep','Oct','Nov','Dic'],fr:['Jan','Fév','Mar','Avr','Mai','Jun','Jul','Aoû','Sep','Oct','Nov','Déc']};
5641
5642  const cardsEl=document.getElementById('emissores-cards');
5643  if (!cardsEl) return;
5644  cardsEl.innerHTML = '';
5645  if (emissores.length === 0) {
5646    cardsEl.innerHTML = '<div style="padding:24px;text-align:center;color:var(--text3);font-size:13px;grid-column:1/-1;">⏳ Dados de mercados emissores não disponíveis nesta análise. Tente executar uma nova análise.</div>';
5647    return;
5648  }
5649
5650  // ===== CARD 1: participacao por mercado (DADO REAL do MTur; sem dado -> "sem dado") =====
5651  emissores.forEach((m,i)=>{
5652    const mm = mByPais(m.pais);
5653    const share = (mm && mm.has_data && mm.share_pct != null) ? mm.share_pct : null;
5654    const yoy = (mm && mm.yoy_pct != null) ? mm.yoy_pct : null;
5655    const tendUp = yoy != null && yoy > 0, tendDown = yoy != null && yoy < 0;
5656    const dc = tendUp?'var(--accent)':tendDown?'var(--red)':'var(--text2)';
5657    const di = tendUp?'▲':tendDown?'▼':'→';
5658    const badge = (yoy != null)
5659      ? `<div style="font-size:11px;padding:3px 8px;border-radius:100px;background:${tendUp?'var(--accent-dim)':tendDown?'var(--red-dim)':'rgba(75,95,110,0.2)'};color:${dc};">${di} ${yoy>=0?'+':''}${yoy}%</div>`
5660      : '';
5661    const partHtml = (share != null)
5662      ? `<div style="font-size:12px;color:var(--text2);margin-bottom:10px;">${String(share).replace('.',',')}% das chegadas</div><div style="height:3px;background:var(--border);border-radius:2px;"><div style="width:0%;height:100%;border-radius:2px;background:${cols[i]||'var(--accent)'};transition:width 1s ease;" data-w="${share}%"></div></div>`
5663      : `<div style="font-size:12px;margin-bottom:10px;">${_EM_SEMDADO}</div>`;
5664    const card=document.createElement('div');
5665    card.style.cssText='background:var(--surface);border:1px solid var(--border);border-radius:14px;padding:20px;position:relative;overflow:hidden;';
5666    card.innerHTML=`<div style="position:absolute;top:0;right:0;width:50px;height:50px;border-radius:0 14px 0 50px;background:${cols[i]||'var(--accent)'};opacity:0.06;"></div><div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:12px;"><div style="font-size:24px;">${m.flag||'🌍'}</div>${badge}</div><div style="font-family:Syne,sans-serif;font-size:17px;font-weight:700;margin-bottom:4px;">${m.pais}</div>${partHtml}`;
5667    cardsEl.appendChild(card);
5668  });
5669  setTimeout(()=>cardsEl.querySelectorAll('[data-w]').forEach(el=>el.style.width=el.dataset.w),300);
5670
5671  const srcEl = document.getElementById('emissores-cards-source');
5672  if (srcEl) {
5673    srcEl.innerHTML = mtur
5674      ? `📊 ${mtur.granularity_note} Fonte: ${mtur.source}.`
5675      : '⚠️ Sem dado oficial de chegadas por origem para este destino (participação por mercado indisponível).';
5676  }
5677
5678  // ===== CARD 2: Cambio por Mercado (TAXA REAL; sentimento por pais REMOVIDO) =====
5679  const sentGrid = document.getElementById('emissores-sentiment-grid');
5680  if (sentGrid) {
5681    sentGrid.innerHTML = '';
5682    const cambios = d.emissores_cambio_por_mercado || [];
5683    const cByPais = (name) => cambios.find(c => _emKey(c.pais) === _emKey(name)) || {};
5684    emissores.forEach((m) => {
5685      const c = cByPais(m.pais);
5686      const moeda = c.moeda || _EM_CURRENCY[_emKey(m.pais)] || null;
5687      const fx = moeda ? fxByMoeda(moeda) : null;
5688      const taxaHtml = fx
5689        ? `1 ${moeda} = <strong>R$ ${fx.taxa_brl.toLocaleString('pt-BR',{minimumFractionDigits:4})}</strong>`
5690        : _EM_SEMDADO;
5691      const card = document.createElement('div');
5692      card.style.cssText = 'background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:14px;';
5693      card.innerHTML = `
5694        <div style="display:flex;align-items:center;gap:6px;margin-bottom:10px;">
5695          <span style="font-size:14px;">${m.flag||'🌍'}</span>
5696          <span style="font-size:12px;font-weight:700;">${m.pais}</span>
5697          ${moeda?`<span style="font-size:10px;color:var(--text3);margin-left:auto;">${moeda}</span>`:''}
5698        </div>
5699        <div style="display:flex;align-items:center;gap:6px;margin-bottom:6px;">
5700          <span style="font-size:12px;">💱</span>
5701          <span style="font-size:12px;color:var(--text2);">${taxaHtml}</span>
5702        </div>
5703        <div style="font-size:10px;color:var(--text3);line-height:1.4;">${c.impacto || ''}</div>`;
5704      sentGrid.appendChild(card);
5705    });
5706    const sgHead = document.getElementById('emissores-cambio-hint');
5707    if (sgHead) sgHead.textContent = fxRates.length ? 'cotação real de hoje · ExchangeRate' : '';
5708  }
5709
5710  // ===== CARD 4: Sazonalidade (MTur mensal REAL - intensidade por volume) =====
5711  const sazonCard = document.getElementById('emissores-seasonality-card');
5712  const sazonEl = document.getElementById('emissores-seasonality');
5713  const mList = MES_ABBR[G.lang] || MES_ABBR.pt;
5714  const sazMarkets = mtur ? mtur.per_market.filter(m => m.has_data && Array.isArray(m.monthly)) : [];
5715  if (sazonEl && sazonCard) {
5716    if (sazMarkets.length > 0) {
5717      sazonCard.style.display = 'block';
5718      sazonEl.innerHTML = '';
5719      sazMarkets.forEach((s) => {
5720        const em = emissores.find(e => _emKey(e.pais) === _emKey(s.pais)) || {};
5721        const max = Math.max.apply(null, s.monthly.concat([1]));
5722        const monthsHtml = mList.map((mn, mi) => {
5723          const v = s.monthly[mi] || 0;
5724          const inten = v / max;
5725          const bg = inten > 0.15 ? `rgba(0,212,232,${(0.12 + inten*0.6).toFixed(2)})` : 'var(--surface2)';
5726          const col = inten > 0.5 ? 'var(--accent)' : inten > 0.15 ? 'var(--text2)' : 'var(--text3)';
5727          return `<div title="${v.toLocaleString('pt-BR')} chegadas" style="width:32px;height:24px;border-radius:4px;display:flex;align-items:center;justify-content:center;font-size:9px;background:${bg};color:${col};${inten>0.5?'font-weight:700;':''}">${mn}</div>`;
5728        }).join('');
5729        const row = document.createElement('div');
5730        row.style.cssText = 'display:flex;align-items:center;gap:12px;padding:10px 0;border-bottom:1px solid var(--border);';
5731        row.innerHTML = `
5732          <div style="width:100px;flex-shrink:0;"><div style="display:flex;align-items:center;gap:6px;"><span>${em.flag||'🌍'}</span><span style="font-size:12px;font-weight:600;">${s.pais}</span></div></div>
5733          <div style="display:flex;gap:3px;flex:1;flex-wrap:wrap;">${monthsHtml}</div>
5734          <div style="width:180px;flex-shrink:0;font-size:10px;color:var(--text3);">pico ${s.mes_pico||'—'} · baixa ${s.mes_baixa||'—'}</div>`;
5735        sazonEl.appendChild(row);
5736      });
5737    } else {
5738      sazonCard.style.display = 'none';
5739    }
5740  }
5741
5742
5743  // ===== CARD 3: Prioridade de Mercado (participacao REAL; sem R$, sem score inventado) =====
5744  const oppCard = document.getElementById('emissores-opportunity-card');
5745  const oppEl = document.getElementById('emissores-ranking');
5746  const oportunidades = d.emissores_oportunidade || [];
5747  if (oppEl && oppCard) {
5748    if (oportunidades.length > 0) {
5749      oppCard.style.display = 'block';
5750      oppEl.innerHTML = '';
5751      const classLabels = {
5752        prioridade_maxima: {pt:'Prioridade máxima',en:'Top priority',es:'Prioridad máxima',fr:'Priorité maximale',color:'var(--accent)',bg:'var(--accent-dim)'},
5753        oportunidade_crescimento: {pt:'Oportunidade de crescimento',en:'Growth opportunity',es:'Oportunidad de crecimiento',fr:'Opportunité de croissance',color:'var(--green)',bg:'rgba(72,187,120,0.1)'},
5754        potencial_moderado: {pt:'Potencial moderado',en:'Moderate potential',es:'Potencial moderado',fr:'Potentiel modéré',color:'var(--yellow)',bg:'rgba(236,201,75,0.1)'},
5755        mercado_retracao: {pt:'Mercado em retração',en:'Market retraction',es:'Mercado en retracción',fr:'Marché en retrait',color:'var(--red)',bg:'rgba(239,68,68,0.1)'},
5756      };
5757      const withShare = oportunidades.map(m => { const mm = mByPais(m.pais); return { m, share: (mm&&mm.has_data)?(mm.share_pct||0):-1, yoy:(mm?mm.yoy_pct:null), chegadas:(mm?mm.chegadas:null) }; });
5758      withShare.sort((a,b) => b.share - a.share);
5759      const medals = ['🥇','🥈','🥉','4️⃣','5️⃣'];
5760      withShare.forEach((row0, i) => {
5761        const m = row0.m;
5762        const cls = classLabels[m.classificacao] || classLabels.potencial_moderado;
5763        const label = cls[G.lang] || cls.pt;
5764        const shareTxt = row0.share >= 0 ? String(row0.share).replace('.',',') + '%' : '—';
5765        const yoy = row0.yoy;
5766        const tendColor = yoy!=null&&yoy>0 ? 'var(--accent)' : yoy!=null&&yoy<0 ? 'var(--red)' : 'var(--text2)';
5767        const turistas = (row0.chegadas!=null) ? row0.chegadas.toLocaleString('pt-BR') + (mtur?(' ('+mtur.year+')'):'') : (m.turistas_atuais || '—');
5768        const row = document.createElement('div');
5769        row.style.cssText = 'display:flex;align-items:stretch;gap:14px;padding:16px;border-radius:12px;background:var(--surface);border:1px solid var(--border);';
5770        row.innerHTML = `
5771          <div style="display:flex;flex-direction:column;align-items:center;justify-content:center;min-width:56px;">
5772            <div style="font-size:22px;">${medals[i] || '🔹'}</div>
5773            <div style="font-size:8px;color:var(--text3);text-transform:uppercase;letter-spacing:.5px;margin-top:4px;">participação</div>
5774            <div style="font-family:Syne,sans-serif;font-size:22px;font-weight:800;color:${row0.share>=0?'var(--accent)':'var(--text3)'};">${shareTxt}</div>
5775            <div style="font-size:8px;color:var(--text3);line-height:1.2;">das chegadas</div>
5776          </div>
5777          <div style="flex:1;min-width:0;">
5778            <div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;flex-wrap:wrap;">
5779              <span style="font-size:16px;">${m.flag || '🌍'}</span>
5780              <span style="font-family:Syne,sans-serif;font-size:15px;font-weight:700;">${m.pais}</span>
5781              <span style="font-size:10px;padding:3px 8px;border-radius:100px;background:${cls.bg};color:${cls.color};font-weight:600;">${label}</span>
5782              ${yoy!=null ? `<span style="font-size:11px;color:${tendColor};font-weight:600;margin-left:auto;">📈 ${yoy>=0?'+':''}${yoy}% vs ${mtur.prev_year}</span>` : ''}
5783            </div>
5784            <div style="display:flex;gap:16px;margin-bottom:8px;flex-wrap:wrap;">
5785              <div style="font-size:11px;color:var(--text3);">🧳 ${turistas} chegadas</div>
5786            </div>
5787            <div style="font-size:12px;color:var(--text2);line-height:1.5;margin-bottom:6px;">${m.acao_recomendada || ''}</div>
5788            ${m.segmentos_alvo && m.segmentos_alvo.length ? `<div style="display:flex;gap:4px;flex-wrap:wrap;">${m.segmentos_alvo.map(s => `<span style="font-size:10px;padding:2px 8px;border-radius:100px;background:var(--surface2);color:var(--text2);border:1px solid var(--border);">${s}</span>`).join('')}</div>` : ''}
5789          </div>`;
5790        oppEl.appendChild(row);
5791      });
5792    } else {
5793      oppCard.style.display = 'none';
5794    }
5795  }
5796
5797  const alertsEl=document.getElementById('market-alerts');
5798  if (alertsEl) {
5799    alertsEl.innerHTML = '';
5800    (d.emissores_alertas||[]).forEach(a=>{
5801      const bc=a.tipo==='oportunidade'?'var(--accent)':a.tipo==='risco'?'var(--red)':'var(--yellow)';
5802      const ico=a.tipo==='oportunidade'?'💡':a.tipo==='risco'?'⚠️':'📌';
5803      const el=document.createElement('div');
5804      el.style.cssText=`padding:12px 14px;border-radius:10px;background:var(--surface2);border:1px solid var(--border);border-left:3px solid ${bc};`;
5805      el.innerHTML=`<div style="display:flex;align-items:center;gap:6px;margin-bottom:4px;"><span>${ico}</span><span style="font-size:13px;font-weight:600;">${a.titulo}</span><span style="font-size:10px;padding:2px 7px;border-radius:100px;background:${bc}22;color:${bc};margin-left:auto;">${a.mercado}</span></div><div style="font-size:12px;color:var(--text2);">${a.descricao || ''}</div>`;
5806      alertsEl.appendChild(el);
5807    });
5808  }
5809
5810  // ===== CARD 5: Mapa Estrategico (BCG) sobre eixos REAIS do MTur =====
5811  const bcgCard = document.getElementById('emissores-bcg-card');
5812  const bcgChart = document.getElementById('emissores-bcg-chart');
5813  const bcgLegend = document.getElementById('emissores-bcg-legend');
5814  const bcgMarkets = mtur ? mtur.per_market.filter(m => m.has_data && m.share_pct != null) : [];
5815  if (bcgCard && bcgChart) {
5816    if (bcgMarkets.length > 0) {
5817      bcgCard.style.display = 'block';
5818      const W = bcgChart.offsetWidth || 600, H = 360;
5819      bcgChart.innerHTML = `
5820        <div style="position:absolute;top:0;left:0;width:50%;height:50%;display:flex;align-items:center;justify-content:center;border-right:1px dashed rgba(255,255,255,0.08);border-bottom:1px dashed rgba(255,255,255,0.08);"><span style="font-size:11px;color:var(--text3);opacity:0.5;">🔍 Nicho (baixo volume, crescendo)</span></div>
5821        <div style="position:absolute;top:0;right:0;width:50%;height:50%;display:flex;align-items:center;justify-content:center;border-bottom:1px dashed rgba(255,255,255,0.08);"><span style="font-size:11px;color:var(--accent);opacity:0.6;">🚀 Prioridade (alto volume, crescendo)</span></div>
5822        <div style="position:absolute;bottom:0;left:0;width:50%;height:50%;display:flex;align-items:center;justify-content:center;border-right:1px dashed rgba(255,255,255,0.08);"><span style="font-size:11px;color:var(--red);opacity:0.5;">⚠️ Retração (baixo volume, caindo)</span></div>
5823        <div style="position:absolute;bottom:0;right:0;width:50%;height:50%;display:flex;align-items:center;justify-content:center;"><span style="font-size:11px;color:var(--blue);opacity:0.6;">✅ Consolidado (alto volume, estável)</span></div>
5824        <div style="position:absolute;bottom:4px;right:8px;font-size:9px;color:var(--text3);">Participação (chegadas) →</div>
5825        <div style="position:absolute;top:4px;left:8px;font-size:9px;color:var(--text3);">↑ Variação anual</div>`;
5826      const maxShare = Math.max.apply(null, bcgMarkets.map(m => m.share_pct).concat([1]));
5827      const maxArr = Math.max.apply(null, bcgMarkets.map(m =>
5827 m.chegadas).concat([1]));
5828      const pts = bcgMarkets.map((m, i) => {
5829        const yoy = m.yoy_pct != null ? m.yoy_pct : 0;
5830        const em = emissores.find(e => _emKey(e.pais) === _emKey(m.pais)) || {};
5831        return {
5832          m, em, i,
5833          x: Math.min(Math.max((m.share_pct / maxShare) * 80 + 10, 8), 90),
5834          y: Math.min(Math.max(50 - Math.max(-100, Math.min(100, yoy)) * 0.4, 5), 92),
5835          size: Math.min(Math.max((m.chegadas / maxArr) * 45 + 18, 18), 60),
5836        };
5837      });
5838      (function deoverlap() {
5839        const PAD = 8;
5840        const P = pts.map(p => ({ px: p.x/100*W, py: p.y/100*H, r: p.size/2 }));
5841        const GOLDEN = 2.399963229728653;
5842        for (let a=0;a<P.length;a++) for (let b=a+1;b<P.length;b++) {
5843          if (Math.abs(P[a].px-P[b].px)<0.5 && Math.abs(P[a].py-P[b].py)<0.5) {
5844            P[b].px += Math.cos(GOLDEN*b)*(P[a].r+P[b].r+PAD); P[b].py += Math.sin(GOLDEN*b)*(P[a].r+P[b].r+PAD);
5845          }
5846        }
5847        for (let it=0;it<120;it++){ let moved=false;
5848          for (let a=0;a<P.length;a++) for (let b=a+1;b<P.length;b++){
5849            const dx=P[b].px-P[a].px, dy=P[b].py-P[a].py, dist=Math.hypot(dx,dy)||0.01, minD=P[a].r+P[b].r+PAD;
5850            if (dist<minD){ const push=(minD-dist)/2, ux=dx/dist, uy=dy/dist; P[a].px-=ux*push;P[a].py-=uy*push;P[b].px+=ux*push;P[b].py+=uy*push; moved=true; }
5851          }
5852          if(!moved)break;
5853        }
5854        pts.forEach((p,k)=>{ const r=P[k].r; p.x=Math.min(Math.max(P[k].px,r+2),W-r-2)/W*100; p.y=Math.min(Math.max(P[k].py,r+2),H-r-2)/H*100; });
5855      })();
5856      pts.forEach(p => {
5857        const { m, em, i, x, y, size } = p;
5858        const yoy = m.yoy_pct != null ? m.yoy_pct : 0;
5859        const color = yoy > 5 ? 'var(--accent)' : yoy < -5 ? 'var(--red)' : 'var(--yellow)';
5860        const bubble = document.createElement('div');
5861        bubble.style.cssText = `position:absolute;left:${x}%;top:${y}%;transform:translate(-50%,-50%);width:${size}px;height:${size}px;border-radius:50%;background:${color};opacity:0.25;border:2px solid ${color};transition:all 0.3s;cursor:pointer;z-index:${10+i};`;
5862        const label = document.createElement('div');
5863        label.style.cssText = `position:absolute;left:${x}%;top:${y}%;transform:translate(-50%,-50%);text-align:center;z-index:${20+i};pointer-events:none;`;
5864        label.innerHTML = `<div style="font-size:16px;">${em.flag || '🌍'}</div><div style="font-size:10px;font-weight:700;color:var(--text);text-shadow:0 1px 3px rgba(0,0,0,0.8);">${m.pais}</div>`;
5865        bubble.onmouseenter = () => { bubble.style.opacity='0.6'; bubble.style.transform='translate(-50%,-50%) scale(1.15)'; };
5866        bubble.onmouseleave = () => { bubble.style.opacity='0.25'; bubble.style.transform='translate(-50%,-50%) scale(1)'; };
5867        bcgChart.appendChild(bubble); bcgChart.appendChild(label);
5868      });
5869      if (bcgLegend) {
5870        bcgLegend.innerHTML = `<div style="display:flex;align-items:center;gap:6px;"><div style="width:12px;height:12px;border-radius:50%;background:var(--accent);opacity:0.4;border:1px solid var(--accent);"></div><span style="color:var(--text3);">Tamanho = volume real de chegadas · eixos: participação × variação anual (MTur ${mtur.year})</span></div>`;
5871      }
5872    } else {
5873      bcgCard.style.display = 'none';
5874    }
5875  }
5876
5877  // ===== CARD 6: Narrativas (LLM qualitativo; % de crescimento REMOVIDO) =====
5878  const narrCard = document.getElementById('emissores-narratives-card');
5879  const narrPos = document.getElementById('narratives-positive');
5880  const narrNeg = document.getElementById('narratives-negative');
5881  const narrAnalysis = document.getElementById('narratives-analysis');
5882  const posNarr = d.emissores_narrativas_positivas || [];
5883  const negNarr = d.emissores_narrativas_negativas || [];
5884  if ((posNarr.length > 0 || negNarr.length > 0) && narrCard) {
5885    narrCard.style.display = 'block';
5886    if (narrPos) {
5887      narrPos.innerHTML = '';
5888      posNarr.forEach(n => {
5889        const el = document.createElement('div');
5890        el.style.cssText = 'padding:12px;border-radius:10px;background:var(--surface2);border:1px solid var(--border);border-left:3px solid var(--accent);';
5891        el.innerHTML = `
5892          <div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;"><span style="font-size:16px;">${n.emoji || '📈'}</span><span style="font-size:13px;font-weight:700;">${n.narrativa}</span></div>
5893          <div style="display:flex;gap:4px;flex-wrap:wrap;margin-bottom:6px;">${(n.mercados||[]).map(m =>
5893 `<span style="font-size:10px;padding:2px 7px;border-radius:100px;background:var(--accent-dim);color:var(--accent);">${m}</span>`).join('')}</div>
5894          <div style="font-size:11px;color:var(--text3);font-style:italic;">${n.conteudo_viral || ''}</div>`;
5895        narrPos.appendChild(el);
5896      });
5897    }
5898    if (narrNeg) {
5899      narrNeg.innerHTML = '';
5900      negNarr.forEach(n => {
5901        const el = document.createElement('div');
5902        el.style.cssText = 'padding:12px;border-radius:10px;background:var(--surface2);border:1px solid var(--border);border-left:3px solid var(--red);';
5903        el.innerHTML = `
5904          <div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;"><span style="font-size:16px;">${n.emoji || '⚠️'}</span><span style="font-size:13px;font-weight:700;">${n.narrativa}</span></div>
5905          <div style="display:flex;gap:4px;flex-wrap:wrap;margin-bottom:6px;">${(n.mercados||[]).map(m => `<span style="font-size:10px;padding:2px 7px;border-radius:100px;background:rgba(239,68,68,0.1);color:var(--red);">${m}</span>`).join('')}</div>
5906          <div style="font-size:11px;color:var(--yellow);">🎯 ${n.acao_mitigar || ''}</div>`;
5907        narrNeg.appendChild(el);
5908      });
5909    }
5910    if (narrAnalysis && d.emissores_narrativas_analise) {
5911      narrAnalysis.innerHTML = d.emissores_narrativas_analise.replace(/\*\*(.*?)\*\*/g,'<strong>$1</strong>').replace(/\n/g,'<br>');
5912    }
5913  }
5914
5915  if (d._exchangeRates) {
5916    const ratesEl = document.getElementById('currency-real-rates');
5917    if (ratesEl) {
5918      ratesEl.innerHTML = `<span style="color:var(--accent);font-weight:700;">USD R$${d._exchangeRates.usd}</span> · <span style="color:var(--blue);">EUR R$${d._exchangeRates.eur}</span> · <span style="color:var(--text2);">ARS R$${d._exchangeRates.ars}</span> <span style="font-size:10px;color:var(--text3);">${{pt:"cotação real",en:"live rate",es:"cotización real",fr:"taux réel"}[G.lang]||"live rate"}</span>`;
5919      ratesEl.style.display = 'flex';
5920    }
5921  }
5922  const anEl = document.getElementById('emissores-analysis');
5923  if (anEl) anEl.innerHTML=(d.emissores_analise||'--').replace(/\*\*(.*?)\*\*/g,'<strong>$1</strong>').replace(/\n/g,'<br>');
5924
5925  const currCard = document.getElementById('currency-alert-card');
5926  const currText = document.getElementById('currency-alert-text');
5927  if (d.emissores_cambio && currCard && currText) {
5928    currText.innerHTML = d.emissores_cambio.replace(/\*\*(.*?)\*\*/g,'<strong>$1</strong>');
5929    currCard.style.display = 'block';
5930  }
5931  const langCard = document.getElementById('language-alert-card');
5932  const langText = document.getElementById('language-alert-text');
5933  if (langCard && langText) {
5934    let txt = d.emissores_idioma ? d.emissores_idioma.replace(/\*\*(.*?)\*\*/g,'<strong>$1</strong>') : '';
5935    if (mtur && mtur.espanhol_share_pct != null) {
5936      txt += `<div style="margin-top:8px;font-size:11px;color:var(--accent);">📊 ${mtur.espanhol_share_pct}% das chegadas internacionais pela UF de ${mtur.uf} são de países hispanofalantes (${mtur.year}, MTur).</div>`;
5937    }
5938    if (txt) { langText.innerHTML = txt; langCard.style.display = 'block'; }
5939  }
5940}
5941
5942async function runSimulator() {
5943  const decisao=document.getElementById('sim-input').value.trim();
5944  if(!decisao)return alert(t('sim_empty'));
5945  document.getElementById('sim-results').style.display='none';
5946  document.getElementById('sim-placeholder').innerHTML='<div style="font-size:36px;margin-bottom:12px;">⏳</div><div>'+t('sim_loading')+'</div>';
5947  const langName = {pt:'Portuguese',en:'English',es:'Spanish',fr:'French'}[G.lang] || 'Portuguese';
5948  const prompt = await getPrompt('decision-simulator', { dest: G.dest || 'destino', decisao, langName });
5949  console.log('📝 [simulator] Prompt carregado do banco');
5950  try {
5951    let txt;
5952    txt = await callAIGlobal(prompt, 1000, true, { slug: 'decision-simulator' });
5953    const result=JSON.parse(txt.replace(/```json|```/g,'').trim());
5954    document.getElementById('sim-positivos').innerHTML=(result.impactos_positivos||[]).map(p=>`<div style="display:flex;gap:8px;font-size:13px;color:var(--text2);"><span style="color:var(--accent);flex-shrink:0;">✓</span>${p}</div>`).join('');
5955    document.getElementById('sim-riscos').innerHTML=(result.riscos||[]).map(r=>`<div style="display:flex;gap:8px;font-size:13px;color:var(--text2);"><span style="color:var(--red);flex-shrink:0;">⚠</span>${r}</div>`).join('');
5956    document.getElementById('sim-metrics').innerHTML=(result.metricas||[]).map(m=>`<div style="display:flex;align-items:center;justify-content:space-between;padding:10px 14px;border-radius:8px;background:var(--surface2);border:1px solid var(--border);"><div style="font-size:13px;">${m.nome}</div><div style="font-family:Syne,sans-serif;font-size:15px;font-weight:700;color:${m.direcao==='up'?'var(--accent)':'var(--red)'};">${m.direcao==='up'?'▲':'▼'} ${m.valor}</div></div>`).join('');
5957    document.getElementById('sim-analysis').innerHTML=(result.analise||'--').replace(/\*\*(.*?)\*\*/g,'<strong>$1</strong>').replace(/\n/g,'<br>');
5958    document.getElementById('sim-results').style.display='block';
5959    document.getElementById('sim-placeholder').style.display='none';
5960  } catch(e) {
5961    alert(t('sim_error_prefix')+e.message);
5962    document.getElementById('sim-placeholder').innerHTML='<div style="font-size:36px;margin-bottom:12px;">🧪</div><div>'+t('sim_retry')+'</div>';
5963  }
5964}
5965
5966// ===== SOCIAL RADAR =====
5967function renderSocial(d) {
5968  if (!_socialHasData(d)) {
5969    if (_showModuleCTA('social')) return;
5970    _showModuleError('social', { icon: '📱', title: t('err_title_social'), buttonStyle: 'background:linear-gradient(135deg,#E1306C,#833AB4)', retryFn: _retrySocial });
5971    return;
5972  }
5973  _hideModuleError('social');
5974  const social = d.social_radar_MANDATORY_DO_NOT_SKIP || d.social_radar || {};
5975
5976  // ===== LIMPAR dados da análise anterior para evitar mesclagem =====
5977  ['consensus-tags','ig-trends','tt-trends','x-trends'].forEach(id => {
5978    const el = document.getElementById(id); if (el) el.innerHTML = '';
5979  });
5980  const alertBadgeReset = document.getElementById('consensus-alert-badge');
5981  if (alertBadgeReset) alertBadgeReset.style.display = 'none';
5982  const consensoCardReset = document.getElementById('consensus-card');
5983  if (consensoCardReset) { consensoCardReset.style.borderColor = ''; consensoCardReset.style.boxShadow = ''; }
5984  const consensoDotReset = document.getElementById('consensus-dot');
5985  if (consensoDotReset) consensoDotReset.style.background = 'var(--accent)';
5986  document.getElementById('consensus-text').textContent = '--';
5987  document.getElementById('social-strategy').innerHTML = '--';
5988
5989  if (!social.consenso && !social.instagram && !social.tiktok) {
5990    const consensusTags = document.getElementById('consensus-tags');
5991    if (consensusTags) consensusTags.innerHTML = '<div style="padding:24px;text-align:center;color:var(--text3);font-size:13px;">'+t('social_no_data')+'</div>';
5992    return;
5993  }
5994
5995  // Consensus tags
5996  const consensusTags = document.getElementById('consensus-tags');
5997  (social.consenso || []).forEach(item => {
5998    const tag = document.createElement('div');
5999    tag.style.cssText = 'display:flex;align-items:center;gap:8px;padding:8px 14px;border-radius:100px;background:var(--accent-dim);border:1px solid rgba(34,211,238,0.25);font-size:13px;font-weight:500;color:var(--accent);';
6000    tag.innerHTML = `${item.emoji||'🔥'} ${item.tema} <span style="font-size:11px;color:var(--text2);margin-left:4px;">${item.plataformas}</span>`;
6001    consensusTags.appendChild(tag);
6002  });
6003  document.getElementById('consensus-text').textContent = social.consenso_analise || '--';
6004
6005  // Consensus Alert: pulse red if any topic appears on 2+ platforms
6006  const alertBadge = document.getElementById('consensus-alert-badge');
6007  const consensoCard = document.getElementById('consensus-card');
6008  const consensoDot = document.getElementById('consensus-dot');
6009  const multiPlatform = (social.consenso || []).filter(c => (c.plataformas||[]).length >= 2);
6010  if (multiPlatform.length > 0 && alertBadge && consensoCard) {
6011    alertBadge.style.display = 'block';
6012    alertBadge.title = multiPlatform.map(c => c.tema).join(', ') + t('social_multi_platform_suffix');
6013    consensoCard.style.borderColor = 'rgba(239,68,68,0.4)';
6014    consensoCard.style.boxShadow = '0 0 20px rgba(239,68,68,0.12)';
6015    if (consensoDot) { consensoDot.style.background = 'var(--red)'; }
6016  }
6017
6018  // Per platform trends
6019  const platforms = [
6020    { id: 'ig-trends', key: 'instagram', color: '#FD1D1D' },
6021    { id: 'tt-trends', key: 'tiktok', color: '#00F2EA' },
6022    { id: 'x-trends', key: 'twitter', color: '#1D9BF0' },
6023  ];
6024  platforms.forEach(({ id, key, color }) => {
6025    const container = document.getElementById(id);
6026    ((social[key]) || []).forEach(trend => {
6027      const el = document.createElement('div');
6028      el.style.cssText = 'display:flex;align-items:center;gap:10px;padding:9px 12px;border-radius:8px;background:var(--surface2);border:1px solid var(--border);';
6029      const growthColor = trend.crescimento > 0 ? 'var(--accent)' : 'var(--red)';
6030      el.innerHTML = `
6031        <div style="font-size:14px;flex-shrink:0;">${trend.emoji||'📌'}</div>
6032        <div style="flex:1;">
6033          <div style="font-size:12px;font-weight:600;">${trend.tema}</div>
6034          <div style="font-size:10px;color:var(--text3);margin-top:2px;">${trend.descricao||''}</div>
6035        </div>
6036        <div style="text-align:right;flex-shrink:0;">
6037          <div style="font-size:12px;font-weight:700;color:${growthColor};">${trend.crescimento > 0 ? '+' : ''}${trend.crescimento}%</div>
6038          <div style="font-size:10px;color:var(--text3);">${t('social_week_suffix')}</div>
6039        </div>`;
6040      container.appendChild(el);
6041    });
6042  });
6043
6044  document.getElementById('social-strategy').innerHTML = (social.estrategia || '--').replace(/\*\*(.*?)\*\*/g,'<strong>$1</strong>').replace(/\n/g,'<br>');
6045}
6046
6047async function runTracker() {
6048  const tema = document.getElementById('tracker-input').value.trim();
6049  if (!tema) return alert(t('social_tracker_alert_empty'));
6050  const sidebarEl = document.getElementById('sidebar-api');
6051  const provider = G.provider || 'openai';
6052  // Chaves gerenciadas server-side — não precisa de apiKey local
6053
6054  document.getElementById('tracker-results').style.display = 'none';
6055  document.getElementById('tracker-placeholder').innerHTML = '<div style="font-size:24px;margin-bottom:8px;">⏳</div><div>' + t('social_tracker_loading').replace('{tema}', tema) + '</div>';
6056
6057  const langName = {pt:'Portuguese',en:'English',es:'Spanish',fr:'French'}[G.lang] || 'Portuguese';
6058  const prompt = await getPrompt('trend-tracker', { dest: G.dest || 'destino', tema, langName });
6059
6060  try {
6061    const txt = await callAISecure(G.provider || 'openai', prompt, 1200, true);
6062
6063    const result = JSON.parse(txt.replace(/```json|```/g, '').trim());
6064
6065    // Heat score color
6066    const hs = result.heat_score || 0;
6067    const hsColor = hs >= 70 ? 'var(--accent)' : hs >= 40 ? 'var(--yellow)' : 'var(--text2)';
6068    document.getElementById('heat-score').textContent = hs;
6069    document.getElementById('heat-score').style.color = hsColor;
6070    document.getElementById('tracker-tema').textContent = '"' + tema + '"';
6071    document.getElementById('tracker-resumo').textContent = result.resumo || '--';
6072    document.getElementById('tracker-crescimento').textContent = result.crescimento_semanal || '--';
6073
6074    // Per platform
6075    const platformsEl = document.getElementById('tracker-platforms');
6076    platformsEl.innerHTML = '';
6077    (result.plataformas || []).forEach(p => {
6078      const gc = p.crescimento > 0 ? 'var(--accent)' : 'var(--red)';
6079      const tomKey = _socialTomKey(p.tom);
6080      const tomColor = tomKey === 'positive' ? 'var(--accent)' : tomKey === 'mixed' ? 'var(--yellow)' : 'var(--text2)';
6081      const tomLabel = tomKey ? t('social_tom_' + tomKey) : (p.tom || '');
6082      const el = document.createElement('div');
6083      el.style.cssText = 'display:flex;align-items:center;gap:12px;padding:12px 14px;border-radius:10px;background:var(--surface2);border:1px solid var(--border);';
6084      el.innerHTML = `
6085        <div style="font-size:20px;flex-shrink:0;">${p.emoji||'📱'}</div>
6086        <div style="flex:1;">
6087          <div style="display:flex;align-items:center;gap:8px;margin-bottom:3px;">
6088            <span style="font-size:13px;font-weight:600;">${p.plataforma}</span>
6089            <span style="font-size:11px;color:var(--text3);">${p.volume}</span>
6090            <span style="font-size:11px;padding:2px 7px;border-radius:100px;background:${tomColor}22;color:${tomColor};margin-left:auto;">${tomLabel}</span>
6091          </div>
6092          <div style="font-size:12px;color:var(--text2);">${p.tipo_conteudo}</div>
6093          <div style="height:3px;background:var(--border);border-radius:2px;margin-top:6px;">
6094            <div style="width:0%;height:100%;border-radius:2px;background:${gc};transition:width 1s ease;" data-w="${Math.abs(p.crescimento)}%"></div>
6095          </div>
6096        </div>
6097        <div style="text-align:right;flex-shrink:0;">
6098          <div style="font-family:Syne,sans-serif;font-size:16px;font-weight:700;color:${gc};">${p.crescimento > 0 ? '+' : ''}${p.crescimento}%</div>
6099          <div style="font-size:10px;color:var(--text3);">${t('social_week_suffix')}</div>
6100        </div>`;
6101      platformsEl.appendChild(el);
6102    });
6103    setTimeout(() => platformsEl.querySelectorAll('[data-w]').forEach(el => el.style.width = el.dataset.w), 200);
6104
6105    document.getElementById('tracker-analysis').innerHTML = (result.analise || '--').replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>').replace(/\n/g, '<br>');
6106    document.getElementById('tracker-results').style.display = 'block';
6107    document.getElementById('tracker-placeholder').style.display = 'none';
6108
6109  } catch(e) {
6110    alert(t('social_tracker_error_prefix') + e.message);
6111    document.getElementById('tracker-placeholder').innerHTML = '<div style="font-size:24px;margin-bottom:8px;">❌</div><div>' + t('social_tracker_error_retry') + '</div>';
6112  }
6113}
6114
6115// Normaliza o tom da IA (novo token fixo em inglês ou legado em pt-BR) pra uma chave
6116// interna estável — mesmo padrão de _crisisLevelKey (crisis.js) — usada pra cor/tradução.
6117function _socialTomKey(v) {
6118  var s = (v || '').toLowerCase();
6119  if (s === 'positive' || s === 'positivo') return 'positive';
6120  if (s === 'mixed' || s === 'misto') return 'mixed';
6121  if (s === 'neutral' || s === 'neutro') return 'neutral';
6122  return '';
6123}
6124
6125// ===== SAVE / LOAD / EXPORT =====
6126
6127// Salvar análise automaticamente no Supabase
6128
6129// Fallback: salvar no localStorage
6130
6131// Carregar histórico do Supabase para a page-history
6132
6133// Confirmar e carregar análise do histórico
6134
6135// Deletar análise do histórico
6136
6137
6138function loadAnalysis() {
6139  goPage('history');
6140  loadHistoryFromSupabase();
6141}
6142
6143function openFilePicker() {
6144  const input = document.createElement('input');
6145  input.type = 'file';
6146  input.accept = '.json';
6147  input.onchange = async (e) => {
6148    const file = e.target.files[0];
6149    if (!file) return;
6150    try {
6151      const text = await file.text();
6152      const record = JSON.parse(text);
6153      if (!record.data || !record.dest) throw new Error('Arquivo inválido');
6154      loadRecord(record, { targetPage: 'dashboard' });
6155    } catch(err) {
6156      alert('Erro ao carregar arquivo: ' + err.message);
6157    }
6158  };
6159  input.click();
6160}
6161
6162
6163
6164function deleteRecord(index, event) {
6165  event.stopPropagation();
6166  let history = [];
6167  try { history = JSON.parse(localStorage.getItem('dp_history') || '[]'); } catch(e) {}
6168  history.splice(index, 1);
6169  localStorage.setItem('dp_history', JSON.stringify(history));
6170  refreshHistoryPanel();
6171}
6172
6173
6174// ===== ORACLE =====
6175
6176// ===== FELICIDADE =====
6177// Schema do analysis-oracle exige 4 emotions + 3 friction + 3 magic.
6178// Quando o LLM (especialmente fallback gpt-4o sob rate limit do Claude) abrevia,
6179// vem 2+1+1 ou similar. Aqui validamos a quantidade minima — se incompleto,
6180// renderFelicidade dispara _retryFelicidade automaticamente.
6181function _felicidadeHasData(d) {
6182  return d.feliz_score != null
6183    && Array.isArray(d.feliz_emotions) && d.feliz_emotions.length >= 4
6184    && Array.isArray(d.feliz_friction) && d.feliz_friction.length >= 3
6185    && Array.isArray(d.feliz_magic)    && d.feliz_magic.length    >= 3;
6186}
6187
6188function _showFelicidadeError() {
6189  _showModuleError('felicidade', {
6190    icon: '😔',
6191    title: t('err_title_felicidade'),
6192    buttonStyle: 'background:linear-gradient(135deg,var(--yellow),var(--orange));color:#000',
6193    retryFn: _retryFelicidade
6194  });
6195}
6196
6197async function _retryFelicidade() {
6198  var btn = document.querySelector('#felicidade-error-state button');
6199  if (btn) { btn.disabled = true; btn.textContent = t('btn_generating'); }
6200  try {
6201    var dest = G.dest || '';
6202    var d = window._lastAnalysisData || {};
6203    var sectorCtx = G.sector || 'destination';
6204    var langName = {pt:'Portuguese',en:'English',es:'Spanish',fr:'French'}[G.lang||'pt'] || 'Portuguese';
6205    var now = new Date();
6206    var startDate = new Date(now.getTime() - (parseInt(G.periodo||'30',10)) * 86400000);
6207    var fmtD = function(dt) { return dt.toISOString().split('T')[0]; };
6208    // Montar contexto a partir de dados existentes
6209    var extraCtx = '';
6210    if (d.temas) extraCtx += 'Temas: ' + d.temas.map(function(t) { return t.nome + ' (' + t.sent + ', ' + t.count + ')'; }).join(', ');
6211    if (d.sentimento) extraCtx += '\nSentimento: positivo ' + d.sentimento.positivo + '%, neutro ' + d.sentimento.neutro + '%, negativo ' + d.sentimento.negativo + '%';
6212    if (d.heatmap_areas) extraCtx += '\nLocais: ' + d.heatmap_areas.map(function(h) { return h.nome + ' (score:' + h.score + ', trend:' + h.tendencia + ')'; }).join(', ');
6213    if (d.turista_perfil) extraCtx += '\nPerfil: ' + (d.turista_perfil.perfil_dominante || '') + ', ' + (d.turista_perfil.faixa_etaria || '');
6214
6215    var felizMeta = await getPromptWithMeta('felicidade-retry', {
6216      dest: dest, startDate: fmtD(startDate), now: fmtD(now),
6217      periodoNum: G.periodo||'30', langName: langName,
6218      extraDataCtx: extraCtx || 'Contexto não disponível'
6219    });
6220    if (!felizMeta?.text) throw new Error('Prompt felicidade-retry não encontrado');
6221    var raw3 = await callAIGlobal(felizMeta.text, 16384, true, felizMeta);
6222    var result = _parseAIJson(raw3);
6223    if (!result || !result.feliz_score) throw new Error('Resposta sem dados de felicidade');
6224    // Merge SELETIVO — só campos feliz_*
6225    var felizFields = ['feliz_score', 'feliz_nivel', 'feliz_emoji', 'feliz_emotions', 'feliz_friction', 'feliz_magic', 'feliz_analise', 'feliz_sazonal'];
6226    var supabase = await getSupabase();
6227    var analysisId = sessionStorage.getItem('cp_active_analysis');
6228    for (var i = 0; i < felizFields.length; i++) {
6229      if (result[felizFields[i]] != null) {
6230        d[felizFields[i]] = result[felizFields[i]];
6231        if (analysisId) await supabase.rpc('merge_analysis_field', { p_analysis_id: analysisId, p_field: felizFields[i], p_value: JSON.stringify(result[felizFields[i]]) });
6232      }
6233    }
6234    window._lastAnalysisData = d;
6235    // Remover estado de erro/CTA e restaurar o corpo do módulo (.module-body)
6236    _hideModuleError('felicidade');
6237    renderFelicidade(d);
6238  } catch(e) {
6239    console.error('[Felicidade] Retry failed:', e);
6240    if (btn) { btn.disabled = false; btn.textContent = 'Tentar novamente'; }
6241    var errEl = document.querySelector('#felicidade-error-state .felicidade-retry-msg');
6242    if (!errEl) {
6243      errEl = document.createElement('div');
6244      errEl.className = 'felicidade-retry-msg';
6245      errEl.style.cssText = 'font-size:12px;color:var(--red);margin-top:10px;';
6246      btn.parentElement.appendChild(errEl);
6247    }
6248    errEl.textContent = 'Falha ao gerar: ' + (e.message || 'erro de
6248sconhecido');
6249  }
6250}
6251
6252// Normaliza feliz_nivel/intensidade (token fixo em inglês novo ou legado em pt-BR de
6253// análises salvas antes do fix) pra uma chave interna estável — mesmo padrão de
6254// _crisisLevelKey (crisis.js) — usada pra tradução na camada de exibição.
6255function _felizNivelKey(v) {
6256  var s = (v || '').toLowerCase();
6257  if (s === 'delighted' || s === 'encantado') return 'delighted';
6258  if (s === 'satisfied' || s === 'satisfeito') return 'satisfied';
6259  if (s === 'neutral' || s === 'neutro') return 'neutral';
6260  if (s === 'frustrated' || s === 'frustrado') return 'frustrated';
6261  if (s === 'critical' || s === 'crítico' || s === 'critico') return 'critical';
6262  return '';
6263}
6264function _felizIntensityKey(v) {
6265  var s = (v || '').toLowerCase();
6266  if (s === 'high' || s === 'alta') return 'high';
6267  if (s === 'medium' || s === 'média' || s === 'media') return 'medium';
6268  if (s === 'low' || s === 'baixa') return 'low';
6269  return '';
6270}
6271
6272function renderFelicidade(d) {
6273  // 3 estados possíveis:
6274  //   1) Dados completos (4 emotions, 3 friction, 3 magic) → renderiza normal
6275  //   2) Dados ausentes (oracle nunca rodou) → CTA convidativo "Calcular Mapa Emocional"
6276  //   3) Dados parciais/incompletos (LLM abreviou) → auto-retry 1x via _retryFelicidade
6277  if (!_felicidadeHasData(d)) {
6278    var hasAnyFelicidade = d.feliz_score != null
6279      || (Array.isArray(d.feliz_emotions) && d.feliz_emotions.length)
6280      || (Array.isArray(d.feliz_friction) && d.feliz_friction.length)
6281      || (Array.isArray(d.feliz_magic)    && d.feliz_magic.length);
6282    // Sem dado nenhum → módulo nunca rodou pelo user → mostra CTA, não auto-retry
6283    if (!hasAnyFelicidade) {
6284      if (_showModuleCTA('felicidade')) return;
6285    }
6286    // Dados parciais (oracle rodou mas LLM abreviou): auto-retry 1x antes de mostrar erro
6287    if (!d._autoRetryFeliz) {
6288      d._autoRetryFeliz = true;
6289      console.log('[Felicidade] Dados incompletos (' +
6290        (d.feliz_emotions||[]).length + ' emotions, ' +
6291        (d.feliz_friction||[]).length + ' friction, ' +
6292        (d.feliz_magic||[]).length + ' magic) — disparando retry automatico.');
6293      _showFelicidadeError();
6294      var btn = document.querySelector('#felicidade-error-state button');
6295      if (btn) { btn.disabled = true; btn.textContent = '⏳ Completando analise...'; }
6296      _retryFelicidade();
6297      return;
6298    }
6299    // Ja tentou retry e ainda incompleto — mostra erro normal (com botao manual)
6300    _showFelicidadeError();
6301    return;
6302  }
6303
6304  // Restaurar o corpo do módulo caso estivesse escondido por CTA/erro anterior
6305  _hideModuleError('felicidade');
6306
6307  const score = d.feliz_score||0;
6308  const scoreEl = document.getElementById('feliz-score');
6309  scoreEl.textContent = score;
6310  scoreEl.style.color = score>=70?'var(--accent)':score>=50?'var(--yellow)':'var(--red)';
6311
6312  const badge = document.getElementById('feliz-badge');
6313  var nivelKey = _felizNivelKey(d.feliz_nivel);
6314  badge.textContent = nivelKey ? t('feliz_nivel_'+nivelKey) : (d.feliz_nivel||'--');
6315  badge.style.background = score>=70?'var(--accent-dim)':score>=50?'rgba(255,200,0,0.15)':'var(--red-dim)';
6316  badge.style.color = score>=70?'var(--accent)':score>=50?'var(--yellow)':'var(--red)';
6317
6318  document.getElementById('feliz-emoji').textContent = d.feliz_emoji||'😊';
6319
6320  const emotionsEl = document.getElementById('feliz-emotions');
6321  emotionsEl.innerHTML = '';
6322  (d.feliz_emotions||[]).forEach(e => {
6323    const col = e.cor==='accent'?'var(--accent)':e.cor==='red'?'var(--red)':e.cor==='blue'?'var(--blue)':'var(--yellow)';
6324    const el = document.createElement('div');
6325    el.innerHTML = `<div style="display:flex;align-items:center;gap:10px;margin-bottom:3px;"><span style="font-size:14px;">${e.emoji||'😐'}</span><span style="font-size:13px;font-weight:600;flex:1;">${e.nome}</span><span style="font-size:13px;font-weight:700;color:${col};">${e.score}%</span></div><div style="height:4px;background:var(--border);border-radius:2px;margin-bottom:4px;"><div style="width:0%;height:100%;border-radius:2px;background:${col};transition:width 1s ease;" data-w="${e.score}%"></div></div><div style="font-size:11px;color:var(--text3);">${e.descricao}</div>`;
6326    emotionsEl.appendChild(el);
6327  });
6328  setTimeout(()=>emotionsEl.querySelectorAll('[data-w]').forEach(el=>el.style.width=el.dataset.w),300);
6329
6330  const frictionEl = document.getElementById('feliz-friction');
6331  frictionEl.innerHTML = '';
6332  (d.feliz_friction||[]).forEach(f => {
6333    const intKey = _felizIntensityKey(f.intensidade);
6334    const intColor = intKey==='high'?'var(--red)':intKey==='medium'?'var(--yellow)':'var(--text2)';
6335    const intLabel = intKey ? t('feliz_intensity_'+intKey) : (f.intensidade||'');
6336    const el = document.createElement('div');
6337    el.style.cssText = `padding:12px 14px;border-radius:9px;background:var(--surface2);border:1px solid var(--border);border-left:3px solid ${intColor};`;
6338    el.innerHTML = `<div style="display:flex;align-items:center;gap:6px;margin-bottom:4px;"><span>${f.emoji||'😤'}</span><span style="font-size:13px;font-weight:600;">${f.ponto}</span><span style="font-size:10px;padding:2px 7px;border-radius:100px;background:${intColor}22;color:${intColor};margin-left:auto;">${intLabel}</span></div><div style="font-size:12px;color:var(--text2);margin-bottom:4px;">${f.descricao}</div><div style="font-size:11px;color:var(--accent);">→ ${f.acao}</div>`;
6339    frictionEl.appendChild(el);
6340  });
6341
6342  const magicEl = document.getElementById('feliz-magic');
6343  magicEl.innerHTML = '';
6344  (d.feliz_magic||[]).forEach(m => {
6345    const el = document.createElement('div');
6346    el.style.cssText = 'display:flex;gap:10px;padding:12px 14px;border-radius:9px;background:var(--surface2);border:1px solid var(--border);border-left:3px solid var(--accent);';
6347    el.innerHTML = `<div style="font-size:20px;flex-shrink:0;">${m.emoji||'✨'}</div><div><div style="font-size:13px;font-weight:600;margin-bottom:3px;">${m.momento}</div><div style="font-size:12px;color:var(--text2);">${m.descricao}</div></div>`;
6348    magicEl.appendChild(el);
6349  });
6350
6351  document.getElementById('feliz-analysis').innerHTML=(d.feliz_analise||'--').replace(/\*\*(.*?)\*\*/g,'<strong>$1</strong>').replace(/\n/g,'<br>');
6352  // Add algorithm note
6353  const felizAnalEl = document.getElementById('feliz-analysis');
6354  if (felizAnalEl && !document.getElementById('feliz-algo-note')) {
6355    const note = document.createElement('div');
6356    note.id = 'feliz-algo-note';
6357    note.style.cssText = 'margin-top:14px;padding:10px 14px;border-radius:8px;background:rgba(255,255,255,0.03);border:1px solid var(--border);';
6358    note.innerHTML = `<div style="font-size:10px;text-transform:uppercase;letter-spacing:1px;color:var(--text3);margin-bottom:5px;">${t('feliz_algo_title')}</div>
6359      <div style="font-size:12px;color:var(--text3);line-height:1.5;">${t('feliz_algo_text')}</div>`;
6360    felizAnalEl.appendChild(note);
6361  }
6362  // Seasonal comparison note
6363  if (d.feliz_sazonal && !document.getElementById('feliz-sazonal-card')) {
6364    const seasonal = document.createElement('div');
6365    seasonal.id = 'feliz-sazonal-card';
6366    seasonal.style.cssText = 'margin-top:12px;padding:12px 14px;border-radius:10px;background:rgba(34,211,238,0.06);border-left:3px solid var(--accent);';
6367    seasonal.innerHTML = `<div style="font-size:11px;text-transform:uppercase;letter-spacing:1px;color:var(--accent);margin-bottom:5px;">${t('feliz_seasonal_title')}</div>
6368      <div style="font-size:13px;color:var(--text2);line-height:1.5;">${d.feliz_sazonal}</div>`;
6369    felizAnalEl.appendChild(seasonal);
6370  }
6371}
6372
6373// ===== CAN =====
6374
6375// ===== GEMEO DIGITAL =====
6376
6377
6378// ===== CAN CHAT =====
6379var canChatHistory = [];
6380
6381
6382
6383
6384
6385// ===== UPLOAD FILE READER =====
6386async function readUploadedFile() {
6387  // Coleta apenas arquivos anexados NESTA análise (em memória).
6388  // Não fazer fallback pra storage: se o usuário não anexou doc nesta análise,
6389  // não usar doc de outra análise (isolamento entre análises).
6390  const allFiles = [
6391    ...Array.from(OB_files || []),
6392    ...Array.from(TOPBAR_files || []),
6393  ].filter(f => f.extracted_text && !f.uploading);
6394
6395  if (allFiles.length === 0) {
6396    return null;
6397  }
6398
6399  const MAX_TOTAL_CHARS = 50000;
6400  let combined = '';
6401  let fileCount = 0;
6402
6403  for (const f of allFiles) {
6404    if (combined.length >= MAX_TOTAL_CHARS) break;
6405    if (!f.extracted_text || f.extracted_text.length <= 10) continue;
6406    fileCount++;
6407    const maxPerFile = Math.min(Math.floor(MAX_TOTAL_CHARS / allFiles.length), 25000);
6408    combined += `\n\n--- DOCUMENTO ${fileCount}: ${f.name} (${(f.size/1024).toFixed(0)}KB) ---\n${f.extracted_text.substring(0, maxPerFile)}`;
6409  }
6410
6411  if (!combined) return null;
6412  console.log(`[Upload] ✅ ${fileCount} files, ${combined.length} chars total (from storage)`);
6413  return combined.substring(0, MAX_TOTAL_CHARS);
6414}
6415
6416// Upload zone interaction
6417document.addEventListener('DOMContentLoaded', () => {
6418  const fileInput = document.getElementById('f-upload');
6419  const uploadLabel = document.getElementById('upload-label');
6420  const uploadZone = document.getElementById('upload-zone');
6421  if (fileInput) {
6422    fileInput.addEventListener('change', (e) => {
6423      const file = e.target.files[0];
6424      if (file) {
6425        uploadLabel.textContent = '✅ ' + file.name + ' (' + (file.size/1024).toFixed(1) + ' KB)';
6426        uploadZone.style.borderColor = 'var(--accent)';
6427        uploadZone.style.background = 'var(--accent-dim)';
6428      }
6429    });
6430  }
6431  // Init focus options on load
6432  updateFocusOptions();
6433});
6434
6435// ===== CLICKABLE INSIGHTS (expandable cards) =====
6436function renderClickableOpportunities(d) {
6437  const container = document.getElementById('oportunidades-list');
6438  if (!container) return;
6439  container.innerHTML = '';
6440  const opps = d.oportunidades_acoes || d.oportunidades || [];
6441  opps.forEach((op, i) => {
6442    const prioColor = op.prioridade==='high'?'var(--accent)':op.prioridade==='medium'?'var(--yellow)':'var(--blue)';
6443    const prioLabel = op.prioridade==='high'?'Alta':'op.prioridade'==='medium'?'Média':'Baixa';
6444    const el = document.createElement('div');
6445    el.style.cssText = `border-radius:12px;background:var(--surface2);border:1px solid var(--border);border-left:3px solid ${prioColor};overflow:hidden;transition:all 0.2s;cursor:pointer;`;
6446    el.innerHTML = `
6447      <div class="opp-header" style="display:flex;align-items:center;gap:12px;padding:14px 16px;" onclick="toggleOpp(this)">
6448        <div style="font-size:20px;flex-shrink:0;">${op.icone||'💡'}</div>
6449        <div style="flex:1;">
6450          <div style="font-size:13px;font-weight:600;">${op.titulo}</div>
6451          <div style="font-size:12px;color:var(--text2);margin-top:2px;">${op.descricao}</div>
6452        </div>
6453        <div style="display:flex;align-items:center;gap:8px;flex-shrink:0;">
6454          <span style="font-size:10px;padding:2px 8px;border-radius:100px;background:${prioColor}22;color:${prioColor};">Prioridade ${op.prioridade==='high'?'Alta':op.prioridade==='medium'?'Média':'Baixa'}</span>
6455          <span style="font-size:12px;color:var(--text3);transition:transform 0.2s;" class="opp-arrow">▼</span>
6456        </div>
6457      </div>
6458      <div class="opp-body" style="display:none;padding:0 16px 16px;border-top:1px solid var(--border);">
6459        <div style="padding:12px 14px;border-radius:8px;background:rgba(34,211,238,0.05);border:1px solid rgba(34,211,238,0.1);margin-top:12px;margin-bottom:12px;">
6460          <div style="font-size:11px;text-transform:uppercase;letter-spacing:0.8px;color:var(--text3);margin-bottom:6px;">🔍 O que isso significa</div>
6461          <div style="font-size:13px;color:var(--text1);line-height:1.6;">${op.o_que_significa||op.descricao}</div>
6462        </div>
6463        <div style="margin-bottom:10px;">
6464          <div style="font-size:11px;text-transform:uppercase;letter-spacing:0.8px;color:var(--text3);margin-bottom:8px;">🎯 Ações Recomendadas</div>
6465          <div style="display:flex;flex-direction:column;gap:6px;">
6466            ${(op.acoes||[]).map((a,i)=>`<div style="display:flex;gap:10px;align-items:flex-start;padding:8px 12px;border-radius:8px;background:var(--surface3);border:1px solid var(--border);"><span style="color:${prioColor};font-weight:700;flex-shrink:0;">${i+1}.</span><span style="font-size:13px;">${a}</span></div>`).join('')}
6467          </div>
6468        </div>
6469        ${op.impacto ? `<div style="display:flex;gap:12px;"><div style="flex:1;padding:10px 12px;border-radius:8px;background:var(--surface3);border:1px solid var(--border);"><div style="font-size:10px;color:var(--text3);text-transform:uppercase;margin-bottom:4px;">Impacto Esperado</div><div style="font-size:12px;color:var(--accent);">${op.impacto}</div></div>${op.prazo?`<div style="flex:1;padding:10px 12px;border-radius:8px;background:var(--surface3);border:1px solid var(--border);"><div style="font-size:10px;color:var(--text3);text-transform:uppercase;margin-bottom:4px;">Prazo</div><div style="font-size:12px;">${op.prazo}</div></div>`:''}</div>` : ''}
6470      </div>`;
6471    container.appendChild(el);
6472  });
6473}
6474
6475function toggleOpp(header) {
6476  const body = header.parentElement.querySelector('.opp-body');
6477  const arrow = header.querySelector('.opp-arrow');
6478  const isOpen = body.style.display !== 'none';
6479  body.style.display = isOpen ? 'none' : 'block';
6480  arrow.style.transform = isOpen ? '' : 'rotate(180deg)';
6481}
6482
6483// ===== EXPORT CURRENT SECTION =====
6484function exportCurrentSection() {
6485  const activePage = document.querySelector('.page.active');
6486  if (!activePage) return;
6487  const pageId = activePage.id.replace('page-','');
6488  // Public MAX tem export próprio (relatório multi-aba com gráficos e CSS scoped).
6489  // O dump genérico de innerHTML abaixo perde o CSS scoped, os <canvas> e as abas
6490  // inativas/lazy. Ver frontend/publicmax/extensions/export/module.js.
6491  if (pageId === 'publicmax' && typeof window.publicmaxExportReport === 'function') {
6492    window.publicmaxExportReport();
6493    return;
6494  }
6495  const title = `Consensus Pulse — ${G.dest} — ${pageId}`;
6496  const printWin = window.open('','_blank');
6497  printWin.document.write(`<html><head><title>${title}</title><style>
6498    body{font-family:sans-serif;padding:20px;color:#111;background:#fff;}
6499    .card{border:1px solid #ddd;border-radius:8px;padding:16px;margin-bottom:16px;}
6500    h1{font-size:20px;margin-bottom:4px;}
6501    .insight-block{line-height:1.7;white-space:pre-wrap;}
6502  </style></head><body class="onboarding">
6503  <h1>${title}</h1>
6504  <p style="color:#666;font-size:12px;">Gerado em ${new Date().toLocaleString('pt-BR')}</p>
6505  <hr/>
6506  ${activePage.querySelector('.dash-content')?.innerHTML || activePage.innerHTML}
6507  </body></html>`);
6508  printWin.document.close();
6509  setTimeout(() => printWin.print(), 500);
6510}
6511
6512// ===== EMISSORES 7D/3D TABS =====
6513// ===== EMISSORES — buscas reais por mercado (Google Trends por país; sem abas 7d/3d) =====
6514// (Card "O que cada mercado busca" REMOVIDO 2026-07-25 — o Google Trends por
6515//  termo do destino era ruído: nome de cidade ambíguo (ex.: "Torres" → Ferran
6516//  Torres/Torres del Paine) e sem fonte confiável de disambiguação. Ver histórico.)
6517
6518
6519// ===== CAN — PLANO DE AÇÃO COMPLETO =====
6520async function generateActionPlan() {
6521  if (!G.data || !G.dest) return alert(t('can_plan_alert_no_analysis'));
6522
6523  const btn = document.querySelector('[onclick="generateActionPlan()"]');
6524  btn.textContent = t('can_plan_generating');
6525  btn.disabled = true;
6526
6527  const d = G.data;
6528  const context = `
6529Destino: ${G.dest}
6530Score atual: ${d.score || 'N/A'}/100
6531Sentimento: ${d.sentimento ? `${d.sentimento.positivo}% positivo, ${d.sentimento.negativo}% negativo` : 'N/A'}
6532Nível de crise: ${d.crisis_level || 'N/A'}
6533Crise detectada: ${d.crisis_resumo || 'Nenhuma'}
6534Top oportunidades: ${(d.oportunidades_acoes || d.oportunidades || []).map(o => o.titulo).join(', ')}
6535Temas principais: ${(d.temas || []).map(t => t.nome).join(', ')}
6536Perfil do turista: ${d.turista_perfil ? `${d.turista_perfil.faixa_etaria}, ${d.turista_perfil.perfil_dominante}` : 'N/A'}
6537Mercado emissor principal: ${(d.emissores || [])[0]?.pais || 'N/A'}
6538Tendência: ${d.tendencia_busca || 'N/A'}
6539Predições virais: ${(d.viral_predictions || []).map(v => v.titulo).join(', ')}
6540Alertas oracle: ${(d.oracle_alerts || []).map(a => a.titulo).join(', ')}
6541Nível de felicidade: ${d.feliz_score || 'N/A'}/100
6542Pontos de fricção: ${(d.feliz_friction || []).map(f => f.ponto).join(', ')}
6543  `.trim();
6544
6545  const langName = {pt:'Português',en:'English',es:'Español',fr:'Français'}[G.lang] || 'Português';
6546  const basePrompt = await getPrompt('can-advisor-plan', { dest: G.dest, context, langName });
6547  const provider = G.provider || 'openai';
6548
6549  // Fallback contra truncação de JSON: quando a resposta estoura o maxTokens ela é
6550  // cortada no meio de um array e o JSON.parse quebra. Tentamos de novo pedindo uma
6551  // resposta progressivamente MENOR. As "hints" abaixo são modificadores curtos
6552  // anexados ao prompt estrutural (que continua vindo do DB — regra do CLAUDE.md).
6553  const attempts = [
6554    { label: t('can_plan_generating'),  maxTokens: 4000, hint: '' },
6555    { label: t('can_plan_adjusting'),   maxTokens: 5000, hint: '\n\nIMPORTANTE: seja objetivo — no máximo 3 ações por seção e cada campo em 1 frase curta. Mantenha o JSON compacto.' },
6556    { label: t('can_plan_simplifying'), maxTokens: 6000, hint: '\n\nRESTRIÇÃO: retorne o schema MÍNIMO — no máximo 2 ações em cada array (acoes_imediatas, acoes_semana, acoes_mes), textos de 1 frase e o JSON o mais curto possível.' },
6557  ];
6558
6559  let plan = null, lastErr = null;
6560  for (let i = 0; i < attempts.length; i++) {
6561    const at = attempts[i];
6562    btn.textContent = at.label;
6563    try {
6564      const txt = await callAISecure(provider, basePrompt + at.hint, at.maxTokens, true);
6565      let planTxt = txt.replace(/```json|```/g, '').trim();
6566      const pStart = planTxt.indexOf('{');
6567      const pEnd = planTxt.lastIndexOf('}');
6568      if (pStart >= 0 && pEnd > pStart) planTxt = planTxt.substring(pStart, pEnd + 1);
6569      plan = JSON.parse(planTxt);
6570      break; // sucesso
6571    } catch(e) {
6572      lastErr = e;
6573      // Só vale reduzir a resposta quando o JSON veio truncado (SyntaxError do
6574      // JSON.parse). Erro do backend (limite, plano, rate-limit, auth, rede) não
6575      // melhora com retry menor → sai na hora e preserva a mensagem real.
6576      if (!(e instanceof SyntaxError)) break;
6577      console.warn(`[Can] Tentativa ${i + 1}/${attempts.length} de gerar plano falhou (JSON truncado?):`, e.message);
6578    }
6579  }
6580
6581  try {
6582    if (plan) {
6583      renderActionPlan(plan);
6584      document.getElementById('action-plan-container').style.display = 'block';
6585      document.getElementById('action-plan-container').scrollIntoView({behavior: 'smooth'});
6586      // Persiste no histórico vinculado ao analysis_id (fire-and-forget; RLS isola por usuário).
6587      if (typeof saveCanActionPlan === 'function') saveCanActionPlan(plan);
6588    } else if (lastErr instanceof SyntaxError) {
6589      // Todas as tentativas vieram truncadas — mesmo depois de pedir resposta menor.
6590      alert(t('can_plan_truncated_alert'));
6591    } else if (lastErr) {
6592      // Erro do backend (limite de plano/uso, rate-limit, auth, conexão) — mensagem real.
6593      alert(lastErr.message || t('can_plan_generic_error'));
6594    } else {
6595      alert(t('can_plan_generic_error'));
6596    }
6597  } catch(e) {
6598    console.error('[Can] Erro ao renderizar plano:', e);
6599    alert(t('can_plan_render_error'));
6600  } finally {
6601    btn.textContent = t('can_btn_action_plan');
6602    btn.disabled = false;
6603  }
6604}
6605
6606// Normaliza prazo (token fixo "48h"/"7d"/"30d" ou legado "7 dias"/"30 dias" pt-BR de
6607// planos salvos antes do fix) pra tradução na exibição — mesmo padrão de _canUrgenciaKey.
6608function _canPrazoKey(v) {
6609  var s = (v || '').toLowerCase().replace(/\s/g, '');
6610  if (s === '48h') return '48h';
6611  if (s === '7d' || s === '7dias') return '7d';
6612  if (s === '30d' || s === '30dias') return '30d';
6613  return '';
6614}
6615
6616function renderActionPlan(plan) {
6617  const container = document.getElementById('action-plan-content');
6618  const fmt = t => (t||'').replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>
6618').replace(/\\n/g, '<br>');
6619
6620  const actionRow = (a, color) => {
6621    const prazoKey = _canPrazoKey(a.prazo);
6622    const prazoLabel = prazoKey ? t('can_prazo_' + prazoKey) : (a.prazo || '');
6623    return `
6624    <div style="padding:12px 14px;border-radius:9px;background:var(--surface3);border:1px solid var(--border);border-left:3px solid ${color};margin-bottom:8px;">
6625      <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:6px;">
6626        <span style="font-size:12px;font-weight:700;">${a.acao||''}</span>
6627        <span style="font-size:10px;padding:2px 7px;border-radius:100px;background:${color}22;color:${color};margin-left:auto;flex-shrink:0;">${prazoLabel}</span>
6628      </div>
6629      <div style="display:grid;grid-template-columns:1fr 1fr;gap:6px;">
6630        <div style="font-size:11px;color:var(--text3);">👤 ${a.responsavel||'—'}</div>
6631        <div style="font-size:11px;color:var(--text3);">📈 ${a.impacto||'—'}</div>
6632        <div style="font-size:11px;color:var(--accent);grid-column:1/-1;">${t('can_lbl_base_prefix')}${a.dados_base||'—'}</div>
6633      </div>
6634    </div>`;
6635  };
6636
6637  container.innerHTML = `
6638    <div style="font-size:13px;line-height:1.8;color:var(--text1);margin-bottom:20px;padding:14px;border-radius:10px;background:rgba(139,92,246,0.05);border:1px solid rgba(139,92,246,0.15);">
6639      ${fmt(plan.resumo_executivo)}
6640    </div>
6641
6642    <div style="font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:1px;color:var(--red);margin-bottom:10px;">${t('can_section_immediate')}</div>
6643    ${(plan.acoes_imediatas||[]).map(a => actionRow(a, 'var(--red)')).join('')}
6644
6645    <div style="font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:1px;color:var(--yellow);margin:16px 0 10px;">${t('can_section_week')}</div>
6646    ${(plan.acoes_semana||[]).map(a => actionRow(a, 'var(--yellow)')).join('')}
6647
6648    <div style="font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:1px;color:var(--accent);margin:16px 0 10px;">${t('can_section_month')}</div>
6649    ${(plan.acoes_mes||[]).map(a => actionRow(a, 'var(--accent)')).join('')}
6650
6651    <div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:20px;">
6652      <div style="padding:14px;border-radius:10px;background:rgba(34,211,238,0.05);border:1px solid rgba(34,211,238,0.2);">
6653        <div style="font-size:11px;text-transform:uppercase;letter-spacing:0.8px;color:var(--accent);margin-bottom:8px;">${t('can_opportunity_title')}</div>
6654        <div style="font-size:12px;color:var(--text1);line-height:1.6;">${plan.oportunidade_critica||''}</div>
6655      </div>
6656      <div style="padding:14px;border-radius:10px;background:rgba(239,68,68,0.05);border:1px solid rgba(239,68,68,0.2);">
6657        <div style="font-size:11px;text-transform:uppercase;letter-spacing:0.8px;color:var(--red);margin-bottom:8px;">${t('can_risk_title')}</div>
6658        <div style="font-size:12px;color:var(--text1);line-height:1.6;">${plan.risco_critico||''}</div>
6659      </div>
6660    </div>
6661
6662
6663    <div style="margin-top:16px;padding:14px 16px;border-radius:10px;background:linear-gradient(135deg,rgba(139,92,246,0.08),rgba(217,70,239,0.08));border:1px solid rgba(139,92,246,0.2);">
6664      <div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;">
6665        <div style="width:24px;height:24px;border-radius:50%;background:linear-gradient(135deg,var(--blue),var(--purple));display:flex;align-items:center;justify-content:center;font-size:12px;">🧠</div>
6666        <span style="font-size:11px;font-weight:700;color:var(--blue);">${t('can_message_title')}</span>
6667      </div>
6668      <div style="font-size:13px;color:var(--text1);line-height:1.7;font-style:italic;">"${plan.mensagem_can||''}"</div>
6669    </div>
6670  `;
6671  // Store for export
6672  window._lastActionPlan = plan;
6673  // Inject orcamento via DOM (avoids template literal nesting issues)
6674  if (plan.orcamento_estimado) {
6675    const msgDiv = container.querySelector('[style*="linear-gradient(135deg,rgba(139,92,246"]');
6676    if (msgDiv && msgDiv.parentNode) {
6677      const oDiv = document.createElement('div');
6678      oDiv.style.cssText = 'margin-top:16px;padding:14px 16px;border-radius:10px;background:rgba(16,185,129,0.06);border:1px solid rgba(16,185,129,0.25);';
6679      const o = plan.orcamento_estimado;
6680      oDiv.innerHTML = '<div style="font-size:11px;text-transform:uppercase;letter-spacing:.8px;color:var(--green);margin-bottom:12px;">'+t('can_budget_title')+'</div>'
6681        + '<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin-bottom:10px;">'
6682        + '<div style="text-align:center;padding:8px;border-radius:8px;background:var(--surface3);"><div style="font-size:10px;color:var(--text3);margin-bottom:4px;">'+t('can_prazo_48h')+'</div><div style="font-size:13px;font-weight:700;color:var(--text1);">'+(o.imediato||'—')+'</div></div>'
6683        + '<div style="text-align:center;padding:8px;border-radius:8px;background:var(--surface3);"><div style="font-size:10px;color:var(--text3);margin-bottom:4px;">'+t('can_prazo_7d')+'</div><div style="font-size:13px;font-weight:700;color:var(--text1);">'+(o.semana||'—')+'</div></div>'
6684        + '<div style="text-align:center;padding:8px;border-radius:8px;background:var(--surface3);"><div style="font-size:10px;color:var(--text3);margin-bottom:4px;">'+t('can_prazo_30d')+'</div><div style="font-size:13px;font-weight:700;color:var(--text1);">'+(o.mes||'—')+'</div></div>'
6685        + '</div>'
6686        + '<div style="display:flex;justify-content:space-between;align-items:center;padding:8px 12px;border-radius:8px;background:var(--surface2);">'
6687        + '<span style="font-size:12px;color:var(--text2);">'+t('can_budget_total_label')+' <strong style="color:var(--text1);">'+(o.total_estimado||'—')+'</strong></span>'
6688        + '<span style="font-size:12px;color:var(--green);font-weight:700;">'+t('can_budget_roi_label')+' '+(o.roi_previsto||'—')+'</span>'
6689        + '</div>';
6690      msgDiv.parentNode.insertBefore(oDiv, msgDiv);
6691    }
6692  }
6693}
6694
6695function exportActionPlan() {
6696  if (!window._lastActionPlan) return;
6697  const plan = window._lastActionPlan;
6698  const content = document.getElementById('action-plan-content').innerHTML;
6699  const win = window.open('', '_blank');
6700  const locale = {pt:'pt-BR',en:'en-US',es:'es-ES',fr:'fr-FR'}[G.lang]||'pt-BR';
6701  win.document.write(`<html><head><title>${t('can_export_title')} — ${G.dest}</title>
6702  <style>body{font-family:sans-serif;padding:24px;color:#111;max-width:800px;margin:0 auto;}
6703  h1{font-size:22px;margin-bottom:4px;}p{margin:8px 0;line-height:1.6;}
6704  div{box-sizing:border-box;}</style></head><body class="onboarding">
6705  <h1>📋 ${t('can_export_title')} — ${G.dest}</h1>
6706  <p style="color:#666;font-size:12px;">${t('can_export_generated_by')}
6706${new Date().toLocaleString(locale)}</p>
6707  <hr style="margin:16px 0;"/>
6708  ${content}
6709  </body></html>`);
6710  win.document.close();
6711  setTimeout(() => win.print(), 500);
6712}
6713
6714// ══════════════════════════════════════════════
6715// SIMULATOR HELPERS
6716// ══════════════════════════════════════════════
6717let G_simCenario = 'moderado';
6718
6719function simSetCenario(c) {
6720  G_simCenario = c;
6721  ['conservador','moderado','agressivo'].forEach(x => {
6722    const btn = document.getElementById('sim-btn-'+x);
6723    if (!btn) return;
6724    if (x === c) {
6725      btn.style.borderColor = 'var(--accent)';
6726      btn.style.background = 'var(--accent-dim)';
6727      btn.style.color = 'var(--accent)';
6728    } else {
6729      btn.style.borderColor = 'var(--border)';
6730      btn.style.background = 'var(--surface2)';
6731      btn.style.color = 'var(--text2)';
6732    }
6733  });
6734}
6735
6736// Idioma corrente -> mapeamento de locale de data (mesmo padrão de shared/report.js).
6737var _SIM_LOCALE_MAP = { pt: 'pt-BR', en: 'en-US', es: 'es-ES', fr: 'fr-FR' };
6738function _simLocale() { return _SIM_LOCALE_MAP[G.lang] || 'pt-BR'; }
6739function _simCenarioLabel() {
6740  return { conservador: t('sim_conservative'), moderado: t('sim_moderate'), agressivo: t('sim_aggressive') }[G_simCenario] || G_simCenario;
6741}
6742
6743function simGerarBriefing() {
6744  const decisao = document.getElementById('sim-input')?.value || '';
6745  const analise = document.getElementById('sim-analysis')?.innerText || '';
6746  const win = window.open('', '_blank');
6747  win.document.write(`<html><head><title>${t('sim_briefing_title')} — ${G.dest}</title>
6748  <style>body{font-family:Arial,sans-serif;padding:40px;max-width:800px;margin:0 auto;color:#111;line-height:1.7;}
6749  h1{font-size:22px;border-bottom:3px solid #00D4E8;padding-bottom:10px;}
6750  h2{font-size:15px;color:#555;text-transform:uppercase;letter-spacing:1px;margin-top:28px;}
6751  .box{background:#f5f5f5;padding:16px;border-radius:8px;margin:10px 0;}
6752  </style></head><body>
6753  <h1>📋 ${t('sim_briefing_title')}</h1>
6754  <p><strong>${t('sim_doc_dest_label')}</strong> ${G.dest} &nbsp;|&nbsp; <strong>${t('sim_doc_date_label')}</strong> ${new Date().toLocaleDateString(_simLocale())} &nbsp;|&nbsp; <strong>
6754${t('sim_doc_scenario_label')}</strong> ${_simCenarioLabel()}</p>
6755  <h2>${t('sim_briefing_decision_label')}</h2>
6756  <div class="box">${decisao}</div>
6757  <h2>${t('sim_briefing_analysis_label')}</h2>
6758  <div class="box">${analise}</div>
6759  <h2>${t('sim_briefing_objective_label')}</h2>
6760  <div class="box">${t('sim_briefing_objective_text').replace('{cenario}', '<strong>'+_simCenarioLabel()+'</strong>').replace('{dest}', G.dest)}</div>
6761  <h2>${t('sim_briefing_kpis_label')}</h2>
6762  <div id="kpis-briefing"></div>
6763  <br><p style="font-size:11px;color:#999;">${t('sim_briefing_footer')}</p>
6764  </body></html>`);
6765  win.document.close();
6766  setTimeout(() => win.print(), 500);
6767}
6768
6769function simGerarReservaOrc() {
6770  const decisao = document.getElementById('sim-input')?.value || '';
6771  const analise = document.getElementById('sim-analysis')?.innerText || '';
6772  const win = window.open('', '_blank');
6773  win.document.write(`<html><head><title>${t('sim_budget_title')} — ${G.dest}</title>
6774  <style>body{font-family:Arial,sans-serif;padding:40px;max-width:800px;margin:0 auto;color:#111;line-height:1.7;}
6775  h1{font-size:20px;border-bottom:2px solid #333;padding-bottom:10px;}
6776  h2{font-size:14px;text-transform:uppercase;letter-spacing:1px;margin-top:24px;color:#555;}
6777  .box{border:1px solid #ddd;padding:14px;border-radius:6px;margin:8px 0;}
6778  table{width:100%;border-collapse:collapse;margin:10px 0;}
6779  td,th{border:1px solid #ddd;padding:10px;font-size:13px;}
6780  th{background:#f5f5f5;}
6781  </style></head><body>
6782  <h1>🏛️ ${t('sim_budget_title')}</h1>
6783  <p><strong>${t('sim_budget_dept_label')}</strong> _________________________ &nbsp;|&nbsp; <strong>${t('sim_doc_date_label')}</strong> ${new Date().toLocaleDateString(_simLocale())}</p>
6784  <p><strong>${t('sim_doc_dest_label')}</strong> ${G.dest} &nbsp;|&nbsp; <strong>
6784${t('sim_doc_scenario_label')}</strong> ${_simCenarioLabel()}</p>
6785  <h2>${t('sim_budget_1_label')}</h2>
6786  <div class="box">${decisao}</div>
6787  <h2>${t('sim_budget_2_label')}</h2>
6788  <div class="box">${analise}</div>
6789  <h2>${t('sim_budget_3_label')}</h2>
6790  <table><tr><th>${t('sim_budget_th_item')}</th><th>${t('sim_budget_th_value')}</th><th>${t('sim_budget_th_justification')}</th></tr>
6791  <tr><td>_______________</td><td>___________</td><td>_______________</td></tr>
6792  <tr><td>_______________</td><td>___________</td><td>_______________</td></tr>
6793  <tr><td><strong>${t('sim_budget_total')}</strong></td><td><strong>___________</strong></td><td></td></tr>
6794  </table>
6795  <h2>${t('sim_budget_4_label')}</h2>
6796  <div style="display:flex;gap:40px;margin-top:30px;">
6797    <div style="text-align:center;flex:1;"><div style="border-top:1px solid #333;padding-top:8px;margin-top:40px;">${t('sim_budget_role_requester')}</div></div>
6798    <div style="text-align:center;flex:1;"><div style="border-top:1px solid #333;padding-top:8px;margin-top:40px;">${t('sim_budget_role_secretary')}</div></div>
6799    <div style="text-align:center;flex:1;"><div style="border-top:1px solid #333;padding-top:8px;margin-top:40px;">${t('sim_budget_role_internal_control')}</div></div>
6800  </div>
6801  <br><p style="font-size:10px;color:#999;">${t('sim_budget_footer')}</p>
6802  </body></html>`);
6803  win.document.close();
6804  setTimeout(() => win.print(), 500);
6805}
6806
6807// ══════════════════════════════════════════════
6808// SOCIAL RADAR HELPERS
6809// ══════════════════════════════════════════════
6810function showConsensoTrend() {
6811  const panel = document.getElementById('consenso-trend-panel');
6812  const sparklines = document.getElementById('consenso-sparklines');
6813  if (!panel || !sparklines) return;
6814  const isVisible = panel.style.display !== 'none';
6815  if (isVisible) { panel.style.display = 'none'; return; }
6816
6817  // Generate sparkline from consensus data
6818  const d = G.lastData || G.data || {};
6819  const consenso = d.social_radar?.consenso || [];
6820  if (!consenso.length) {
6821    sparklines.innerHTML = '<div style="font-size:13px;color:var(--text3);">'+t('social_trend_empty')+'</div>';
6822    panel.style.display = 'block';
6823    return;
6824  }
6825
6826  sparklines.innerHTML = consenso.map(c => {
6827    // Simulate a 7-day trend based on topic sentiment
6828    const peak = 70 + Math.floor(Math.random()*25);
6829    const points = Array.from({length:7}, (_,i) => {
6830      const base = 30 + Math.floor(Math.random()*20);
6831      return i === 5 ? peak : base + Math.floor(Math.random()*(peak-base)*((i+1)/7));
6832    });
6833    const max = Math.max(...points);
6834    const bars = points.map((v,i) => {
6835      const h = Math.round((v/max)*32);
6836      const col = i === 5 ? 'var(--accent)' : 'var(--surface3)';
6837      return '<div style="width:10px;height:'+h+'px;background:'+col+';border-radius:3px 3px 0 0;align-self:flex-end;"></div>';
6838    }).join('');
6839    return '<div style="display:flex;align-items:center;gap:10px;">'
6840      +'<div style="font-size:13px;flex:1;color:var(--text2);">'+(c.emoji||'🔥')+' '+c.tema+'</div>'
6841      +'<div style="display:flex;gap:2px;align-items:flex-end;height:36px;">'+bars+'</div>'
6842      +'<div style="font-size:11px;color:var(--accent);font-weight:700;width:30px;text-align:right;">↑'+points[5]+'%</div>'
6843      +'</div>';
6844  }).join('');
6845  panel.style.display = 'block';
6846}
6847
6848// ══════════════════════════════════════════════
6849// ONBOARDING STATE
6850// ══════════════════════════════════════════════
6851function obSelectLang(el, lang) {
6852  OB.lang = lang;
6853  document.querySelectorAll('#ob-step1 [data-lang]').forEach(c => c.classList.remove('sel'));
6854  el.classList.add('sel');
6855  applyObI18n();
6856  obAutoSave();
6857}
6858
6859function obSelectSector(el, sector) {
6860  OB.sector = sector;
6861  document.querySelectorAll('#ob-step1 [data-sector]').forEach(c => c.classList.remove('selected'));
6862  el.classList.add('selected');
6863  obAutoSave();
6864}
6865
6866function obGoStep1() {
6867  // Se destino já está travado e não é bypass, não permitir voltar ao step 1
6868  if (window._userDestination && !_isBypassUser()) return;
6869  if (window.AppRouter) {
6870    AppRouter.navigateTo('/app/onboarding/1');
6871  } else {
6872    document.querySelectorAll('.ob-page').forEach(p => p.classList.remove('active'));
6873    document.getElementById('ob-step1').classList.add('active');
6874  }
6875}
6876
6877function obGoStep2() {
6878  if (window.AppRouter) {
6879    AppRouter.navigateTo('/app/onboarding/2');
6880  } else {
6881    document.querySelectorAll('.ob-page').forEach(p => p.classList.remove('active'));
6882    document.getElementById('ob-step2').classList.add('active');
6883  }
6884  applyObI18n();
6885  const isDestino = OB.sector === 'destination';
6886  document.getElementById('ob-form-destino').style.display = isDestino ? 'flex' : 'none';
6887  document.getElementById('ob-form-empresa').style.display = isDestino ? 'none' : 'flex';
6888  // Populate focus options
6889  const sel = document.getElementById(isDestino ? 'ob-foco' : 'ob-foco-emp');
6890  if (sel) {
6891    const opts = sectorFocusOptions[OB.sector] || sectorFocusOptions.destination;
6892    sel.innerHTML = opts.map(o => `<option value="${o.v}">${o[OB.lang]||o.en||o.pt}</option>`).join('');
6893  }
6894  // Show recent destinations
6895  obShowRecent();
6896  obValidateStep2();
6897  // Aplicar lock de destino se já tem destino salvo no banco
6898  enforceDestinationLockFromDB();
6899
6900  // Buscar plano do Supabase e aplicar limites de concorrentes
6901  fetchUserPlanAndEnforce();
6902}
6903
6904async function fetchUserPlanAndEnforce() {
6905  try {
6906    const supabase = await getSupabase();
6907    const { data: { session } } = await supabase.auth.getSession();
6908    if (session) {
6909      const { data: profile } = await supabase
6910        .from('profiles')
6911        .select('plan_type')
6912        .eq('id', session.user.id)
6913        .single();
6914      if (profile?.plan_type) {
6915        localStorage.setItem('cp_user_plan', profile.plan_type);
6916        localStorage.setItem('cp_plan_type', profile.plan_type);
6917      }
6918    }
6919  } catch (e) {
6920    console.warn('Erro ao buscar plano do Supabase:', e);
6921  }
6922  enforceCompetitorLimit();
6923  enforceMenuByPlan();
6924  updateTopbarUserMenu();
6925}
6926
6927function obShowRecent() {
6928  const isDestino = OB.sector === 'destination';
6929  const key = isDestino ? 'ob_recent_dests' : 'ob_recent_empresas';
6930  const panelId = isDestino ? 'ob-recent-dests' : 'ob-recent-empresas';
6931  const listId = isDestino ? 'ob-recent-dests-list' : 'ob-recent-empresas-list';
6932  try {
6933    const saved = JSON.parse(localStorage.getItem(key) || '[]');
6934    const panel = document.getElementById(panelId);
6935    const list = document.getElementById(listId);
6936    if (!panel || !list || !saved.length) return;
6937    list.innerHTML = saved.slice(0,5).map(d => 
6938      `<button onclick="obFillRecent('${d.replace(/'/g,"\'")}','${isDestino?'dest':'emp'}')" style="padding:5px 12px;border-radius:100px;border:1px solid var(--border);background:var(--surface2);color:var(--text2);font-size:12px;cursor:pointer;">${d}</button>`
6939    ).join('');
6940    panel.style.display = 'block';
6941  } catch(e) {}
6942}
6943
6944function obFillRecent(val, type) {
6945  // Não preencher se destino está travado
6946  if (window._userDestination) return;
6947  if (type === 'dest') {
6948    const parts = val.split(', '); // "Cidade, UF/Estado, País"
6949    const pais = parts[2] || '';
6950    const paisEl = document.getElementById('ob-pais');
6951    if (pais && paisEl) paisEl.value = pais;
6952    // UF só faz sentido maiúscula pro Brasil (sigla) — estado/província estrangeira fica como digitada
6953    const estado = isBrasilCountry(pais) ? (parts[1] || '').toUpperCase() : (parts[1] || '');
6954    // obSelectLocation decide o modo (select IBGE x input livre) e preenche (e valida)
6955    obSelectLocation('dest', estado, parts[0] || '', pais);
6956  } else {
6957    document.getElementById('ob-empresa').value = val;
6958    obValidateStep2();
6959  }
6960}
6961
6962function obSaveRecent() {
6963  const isDestino = OB.sector === 'destination';
6964  const key = isDestino ? 'ob_recent_dests' : 'ob_recent_empresas';
6965  const val = isDestino ? OB.dest : OB.dest;
6966  if (!val) return;
6967  try {
6968    const saved = JSON.parse(localStorage.getItem(key) || '[]');
6969    const updated = [val, ...saved.filter(x => x !== val)].slice(0, 8);
6970    localStorage.setItem(key, JSON.stringify(updated));
6971  } catch(e) {}
6972}
6973
6974// ===== AUTO-SAVE ALL ONBOARDING FIELDS =====
6975const OB_FIELDS_DEST = [
6976  'ob-pais','ob-estado','ob-cidade','ob-dest-ig','ob-dest-site','ob-foco',
6977  'ob-dest-conc1','ob-dest-conc1-tipo','ob-dest-conc1-ig','ob-dest-conc1-site','ob-dest-conc1-estado','ob-dest-conc1-cidade',
6978  'ob-dest-conc2','ob-dest-conc2-tipo','ob-dest-conc2-ig','ob-dest-conc2-site','ob-dest-conc2-estado','ob-dest-conc2-cidade',
6979  'ob-dest-conc3','ob-dest-conc3-tipo','ob-dest-conc3-ig','ob-dest-conc3-site','ob-dest-conc3-estado','ob-dest-conc3-cidade',
6980];
6981const OB_FIELDS_EMP = [
6982  'ob-empresa','ob-empresa-ig','ob-empresa-site','ob-empresa-pais','ob-empresa-estado','ob-empresa-cidade','ob-foco-emp',
6983  'ob-conc1','ob-conc1-tipo','ob-conc1-ig','ob-conc1-site','ob-conc1-estado','ob-conc1-cidade',
6984  'ob-conc2','ob-conc2-tipo','ob-conc2-ig','ob-conc2-site','ob-conc2-estado','ob-conc2-cidade',
6985  'ob-conc3','ob-conc3-tipo','ob-conc3-ig','ob-conc3-site','ob-conc3-estado','ob-conc3-cidade',
6986];
6987
6988function obAutoSave() {
6989  try {
6990    const data = {};
6991    [...OB_FIELDS_DEST, ...OB_FIELDS_EMP].forEach(id => {
6992      const el = document.getElementById(id);
6993      if (el) data[id] = el.value || '';
6994    });
6995    // Save sector, lang, provider, period
6996    // (_markets saiu em 2026-07-27 junto com o campo de mercados emissores — ver
6997    //  obAutoRestore, que ainda limpa a chave antiga de quem já a tem gravada.)
6998    data._sector = OB.sector || '';
6999    data._lang = OB.lang || 'pt';
7000    data._provider = OB.provider || 'openai';
7001    data._period = OB.period || '30';
7002    localStorage.setItem('cp_ob_autosave', JSON.stringify(data));
7003  } catch(e) {}
7004}
7005
7006function obAutoRestore() {
7007  try {
7008    const raw = localStorage.getItem('cp_ob_autosave');
7009    if (!raw) return;
7010    const data = JSON.parse(raw);
7011
7012    // MIGRAÇÃO (2026-07-27): mercados emissores saíram do onboarding. Quem já tinha o
7013    // autosave gravado carregava mercados de análises antigas para toda análise nova
7014    // (via OB.markets → G.markets → _meta_emitterMarkets), e o módulo então achava que
7015    // já havia mercados cadastrados. Limpa a chave morta de vez.
7016    if ('_markets' in data) {
7017      delete data._markets;
7018      try { localStorage.setItem('cp_ob_autosave', JSON.stringify(data)); } catch(_e) {}
7019    }
7020    OB.markets = [];
7021
7022    // Restore sector first (affects which form is shown)
7023    if (data._sector) OB.sector = data._sector;
7024    if (data._lang) OB.lang = data._lang;
7025    if (data._provider) OB.provider = data._provider;
7026    if (data._period) OB.period = data._period;
7027
7028    // Restore field values after a tick (DOM needs to be ready)
7029    setTimeout(() => {
7030      [...OB_FIELDS_DEST, ...OB_FIELDS_EMP].forEach(id => {
7031        if (OB_LOC_IDS.includes(id)) return;   // localização (UF/cidade): restaurada async abaixo
7032        const el = document.getElementById(id);
7033        // Não sobrescrever campos que estão disabled (locked)
7034        if (el && data[id] && !el.disabled) el.value = data[id];
7035      });
7036      // Restaurar selects de localização (carga async do banco).
7037      // Destino/empresa só quando NÃO travado (o lock já preencheu); concorrentes sempre.
7038      // O modo (select IBGE x input livre) é decidido pelo país salvo ANTES de preencher.
7039      (async () => {
7040        await obPopulateEstados();
7041        if (!window._userDestination) {
7042          if (data['ob-estado']) {
7043            obSetLocationMode('ob-estado', 'ob-cidade', isBrasilCountry(data['ob-pais']) ? 'br' : 'free');
7044            await obSelectCityInto('ob-estado', 'ob-cidade', data['ob-estado'], data['ob-cidade']);
7045          }
7046          if (data['ob-empresa-estado']) {
7047            obSetLocationMode('ob-empresa-estado', 'ob-empresa-cidade', isBrasilCountry(data['ob-empresa-pais']) ? 'br' : 'free');
7048            await obSelectCityInto('ob-empresa-estado', 'ob-empresa-cidade', data['ob-empresa-estado'], data['ob-empresa-cidade']);
7049          }
7050        }
7051        for (const p of ['ob-dest-conc1','ob-dest-conc2','ob-dest-conc3','ob-conc1','ob-conc2','ob-conc3']) {
7052          if (data[p+'-estado']) {
7053            const pais = p.startsWith('ob-dest-conc') ? data['ob-pais'] : data['ob-empresa-pais'];
7054            obSetLocationMode(p+'-estado', p+'-cidade', isBrasilCountry(pais) ? 'br' : 'free');
7055            await obSelectCityInto(p+'-estado', p+'-cidade', data[p+'-estado'], data[p+'-cidade'], ob_t('ob_opt_city_ph'));
7056          }
7057        }
7058        obValidateStep2();
7059      })();
7060      // Restore period selection
7061      if (data._period) {
7062        document.querySelectorAll('.ob-period-btn').forEach(btn => {
7063          btn.classList.remove('sel');
7064          if (btn.textContent.includes(data._period === '7' ? '7' : data._period === '15' ? '15' : data._period === '30' ? '30' : data._period === '90' ? '3 meses' : '6 meses')) {
7065            btn.classList.add('sel');
7066          }
7067        });
7068      }
7069      // (markets não são mais restaurados aqui — cadastro saiu do onboarding em 2026-07-27)
7070      // Restore visual selections in Step 1
7071      if (data._lang) {
7072        document.querySelectorAll('#ob-step1 [data-lang]').forEach(el => {
7073          el.classList.remove('sel');
7074          if (el.dataset.lang === data._lang) el.classList.add('sel');
7075        });
7076      }
7077      if (data._sector) {
7078        document.querySelectorAll('#ob-step1 [data-sector]').forEach(el => {
7079          el.classList.remove('selected');
7080          if (el.dataset.sector === data._sector) el.classList.add('selected');
7081        });
7082      }
7083      if (data._provider) {
7084        document.querySelectorAll('#ob-step4 [data-provider]').forEach(el => {
7085          el.classList.remove('sel');
7086          if (el.dataset.provider === data._provider) el.classList.add('sel');
7087        });
7088      }
7089      // Validate
7090      obValidateStep2();
7091      console.log('[AutoRestore] ✅ Restored onboarding fields');
7092    }, 200);
7093  } catch(e) { console.log('[AutoRestore] Error:', e.message); }
7094}
7095
7096// Attach auto-save to all fields on input/change
7097function obInitAutoSave() {
7098  [...OB_FIELDS_DEST, ...OB_FIELDS_EMP].forEach(id => {
7099    const el = document.getElementById(id);
7100    if (el) {
7101      el.addEventListener('input', function() { obAutoSave(); obValidateStep2(); });
7102      el.addEventListener('change', function() { obAutoSave(); obValidateStep2(); });
7103    }
7104  });
7105  console.log('[AutoSave] ✅ Attached to', OB_FIELDS_DEST.length + OB_FIELDS_EMP.length, 'fields');
7106}
7107
7108// Initialize on page load
7109document.addEventListener('DOMContentLoaded', () => {
7110  // ===== AUTH GATE =====
7111  // Lê is_admin do claim DENTRO do JWT (access_token). O claim do hook NÃO
7112  // aparece em session.user.app_metadata — só decodificando o token.
7113  function _cpClaimIsAdmin(session) {
7114    try {
7115      var part = (session && session.access_token || '').split('.')[1];
7116      if (!part) return false;
7117      var b64 = part.replace(/-/g, '+').replace(/_/g, '/');
7118      while (b64.length % 4) b64 += '=';
7119      var json = decodeURIComponent(Array.prototype.map.call(atob(b64), function (c) {
7120        return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
7121      }).join(''));
7122      var claims = JSON.parse(json);
7123      return !!(claims && claims.app_metadata && claims.app_metadata.is_admin === true);
7124    } catch (e) { return false; }
7125  }
7126
7127  // Esconder todo conteúdo e mostrar loading enquanto verifica autenticação
7128  document.body.classList.add('loading-screen');
7129  document.documentElement.style.visibility = 'hidden';
7130
7131  // Criar overlay de verificação de acesso
7132  var authOverlay = document.createElement('div');
7133  authOverlay.id = 'auth-check-overlay';
7134  authOverlay.style.cssText = 'position:fixed;inset:0;background:#07070B;display:flex;flex-direction:column;align-items:center;justify-content:center;z-index:99999;';
7135  authOverlay.innerHTML = '<div style="width:40px;height:40px;border:3px solid rgba(34,211,238,0.2);border-top-color:var(--cyan);border-radius:50%;animation:spin .8s linear infinite;"></div>' +
7136    '<p style="margin-top:16px;color:#A0A0B8;font-size:14px;font-family:DM Sans,sans-serif;">Verificando acesso...</p>' +
7137    '<style>@keyframes spin{to{transform:rotate(360deg)}}</style>';
7138  document.body.appendChild(authOverlay);
7139  document.documentElement.style.visibility = 'visible';
7140
7141  (async function authGate() {
7142    try {
7143      var supabase = await getSupabase();
7144      var result = await supabase.auth.getSession();
7145      var session = result && result.data && result.data.session;
7146
7147      if (!session) {
7148        // Não logado → redirecionar para login
7149        window.location.href = '/auth?view=login&return=' + encodeURIComponent(window.location.pathname);
7150        return;
7151      }
7152
7153      // GUARD: conta administrativa não usa o portal (sessões separadas).
7154      // Fonte de verdade: claim app_metadata.is_admin DENTRO do JWT (não forjável).
7155      if (_cpClaimIsAdmin(session)) {
7156        console.warn('⛔ Sessão admin detectada no portal — encerrando');
7157        await supabase.auth.signOut({ scope: 'local' });
7158        window.location.href = '/auth?reason=admin-account';
7159        return;
7160      }
7161
7162      // Verificar plano no banco (is_admin já tratado acima via claim)
7163      var profileResult = await supabase.from('profiles')
7164        .select('plan_type, is_bypass')
7165        .eq('id', session.user.id)
7166        .single();
7167
7168      var plan = (profileResult.data && profileResult.data.plan_type) || 'free';
7169      var validPlans = ['basic', 'premium', 'oracle', 'enterprise'];
7170
7171      if (!validPlans.includes(plan)) {
7172        // Sem plano válido → redirecionar para página de planos
7173        window.location.href = '/auth';
7174        return;
7175      }
7176
7177      // Atualizar localStorage com dados do servidor (sem cp_is_admin — fonte é o JWT)
7178      localStorage.setItem('cp_plan_type', plan);
7179      localStorage.setItem('cp_user_plan', plan);
7180      localStorage.setItem('cp_user_id', session.user.id);
7181      localStorage.setItem('cp_user_email', session.user.email);
7182      if (profileResult.data) {
7183        localStorage.setItem('cp_is_bypass', String(!!profileResult.data.is_bypass));
7184      }
7185
7186      // ===== CONTROLE POR USUÁRIO VIA CSS (temporário) =====
7187      var _ue = (session.user.email || '').toLowerCase();
7188      if (_ue === '[email protected]' && !document.getElementById('_user-override-css')) {
7189        var _s = document.createElement('style');
7190        _s.id = '_user-override-css';
7191        _s.textContent =
7192          '#nav-label-analysis,#nav-label-premium,#nav-label-oracle,#nav-admin,' +
7193          '[data-page="input"],#nav-dashboard,#nav-crisis,#nav-profile,#nav-benchmark,#nav-history,' +
7194          '#nav-heatmap,#nav-influence,#nav-viral,#nav-simulator,#nav-social,#nav-campaign,' +
7195          '#nav-emissores,#nav-felicidade,#nav-gemeo,#nav-can,#nav-oracle-main,#nav-sinc,' +
7196          '#nav-publicmax,#nav-projecao,#nav-calendario,' +
7197          '#btn-docs,#btn-export,#btn-report { display:none!important }' +
7198          '\n#nav-compass { display:flex!important }' +
7199          '\n#nav-label-compass { display:block!important }';
7200        document.head.appendChild(_s);
7201      }
7202      if (_ue === '[email protected]' && !document.getElementById('_user-override-css')) {
7203        var _s = document.createElement('style');
7204        _s.id = '_user-override-css';
7205        _s.textContent = '#nav-compass,#nav-label-compass { display:none!important }';
7206        document.head.appendChild(_s);
7207      }
7208      // ===== FIM CONTROLE POR USUÁRIO VIA CSS =====
7209
7210      // Auth OK — remover overlay e continuar boot normal
7211      var ov = document.getElementById('auth-check-overlay');
7212      if (ov) ov.remove();
7213
7214      _bootApp();
7215
7216    } catch (e) {
7217      console.error('[AuthGate] Erro ao verificar acesso:', e);
7218      // Em caso de erro de rede, tentar continuar se tem dados locais
7219      var cachedPlan = localStorage.getItem('cp_plan_type');
7220      if (cachedPlan && ['basic', 'premium', 'oracle', 'enterprise'].includes(cachedPlan)) {
7221        var ov = document.getElementById('auth-check-overlay');
7222        if (ov) ov.remove();
7223        _bootApp();
7224      } else {
7225        window.location.href = '/auth?view=login';
7226      }
7227    }
7228  })();
7229});
7230
7231function _bootApp() {
7232  // Aplicar controle de menus por plano e por usuário imediatamente no boot
7233  enforceMenuByPlan();
7234
7235  // Mostrar loading de análise enquanto decide o fluxo
7236  // Chamar goPage diretamente sem delegar ao router (loading não é uma rota)
7237  window._routerNavigating = true;
7238  goPage('loading');
7239  window._routerNavigating = false;
7240
7241  setTimeout(async () => {
7242    // Verificar se plano expirou antes de qualquer coisa
7243    await checkExpiredPlan();
7244
7245    // Skip: usuarios compass-only (user_features apenas com compass habilitado) vao direto pro painel.
7246    // Configuracao gerenciada pelo admin via Controle de Features > Por Usuario.
7247    try {
7248      if (window.AppRouter && typeof window.AppRouter.getAuthState === 'function') {
7249        await window.AppRouter.getAuthState(); // popula _routerAuthCache.modules via RPC
7250      }
7251      if (_isCompassOnlyAccess()) {
7252        if (window.AppRouter) {
7253          AppRouter.navigateTo('/app/compass', { replace: true });
7254        } else {
7255          window.location.href = '/app/compass';
7256        }
7257        return;
7258      }
7259    } catch (e) { console.warn('[Boot] check compass-only falhou:', e); }
7260
7261    // Se veio com ?onboarding=1, forçar onboarding
7262    const urlParams = new URLSearchParams(window.location.search);
7263    if (urlParams.get('onboarding') === '1') {
7264      window.history.replaceState({}, '', '/app');
7265      sessionStorage.removeItem('cp_active_analysis');
7266
7267      // Carregar dados do usuário ANTES de decidir o step
7268      await obUpdateUserMenu();
7269
7270      // Restaurar sector do destino salvo
7271      if (window._userDestination && window._userDestination.sector) {
7272        OB.sector = window._userDestination.sector;
7273      }
7274
7275      // Usar router para navegar ao step correto
7276      if (window.AppRouter) {
7277        AppRouter.navigateTo('/app/onboarding', { replace: true });
7278      } else {
7279        // Fallback sem router
7280        document.body.classList.remove('loading-screen');
7281        document.body.classList.add('onboarding');
7282        if (window._userDestination && !_isBypassUser()) {
7283          document.getElementById('ob-step2').classList.add('active');
7284        } else {
7285          document.getElementById('ob-step1').classList.add('active');
7286        }
7287      }
7288
7289      obAutoRestore();
7290      obInitAutoSave();
7291      return;
7292    }
7293
7294    // Verificar se há análise ativa no sessionStorage para restaurar
7295    // Se a URL é /app/dashboard (deep link) e não tem análise ativa, buscar a mais recente
7296    let activeId = sessionStorage.getItem('cp_active_analysis');
7297    if (!activeId && window.location.pathname.startsWith('/app/') && !window.location.pathname.startsWith('/app/onboarding')) {
7298      try {
7299        const supabase = await getSupabase();
7300        const { data: { session } } = await supabase.auth.getSession();
7301        if (session) {
7302          const { data: latest } = await supabase
7303            .from('analysis_history')
7304            .select('id')
7305            .eq('user_id', session.user.id)
7306            .order('created_at', { ascending: false })
7307            .limit(1)
7308            .maybeSingle();
7309          if (latest && latest.id) {
7310            activeId = latest.id;
7311            sessionStorage.setItem('cp_active_analysis', activeId);
7312            console.log('[Boot] Análise mais recente encontrada para deep link:', activeId);
7313          }
7314        }
7315      } catch(e) { console.warn('[Boot] Erro ao buscar análise para deep link:', e); }
7316    }
7317
7318    if (activeId) {
7319      try {
7320        const supabase = await getSupabase();
7321
7322        // Aguardar sessão restaurar (até 3s)
7323        let session = null;
7324        for (let i = 0; i < 6; i++) {
7325          const { data } = await supabase.auth.getSession();
7326          if (data?.session) { session = data.session; break; }
7327          await new Promise(r => setTimeout(r, 500));
7328        }
7329
7330        if (session) {
7331          const { data: row, error } = await supabase
7332            .from('analysis_history')
7333            .select('*')
7334            .eq('id', activeId)
7335            .single();
7336          if (!error && row) {
7337            console.log('🔄 Restaurando análise:', row.destination);
7338            document.body.classList.remove('loading-screen');
7339            loadRecord({
7340              dest: row.destination,
7341              sector: row.sector,
7342              lang: row.lang,
7343              data: row.analysis_data,
7344              concs: row.competitors || [],
7345              periodo: row.period || '30'
7346            });
7347            return;
7348          }
7349        }
7350        // Sessão não restaurou ou análise não encontrada — fallback onboarding
7351        console.warn('Sessão ou análise não encontrada, mostrando onboarding');
7352      } catch(e) {
7353        console.warn('Erro ao restaurar análise:', e);
7354        if (G.data) {
7355          document.querySelectorAll('.ob-page').forEach(p => p.classList.remove('active'));
7356          document.body.classList.remove('onboarding');
7357          document.body.classList.remove('loading-screen');
7358          // Respeitar URL atual (fix: refresh mantém rota)
7359          var _curPath = window.location.pathname;
7360          var _targetPage = 'dashboard';
7361          if (_curPath.startsWith('/app/') && _curPath !== '/app/onboarding') {
7362            var _seg = _curPath.replace('/app/', '');
7363            if (document.getElementById('page-' + _seg)) _targetPage = _seg;
7364          }
7365          goPage(_targetPage);
7366          return;
7367        }
7368      }
7369    }
7370
7371    // Sem análise ativa — verificar se usuário tem destino salvo
7372    // Carregar dados do usuário (inclui _userDestination) ANTES de decidir o step
7373    await obUpdateUserMenu();
7374
7375    // Verificar se já tem análises no banco para decidir step
7376    let userHasAnalyses = false;
7377    try {
7378      const supabase = await getSupabase();
7379      const { data: { session } } = await supabase.auth.getSession();
7380      if (session) {
7381        const { data: anyAnalysis } = await supabase
7382          .from('analysis_history')
7383          .select('id')
7384          .eq('user_id', session.user.id)
7385          .limit(1)
7386          .maybeSingle();
7387        userHasAnalyses = !!(anyAnalysis && anyAnalysis.id);
7388      }
7389    } catch(e) { console.warn('Erro ao verificar análises:', e); }
7390
7391    // Restaurar sector do destino salvo
7392    if (window._userDestination && window._userDestination.sector) {
7393      OB.sector = window._userDestination.sector;
7394    }
7395
7396    // Usar router para navegar ao step correto
7397    if (window.AppRouter) {
7398      AppRouter.navigateTo('/app/onboarding', { replace: true });
7399    } else {
7400      // Fallback sem router
7401      document.body.classList.remove('loading-screen');
7402      document.body.classList.add('onboarding');
7403      if (userHasAnalyses && window._userDestination && !_isBypassUser()) {
7404        document.getElementById('ob-step2').classList.add('active');
7405      } else {
7406        document.getElementById('ob-step1').classList.add('active');
7407      }
7408    }
7409
7410    obAutoRestore();
7411    obInitAutoSave();
7412  }, 300);
7413}
7414
7415function obGoStep3() {
7416  // Collect step 2 data
7417  if (OB.sector === 'destination') {
7418    const pais = document.getElementById('ob-pais')?.value.trim() || '';
7419    const estado = document.getElementById('ob-estado')?.value.trim() || '';
7420    const cidade = document.getElementById('ob-cidade')?.value.trim() || '';
7421    if (!cidade) return;
7422    OB.dest = _deduplicateDest([cidade, estado, pais].filter(Boolean).join(', '));
7423    OB.empresaIG = document.getElementById('ob-dest-ig')?.value.trim() || '';
7424    OB.empresaSite = document.getElementById('ob-dest-site')?.value.trim() || '';
7425    OB.concsData = [1,2,3].map(i => {
7426      const estado = document.getElementById('ob-dest-conc'+i+'-estado')?.value || '';
7427      const cidade = document.getElementById('ob-dest-conc'+i+'-cidade')?.value || '';
7428      const nome = cidade;  // o concorrente é a cidade selecionada (não há mais input de nome)
7429      const local = [cidade, estado].filter(Boolean).join(', ');
7430      return {
7431        nome,
7432        tipo: document.getElementById('ob-dest-conc'+i+'-tipo')?.value || 'direto',
7433        ig: document.getElementById('ob-dest-conc'+i+'-ig')?.value.trim() || '',
7434        site: document.getElementById('ob-dest-conc'+i+'-site')?.value.trim() || '',
7435        estado, cidade,
7436        nomeCompleto: local || nome,
7437      };
7438    }).filter(c => c.cidade);
7439    // Nome + localização vai para o prompt e as buscas (G.concs = array de strings)
7440    OB.concs = OB.concsData.map(c => c.nomeCompleto);
7441    OB.foco = document.getElementById('ob-foco')?.value || 'geral';
7442  } else {
7443    const empresa = document.getElementById('ob-empresa')?.value.trim() || '';
7444    const pais = document.getElementById('ob-empresa-pais')?.value.trim() || '';
7445    const estado = document.getElementById('ob-empresa-estado')?.value.trim() || '';
7446    const cidade = document.getElementById('ob-empresa-cidade')?.value.trim() || '';
7447    if (!empresa) return;
7448    const location = [cidade, estado, pais].filter(Boolean).join(', ');
7449    OB.dest = location ? `${empresa}, ${location}` : empresa;
7450    OB.empresaIG = document.getElementById('ob-empresa-ig')?.value.trim() || '';
7451    OB.empresaSite = document.getElementById('ob-empresa-site')?.value.trim() || '';
7452    OB.concsData = [1,2,3].map(i => {
7453      const estado = document.getElementById('ob-conc'+i+'-estado')?.value || '';
7454      const cidade = document.getElementById('ob-conc'+i+'-cidade')?.value || '';
7455      const nome = cidade;  // o concorrente é a cidade selecionada (não há mais input de nome)
7456      const local = [cidade, estado].filter(Boolean).join(', ');
7457      return {
7458        nome,
7459        tipo: document.getElementById('ob-conc'+i+'-tipo')?.value || 'direto',
7460        ig: document.getElementById('ob-conc'+i+'-ig')?.value.trim() || '',
7461        site: document.getElementById('ob-conc'+i+'-site')?.value.trim() || '',
7462        estado, cidade,
7463        nomeCompleto: local || nome,
7464      };
7465    }).filter(c => c.cidade);
7466    OB.concs = OB.concsData.map(c => c.nomeCompleto);
7467    OB.foco = document.getElementById('ob-foco-emp')?.value || 'geral';
7468  }
7469
7470  // ── Guarda-corpo: trocou o site/Instagram mas manteve a cidade? ──
7471  // Bug real (jul/2026): o onboarding pré-preenche a análise anterior (obAutoRestore).
7472  // O usuário troca o site/Instagram pra outra cidade mas esquece de trocar o DESTINO;
7473  // a análise — e o SEO/Presença Digital, que é titulado/chaveado por G.dest — roda os
7474  // ativos novos SOB a cidade antiga (ex.: ativos de Itacaré rotulados como "Ilhéus").
7475  // Comparamos com o snapshot da última análise iniciada (gravado em obStartAnalysis).
7476  try {
7477    const _prevRaw = localStorage.getItem('cp_last_analysis_meta');
7478    if (_prevRaw) {
7479      const _prev = JSON.parse(_prevRaw);
7480      const _siteChanged = (OB.empresaSite || '') !== (_prev.site || '');
7481      const _igChanged   = (OB.empresaIG   || '') !== (_prev.ig   || '');
7482      const _destSame    = (OB.dest || '') === (_prev.dest || '');
7483      if (_destSame && (_siteChanged || _igChanged) && (OB.empresaSite || OB.empresaIG)) {
7484        const _city = OB.dest || _prev.dest || 'a cidade atual';
7485        const _ok = confirm(
7486          'Você alterou o site e/ou Instagram, mas manteve o destino em "' + _city + '".\n\n' +
7487          'A análise de SEO / Presença Digital é identificada pela cidade — se a intenção era ' +
7488          'analisar OUTRA cidade, cancele e troque o destino antes de continuar.\n\n' +
7489          'Continuar mesmo assim com "' + _city + '"?'
7490        );
7491        if (!_ok) return;  // fica na etapa pra corrigir a cidade
7492      }
7493    }
7494  } catch (_e) { /* localStorage indisponível — segue sem o guarda-corpo */ }
7495
7496  // Primeira análise: mostrar modal de confirmação antes de avançar
7497  if (!window._userDestination) {
7498    showDestinationLockModal(OB.dest);
7499    return;
7500  }
7501
7502  // Bypass: mostrar modal pra confirmar troca de destino
7503  if (_isBypassUser()) {
7504    showDestinationLockModal(OB.dest);
7505    return;
7506  }
7507
7508  // Já tem destino salvo: avançar direto
7509  _proceedToStep3();
7510}
7511
7512function _proceedToStep3() {
7513  if (window.AppRouter) {
7514    AppRouter.navigateTo('/app/onboarding/3');
7515  } else {
7516    document.querySelectorAll('.ob-page').forEach(p => p.classList.remove('active'));
7517    document.getElementById('ob-step3').classList.add('active');
7518  }
7519  // Reset file list display
7520  obRenderFileList();
7521}
7522
7523// ===== MODAL DE CONFIRMAÇÃO DE DESTINO =====
7524function showDestinationLockModal(dest) {
7525  document.getElementById('destination-lock-modal-name').textContent = dest;
7526  const modal = document.getElementById('destination-lock-modal');
7527  modal.style.display = 'flex';
7528}
7529
7530function closeDestinationLockModal() {
7531  document.getElementById('destination-lock-modal').style.display = 'none';
7532}
7533
7534async function confirmDestinationLock() {
7535  closeDestinationLockModal();
7536  // Salvar destino no banco
7537  try {
7538    const supabase = await getSupabase();
7539    const { data: { session } } = await supabase.auth.getSession();
7540    if (session) {
7541      const isDestino = OB.sector === 'destination';
7542      var _rawCity = isDestino ? (document.getElementById('ob-cidade')?.value.trim() || '') : (document.getElementById('ob-empresa-cidade')?.value.trim() || '');
7543      var _rawState = isDestino ? (document.getElementById('ob-estado')?.value.trim() || '') : (document.getElementById('ob-empresa-estado')?.value.trim() || '');
7544      var _rawCountry = isDestino ? (document.getElementById('ob-pais')?.value.trim() || '') : (document.getElementById('ob-empresa-pais')?.value.trim() || '');
7545      // Extrair apenas o nome da cidade se o campo contém estado/país (ex: "Bom Jardim da Serra, SC, Brasil")
7546      if (_rawCity.includes(', ')) {
7547        var _cityParts = _rawCity.split(', ');
7548        _rawCity = _cityParts[0];
7549      }
7550      const destData = {
7551        user_id: session.user.id,
7552        sector: OB.sector || 'destination',
7553        country: _rawCountry,
7554        state: _rawState,
7555        city: _rawCity,
7556      };
7557      const { error } = await supabase.from('user_destinations').upsert(destData, { onConflict: 'user_id' });
7558      if (error) {
7559        console.warn('⚠️ Upsert falhou, tentando update direto:', error.message);
7560        // Fallback: se upsert falhou (ex: RLS), tenta update direto
7561        const { error: updateErr } = await supabase
7562          .from('user_destinations')
7563          .update({ sector: destData.sector, country: destData.country, state: destData.state, city: destData.city })
7564          .eq('user_id', session.user.id);
7565        if (updateErr) console.error('Erro ao atualizar destino:', updateErr);
7566        else console.log('✅ Destino atualizado via update:', destData);
7567      }
7568      else {
7569        window._userDestination = { country: destData.country, state: destData.state, city: destData.city, sector: destData.sector };
7570        console.log('✅ Destino salvo:', destData);
7571      }
7572    }
7573  } catch(e) {
7574    console.error('Erro ao salvar destino:', e);
7575  }
7576  _proceedToStep3();
7577}
7578
7579// ===== LOCK DE DESTINO A PARTIR DO BANCO =====
7580async function enforceDestinationLockFromDB() {
7581  const dest = window._userDestination;
7582  const scope = (OB.sector === 'destination') ? 'dest' : 'emp';
7583  const paisId = scope === 'dest' ? 'ob-pais' : 'ob-empresa-pais';
7584  const estId  = scope === 'dest' ? 'ob-estado' : 'ob-empresa-estado';
7585  const cidId  = scope === 'dest' ? 'ob-cidade' : 'ob-empresa-cidade';
7586
7587  if (!dest) {
7588    // Primeira análise — desbloquear campos e carregar os estados
7589    unlockDestinationFields();
7590    await obPopulateEstados();
7591    return;
7592  }
7593
7594  // Bypass: pode reabrir/trocar um destino já travado, mas nunca fica com os campos
7595  // desabilitados (testa livremente) — reaproveita o país/estado/cidade salvos como
7596  // ponto de partida, editáveis.
7597  if (_isBypassUser()) {
7598    unlockDestinationFields();
7599    const pais = dest.country || 'Brasil';
7600    const paisEl = document.getElementById(paisId);
7601    if (paisEl) paisEl.value = pais;
7602    _obApplyConcLocationMode(scope, pais);
7603    await obSelectLocation(scope, dest.state || '', dest.city || '', pais);
7604    return;
7605  }
7606
7607  const paisEl = document.getElementById(paisId);
7608  if (paisEl) paisEl.value = dest.country || 'Brasil';
7609  // Concorrentes seguem o país do destino/empresa salvo (mesma regra de obOnPaisChange) —
7610  // aqui o país é preenchido via JS (não digitado), então o oninput não dispara sozinho.
7611  _obApplyConcLocationMode(scope, dest.country || 'Brasil');
7612
7613  // Locked: seleciona destino salvo e trava os campos (obSelectLocation injeta
7614  // option avulsa se a cidade não estiver na lista → o lock nunca fica vazio)
7615  await obSelectLocation(scope, dest.state || '', dest.city || '', dest.country || 'Brasil');
7616  [paisEl, document.getElementById(estId), document.getElementById(cidId)].forEach(el => {
7617    if (el) { el.disabled = true; el.style.opacity = '0.6'; el.style.cursor = 'not-allowed'; }
7618  });
7619
7620  // Mostrar mensagem de destino travado
7621  const lockMsg = document.getElementById('ob-lock-msg');
7622  if (lockMsg) lockMsg.style.display = '';
7623}
7624
7625// ===== DESTINATION LOCK SYSTEM =====
7626// País, estado e cidade ficam livres pro usuário digitar (qualquer país, não só os 4 do
7627// idioma). isBrasilCountry(pais) decide o modo de estado/cidade: Brasil usa os selects do
7628// IBGE, qualquer outro texto vira input livre (ver obOnPaisChange/obSetLocationMode).
7629function unlockDestinationFields() {
7630  ['ob-pais','ob-estado','ob-cidade','ob-empresa-pais','ob-empresa-estado','ob-empresa-cidade'].forEach(id => {
7631    const el = document.getElementById(id);
7632    if (el) { el.disabled = false; el.style.opacity = '1'; el.style.cursor = ''; }
7633  });
7634  // Garante que os selects de UF estejam populados quando os campos ficam editáveis
7635  obPopulateEstados();
7636}
7637
7638// ===== SELECTS ENCADEADOS ESTADO -> CIDADE (dados do IBGE no banco) =====
7639// Escapa texto para uso seguro em atributo/conteúdo de <option> (acentos ficam literais).
7640function _obEsc(s) {
7641  return String(s == null ? '' : s)
7642    .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
7643}
7644
7645// Todos os <select> de UF (destino, empresa e 6 concorrentes)
7646const OB_ESTADO_SELECT_IDS = [
7647  'ob-estado', 'ob-empresa-estado',
7648  'ob-dest-conc1-estado', 'ob-dest-conc2-estado', 'ob-dest-conc3-estado',
7649  'ob-conc1-estado', 'ob-conc2-estado', 'ob-conc3-estado',
7650];
7651// Todos os <select> de localização (UF + cidade) — restaurados de forma async (não no loop genérico)
7652const OB_LOC_IDS = [
7653  'ob-estado', 'ob-cidade', 'ob-empresa-estado', 'ob-empresa-cidade',
7654  'ob-dest-conc1-estado', 'ob-dest-conc1-cidade', 'ob-dest-conc2-estado', 'ob-dest-conc2-cidade', 'ob-dest-conc3-estado', 'ob-dest-conc3-cidade',
7655  'ob-conc1-estado', 'ob-conc1-cidade', 'ob-conc2-estado', 'ob-conc2-cidade', 'ob-conc3-estado', 'ob-conc3-cidade',
7656];
7657
7658// Popula todos os selects de UF (idempotente via dataset.filled; loadEstados tem cache)
7659async function obPopulateEstados() {
7660  const ests = await loadEstados();
7661  if (!ests || !ests.length) return;
7662  const opts = ests.map(e => `<option value="${e.sigla}" title="${_obEsc(e.nome)}">${e.sigla}</option>`).join('');
7663  OB_ESTADO_SELECT_IDS.forEach(id => {
7664    const sel = document.getElementById(id);
7665    if (!sel || sel.tagName !== 'SELECT' || sel.dataset.filled) return;
7666    sel.insertAdjacentHTML('beforeend', opts);
7667    sel.dataset.filled = '1';
7668  });
7669}
7670
7671// Preenche um <select> de cidade com os municípios da UF
7672async function obFillCitySelect(citySel, uf, placeholder) {
7673  if (!citySel || citySel.tagName !== 'SELECT') return;
7674  const ph = placeholder || ob_t('ob_opt_select_city_ph');
7675  if (!uf) { citySel.innerHTML = `<option value="">${ph}</option>`; return; }
7676  citySel.innerHTML = `<option value="">${ob_t('ob_loading')}</option>`;
7677  const cids = await loadCidades(uf);
7678  citySel.innerHTML = `<option value="">${ph}</option>` +
7679    cids.map(c => `<option value="${_obEsc(c.nome)}">${_obEsc(c.nome)}</option>`).join('');
7680}
7681
7682// Núcleo do timing: seleciona UF, AGUARDA municípios, depois seleciona a cidade.
7683// Injeta option avulsa quando o valor salvo não está na lista (robustez p/ lock e dados legados).
7684// Modo livre (país != Brasil, ver obSetLocationMode): estSel/citySel já são <input> —
7685// aqui só atribui o texto puro, sem tocar nas tabelas estados/municipios (Brasil-only).
7686async function obSelectCityInto(estSelId, citySelId, uf, cidade, placeholder) {
7687  const estSel = document.getElementById(estSelId);
7688  const citySel = document.getElementById(citySelId);
7689  if (estSel && estSel.tagName !== 'SELECT') {
7690    if (uf != null) estSel.value = uf;
7691    if (citySel && cidade != null) citySel.value = cidade;
7692    return;
7693  }
7694  await obPopulateEstados();
7695  if (estSel && uf) {
7696    estSel.value = uf;
7697    if (estSel.value !== uf) { // UF salva como texto que não bate com sigla (legado)
7698      estSel.insertAdjacentHTML('beforeend', `<option value="${_obEsc(uf)}">${_obEsc(uf)}</option>`);
7699      estSel.value = uf;
7700    }
7701  }
7702  await obFillCitySelect(citySel, uf, placeholder);
7703  if (citySel && cidade) {
7704    if (![...citySel.options].some(o => o.value === cidade)) {
7705      citySel.insertAdjacentHTML('afterbegin', `<option value="${_obEsc(cidade)}">${_obEsc(cidade)}</option>`);
7706    }
7707    citySel.value = cidade;
7708  }
7709}
7710
7711// Reanexa o listener genérico de autosave (obInitAutoSave só roda 1x no load — ao trocar
7712// o nó do campo via obSetLocationMode, o elemento novo perde os listeners do original).
7713function _obAttachAutoSave(el) {
7714  if (!el) return;
7715  el.addEventListener('input', function() { obAutoSave(); obValidateStep2(); });
7716  el.addEventListener('change', function() { obAutoSave(); obValidateStep2(); });
7717}
7718
7719// Troca estado/cidade entre <select> IBGE (Brasil) e <input> livre (qualquer outro país),
7720// preservando o mesmo id — autosave/obGoStep3/confirmDestinationLock só leem .value, então
7721// não precisam saber qual modo está ativo. mode: 'br' | 'free'.
7722function obSetLocationMode(estId, cidId, mode) {
7723  const wantSelect = mode !== 'free';
7724  const isMainCity = (cidId === 'ob-cidade' || cidId === 'ob-empresa-cidade');
7725
7726  const swap = (id, isEstado) => {
7727    const old = document.getElementById(id);
7728    if (!old) return;
7729    if ((old.tagName === 'SELECT') === wantSelect) return; // já está no modo certo
7730
7731    const el = document.createElement(wantSelect ? 'select' : 'input');
7732    el.id = id;
7733    el.className = old.className;
7734    if (old.hasAttribute('style')) el.setAttribute('style', old.getAttribute('style'));
7735    el.disabled = old.disabled;
7736
7737    if (wantSelect) {
7738      el.innerHTML = isEstado
7739        ? `<option value="" data-ob-i18n="ob_opt_uf">${ob_t('ob_opt_uf')}</option>`
7740        : (isMainCity
7741            ? `<option value="" data-ob-i18n="ob_opt_select_state_first">${ob_t('ob_opt_select_state_first')}</option>`
7742            : `<option value="" data-ob-i18n="ob_opt_city_ph">${ob_t('ob_opt_city_ph')}</option>`);
7743      if (isEstado) {
7744        el.addEventListener('change', function() {
7745          if (id === 'ob-estado' || id === 'ob-empresa-estado') obOnEstadoChange(id === 'ob-estado' ? 'dest' : 'emp');
7746          else obOnConcEstadoChange(id.replace(/-estado$/, ''));
7747        });
7748      }
7749    } else {
7750      el.type = 'text';
7751      el.placeholder = isEstado ? ob_t('ob_ph_estado_free') : ob_t('ob_opt_city_ph');
7752      el.autocomplete = 'off';
7753    }
7754    _obAttachAutoSave(el);
7755    old.replaceWith(el);
7756  };
7757
7758  swap(estId, true);
7759  swap(cidId, false);
7760
7761  if (wantSelect) obPopulateEstados();
7762}
7763
7764// Seleciona localização do destino/empresa principal. `pais`, quando informado, decide
7765// o modo (select IBGE x input livre) antes de preencher — omitido = mantém o modo atual.
7766async function obSelectLocation(scope, uf, cidade, pais) {
7767  const estId = scope === 'dest' ? 'ob-estado' : 'ob-empresa-estado';
7768  const cidId = scope === 'dest' ? 'ob-cidade' : 'ob-empresa-cidade';
7769  if (pais !== undefined) obSetLocationMode(estId, cidId, isBrasilCountry(pais) ? 'br' : 'free');
7770  await obSelectCityInto(estId, cidId, uf, cidade);
7771  obValidateStep2();
7772}
7773
7774// onChange do select de UF (destino/empresa) — recarrega cidades
7775async function obOnEstadoChange(scope) {
7776  const estId = scope === 'dest' ? 'ob-estado' : 'ob-empresa-estado';
7777  const cidId = scope === 'dest' ? 'ob-cidade' : 'ob-empresa-cidade';
7778  const estSel = document.getElementById(estId);
7779  await obFillCitySelect(document.getElementById(cidId), estSel && estSel.value);
7780  obValidateStep2();
7781  obAutoSave();
7782}
7783
7784// onChange do select de UF de um concorrente (prefix = 'ob-dest-conc1', 'ob-conc2', ...)
7785async function obOnConcEstadoChange(prefix) {
7786  const estSel = document.getElementById(prefix + '-estado');
7787  await obFillCitySelect(document.getElementById(prefix + '-cidade'), estSel && estSel.value, ob_t('ob_opt_city_ph'));
7788  obAutoSave();
7789}
7790
7791// Aplica o modo (select IBGE x input livre) aos 3 pares de concorrente de um scope,
7792// a partir do país do destino/empresa — concorrente não tem campo de país próprio.
7793function _obApplyConcLocationMode(scope, pais) {
7794  const mode = isBrasilCountry(pais) ? 'br' : 'free';
7795  const prefixes = scope === 'dest' ? ['ob-dest-conc1', 'ob-dest-conc2', 'ob-dest-conc3'] : ['ob-conc1', 'ob-conc2', 'ob-conc3'];
7796  prefixes.forEach(p => obSetLocationMode(p + '-estado', p + '-cidade', mode));
7797}
7798
7799// onInput do país (destino/empresa) — decide se estado/cidade ficam com o select do IBGE
7800// (Brasil) ou viram input livre (qualquer outro país). Concorrentes seguem o país do
7801// destino/empresa (mesma decisão de UX — não há campo de país por concorrente).
7802function obOnPaisChange(scope) {
7803  const isDest = scope === 'dest';
7804  const paisId = isDest ? 'ob-pais' : 'ob-empresa-pais';
7805  const estId  = isDest ? 'ob-estado' : 'ob-empresa-estado';
7806  const cidId  = isDest ? 'ob-cidade' : 'ob-empresa-cidade';
7807  const pais = document.getElementById(paisId)?.value || '';
7808  const mode = isBrasilCountry(pais) ? 'br' : 'free';
7809
7810  obSetLocationMode(estId, cidId, mode);
7811  _obApplyConcLocationMode(scope, pais);
7812
7813  obValidateStep2();
7814  obAutoSave();
7815}
7816
7817// Rules:
7818// - Destination locks after first analysis
7819// - Admin can unlock via admin panel
7820// - Extra destinations available as add-on purchase
7821// - Competitors can change each analysis but limited by plan
7822// Plan limits — carregados do Supabase com fallback hardcoded
7823var PLAN_LIMITS = {
7824  free:       { destinations: 0, competitors: 0, analyses: 0 },
7825  basic:      { destinations: 1, competitors: 1, analyses: 30 },
7826  premium:    { destinations: 1, competitors: 2, analyses: 100 },
7827  oracle:     { destinations: 1, competitors: 3, analyses: 300 },
7828  enterprise: { destinations: 999, competitors: 999, analyses: 999 },
7829};
7830var _planLimitsLoaded = false;
7831
7832async function loadPlanLimits() {
7833  if (_planLimitsLoaded) return PLAN_LIMITS;
7834
7835  var result = await withRetry(async function() {
7836    var sb = await getSupabase();
7837    var resp = await sb.from('plan_limits').select('plan, destinations, competitors, analyses_monthly');
7838    if (resp.error) throw new Error(resp.error.message || 'Supabase error');
7839    if (!resp.data || resp.data.length === 0) throw new Error('Nenhum limite retornado');
7840    return resp.data;
7841  }, { retries: 3, label: 'PlanLimits' });
7842
7843  if (result.ok) {
7844    result.data.forEach(function(r) {
7845      PLAN_LIMITS[r.plan] = { destinations: r.destinations, competitors: r.competitors, analyses: r.analyses_monthly };
7846    });
7847    _planLimitsLoaded = true;
7848    console.log('[PlanLimits] ✅ Limites carregados do Supabase');
7849  } else {
7850    console.error('[PlanLimits] ❌ Falha total — usando valores padrão restritivos');
7851    // Em caso de falha total, usar limites restritivos (free) para segurança
7852    PLAN_LIMITS = {
7853      free:       { destinations: 0, competitors: 0, analyses: 0 },
7854      basic:      { destinations: 0, competitors: 0, analyses: 0 },
7855      premium:    { destinations: 0, competitors: 0, analyses: 0 },
7856      oracle:     { destinations: 0, competitors: 0, analyses: 0 },
7857      enterprise: { destinations: 0, competitors: 0, analyses: 0 },
7858    };
7859  }
7860  return PLAN_LIMITS;
7861}
7862
7863loadPlanLimits();
7864
7865function getUserPlan() {
7866  // Preferir plano validado pelo servidor via router
7867  if (window._routerAuthCache && window._routerAuthCache.plan) {
7868    return window._routerAuthCache.plan;
7869  }
7870  // Fallback conservador: usar localStorage mas nunca confiar cegamente
7871  return localStorage.getItem('cp_user_plan') || localStorage.getItem('cp_plan_type') || 'basic';
7872}
7873
7874/**
7875 * Controla a visibilidade dos menus do sidebar com base no plano do usuário.
7876 * Básico: esconde Premium e Oracle.
7877 * Premium: esconde Oracle.
7878 * Oracle: mostra tudo.
7879 */
7880function enforceMenuByPlan() {
7881  const plan = getUserPlan();
7882  // IDs dos menus Premium
7883  const premiumIds = ['nav-heatmap','nav-influence','nav-viral','nav-simulator','nav-social','nav-campaign'];
7884  // IDs dos menus Oracle
7885  const oracleIds = ['nav-emissores','nav-felicidade','nav-gemeo','nav-can','nav-oracle-main','nav-sinc','nav-compass'];
7886
7887  const unlockPremium = (plan === 'premium' || plan === 'oracle' || plan === 'enterprise');
7888  const unlockOracle = (plan === 'oracle' || plan === 'enterprise');
7889
7890  function applyLock(id, unlocked) {
7891    const el = document.getElementById(id);
7892    if (!el) return;
7893    // Sempre visível
7894    el.style.removeProperty('display');
7895    if (unlocked) {
7896      el.classList.remove('locked');
7897      el.removeAttribute('onclick');
7898      el.onclick = null;
7899      // Restaurar data-route para que o router intercepte cliques
7900      if (!el.hasAttribute('data-route')) el.setAttribute('data-route', '');
7901      // Remover cadeado
7902      const lock = el.querySelector('.nav-lock');
7903      if (lock) lock.remove();
7904    } else {
7905      el.classList.add('locked');
7906      // Remover data-route para que o router não intercepte
7907      el.removeAttribute('data-route');
7908      // Ao clicar em menu locked, mostrar mensagem de upgrade
7909      el.onclick = function(e) {
7910        e.preventDefault();
7911        e.stopPropagation();
7912        const tierName = premiumIds.includes(id) ? 'Premium' : 'Oracle';
7913        alert('🔒 Este módulo requer o plano ' + tierName + ' ou superior.\n\nFaça upgrade para desbloquear.');
7914      };
7915      // Adicionar cadeado se não existir
7916      if (!el.querySelector('.nav-lock')) {
7917        const lockSpan = document.createElement('span');
7918        lockSpan.className = 'nav-lock';
7919        lockSpan.textContent = '🔒';
7920        el.appendChild(lockSpan);
7921      }
7922    }
7923  }
7924
7925  premiumIds.forEach(function(id) { applyLock(id, unlockPremium); });
7926  oracleIds.forEach(function(id) { applyLock(id, unlockOracle); });
7927
7928  // Labels de seção — sempre visíveis (default)
7929  const premLabel = document.getElementById('nav-label-premium');
7930  if (premLabel) premLabel.style.display = '';
7931  const oracleLabel = document.getElementById('nav-label-oracle');
7932  if (oracleLabel) oracleLabel.style.display = '';
7933  const compassLabel = document.getElementById('nav-label-compass');
7934  if (compassLabel) compassLabel.style.display = '';
7935
7936  // ===== CONTROLE POR MÓDULO (via backend feature flags) =====
7937  var _modules = window._routerAuthCache && window._routerAuthCache.modules;
7938  if (_modules) {
7939    // Mapa de nav-id para module_id
7940    var _navModuleMap = {
7941      'nav-dashboard': 'dashboard', 'nav-crisis': 'crisis', 'nav-profile': 'profile',
7942      'nav-benchmark': 'benchmark', 'nav-history': 'history',
7943      'nav-heatmap': 'heatmap', 'nav-influence': 'influence', 'nav-viral': 'viral',
7944      'nav-simulator': 'simulator', 'nav-social': 'social', 'nav-campaign': 'campaign',
7945      'nav-emissores': 'emissores', 'nav-felicidade': 'felicidade', 'nav-gemeo': 'gemeo',
7946      'nav-can': 'can', 'nav-oracle-main': 'oracle', 'nav-sinc': 'sinc',
7947      'nav-compass': 'compass', 'nav-publicmax': 'publicmax',
7948      'nav-projecao': 'projecao', 'nav-calendario': 'calendario',
7949      'nav-studio': 'studio'
7950    };
7951    Object.keys(_navModuleMap).forEach(function(navId) {
7952      var modId = _navModuleMap[navId];
7953      var enabled = _modules[modId];
7954      if (enabled === false) {
7955        applyLock(navId, false);
7956      } else if (enabled === true) {
7957        applyLock(navId, true);
7958      }
7959    });
7960  }
7961
7962  // Cliente de um modulo so (ex.: Consensus Studio): sem dashboard, a tela de
7963  // Nova Analise e o Historico nao levam a lugar nenhum. Aqui o item some do
7964  // menu em vez de ganhar cadeado — cadeado convida a clicar e vender upgrade
7965  // para quem contratou outra coisa. Vem das feature flags, nunca de e-mail.
7966  if (_modules && _modules.dashboard === false) {
7967    var _ligados = Object.keys(_modules).filter(function (m) { return _modules[m] === true; });
7968    if (_ligados.length === 1) {
7969      Object.keys(_navModuleMap).forEach(function (navId) {
7970        if (_modules[_navModuleMap[navId]] === true) return;
7971        var el = document.getElementById(navId);
7972        if (el) el.style.display = 'none';
7973      });
7974      var _novaAnalise = document.querySelector('.nav-item[data-page="input"]');
7975      if (_novaAnalise) _novaAnalise.style.display = 'none';
7976      ['nav-label-premium', 'nav-label-oracle', 'nav-label-compass'].forEach(function (id) {
7977        var el = document.getElementById(id);
7978        if (el) el.style.display = 'none';
7979      });
7980    }
7981  }
7982  // ===== FIM CONTROLE POR MÓDULO =====
7983
7984  // Módulos Brasil-only (emissores, publicmax, studio, influence/Termômetro — dados/
7985  // integrações específicos do Brasil: MTur, Portal da Transparência etc.) — somem do
7986  // menu pra destino fora do Brasil, mesmo com plano/feature flag liberando. Roda por
7987  // último de propósito, pra não ser sobrescrito pelo applyLock() dos blocos acima
7988  // (premiumIds/oracleIds e feature flags sempre removem o display:none).
7989  var _isBR = (typeof isCurrentDestBrasil === 'function') ? isCurrentDestBrasil() : true;
7990  if (!_isBR) {
7991    ['nav-emissores', 'nav-publicmax', 'nav-studio', 'nav-influence', 'nav-campaign'].forEach(function(id) {
7992      var el = document.getElementById(id);
7993      if (el) el.style.display = 'none';
7994    });
7995  }
7996}
7997
7998/**
7999 * Atualiza o menu de usuário no topbar com dados do usuário logado.
8000 */
8001async function updateTopbarUserMenu() {
8002  try {
8003    const supabase = await getSupabase();
8004    const { data: { session } } = await supabase.auth.getSession();
8005    if (session && session.user) {
8006      const user = session.user;
8007      const meta = user.user_metadata || {};
8008      const name = meta.full_name || meta.name || user.email?.split('@')[0] || 'Usuário';
8009      const initial = name.charAt(0).toUpperCase();
8010
8011      // Atualizar topbar
8012      const avatarEl = document.getElementById('topbar-avatar');
8013      const unameEl = document.getElementById('topbar-uname');
8014      const fullnameEl = document.getElementById('topbar-fullname');
8015      const emailEl = document.getElementById('topbar-email');
8016
8017      if (avatarEl) avatarEl.textContent = initial;
8018      if (unameEl) unameEl.textContent = name;
8019      if (fullnameEl) fullnameEl.textContent = name;
8020      if (emailEl) emailEl.textContent = user.email || '';
8021
8022      // Atualizar menu de usuário na sidebar
8023      const navUserName = document.getElementById('nav-user-plan-name');
8024      if (navUserName) navUserName.textContent = name;
8025    }
8026  } catch(e) {
8027    console.warn('Erro ao atualizar menu do topbar:', e);
8028  }
8029}
8030
8031function getLockedDestination() {
8032  return JSON.parse(localStorage.getItem('cp_locked_destination') || 'null');
8033}
8034
8035
8036function unlockDestination() {
8037  localStorage.removeItem('cp_locked_destination');
8038}
8039
8040function getCompetitorLimit() {
8041  const plan = getUserPlan();
8042  return (PLAN_LIMITS[plan] || PLAN_LIMITS.basic).competitors;
8043}
8044
8045function enforceCompetitorLimit() {
8046  const limit = getCompetitorLimit();
8047  // Hide competitor fields beyond the plan limit (empresa + destino forms)
8048  for (let i = 1; i <= 3; i++) {
8049    // Empresa form: ob-conc1, ob-conc2, ob-conc3
8050    const concBlock = document.getElementById('ob-conc' + i + '-estado')?.closest('[style*="padding:12px"]');
8051    if (concBlock) {
8052      if (i > limit) {
8053        concBlock.style.display = 'none';
8054        // Limpar valores dos campos ocultos
8055        ['ob-conc'+i+'-estado', 'ob-conc'+i+'-cidade', 'ob-conc'+i+'-ig', 'ob-conc'+i+'-site'].forEach(id => {
8056          const el = document.getElementById(id);
8057          if (el) el.value = '';
8058        });
8059      } else {
8060        concBlock.style.display = '';
8061      }
8062    }
8063    // Destino form: ob-dest-conc1, ob-dest-conc2, ob-dest-conc3
8064    const destConcBlock = document.getElementById('ob-dest-conc' + i + '-estado')?.closest('[style*="padding:12px"]');
8065    if (destConcBlock) {
8066      if (i > limit) {
8067        destConcBlock.style.display = 'none';
8068        // Limpar valores dos campos ocultos
8069        ['ob-dest-conc'+i+'-estado', 'ob-dest-conc'+i+'-cidade', 'ob-dest-conc'+i+'-ig', 'ob-dest-conc'+i+'-site'].forEach(id => {
8070          const el = document.getElementById(id);
8071          if (el) el.value = '';
8072        });
8073      } else {
8074        destConcBlock.style.display = '';
8075      }
8076    }
8077    // Sidebar competitor fields
8078    const fc = document.getElementById('f-c' + i);
8079    if (fc) {
8080      if (i > limit) {
8081        fc.style.display = 'none';
8082        fc.value = '';
8083      } else {
8084        fc.style.display = '';
8085      }
8086    }
8087  }
8088}
8089
8090// ===== ANÁLISE LIMIT CHECK =====
8091
8092function showAnalysisLimitAlert(used, limit, plan) {
8093  const lang = (typeof OB !== 'undefined' && OB.lang) || G.lang || 'pt';
8094  const planNames = { basic: 'Basic', premium: 'Premium', oracle: 'Oracle' };
8095  const planName = planNames[plan] || plan;
8096
8097  const msgs = {
8098    pt: `🚫 Limite de análises atingido!\n\nVocê já usou ${used}/${limit} análises este mês no plano ${planName}.\n\nPara continuar analisando, faça upgrade do seu plano.`,
8099    en: `🚫 Analysis limit reached!\n\nYou've used ${used}/${limit} analyses this month on the ${planName} plan.\n\nTo continue analyzing, upgrade your plan.`,
8100    es: `🚫 Límite de análisis alcanzado!\n\nYa usó ${used}/${limit} análisis este mes en el plan ${planName}.\n\nPara continuar analizando, actualice su plan.`,
8101    fr: `🚫 Limite d'analyses atteinte!\n\nVous avez utilisé ${used}/${limit} analyses ce mois sur le plan ${planName}.\n\nPour continuer, mettez à niveau votre plan.`
8102  };
8103  alert(msgs[lang] || msgs.pt);
8104}
8105
8106function enforceDestinationLock() {
8107  const locked = getLockedDestination();
8108  if (!locked) return false; // No lock yet — first time user
8109
8110  const isDestino = OB.sector === 'destination';
8111
8112  if (isDestino) {
8113    const cidadeEl = document.getElementById('ob-cidade');
8114    const paisEl = document.getElementById('ob-pais');
8115    const estadoEl = document.getElementById('ob-estado');
8116    if (cidadeEl) { cidadeEl.value = locked.destination; cidadeEl.readOnly = true; cidadeEl.style.opacity = '0.7'; cidadeEl.style.cursor = 'not-allowed'; }
8117    if (paisEl) { paisEl.readOnly = true; paisEl.style.opacity = '0.7'; }
8118    if (estadoEl) { estadoEl.readOnly = true; estadoEl.style.opacity = '0.7'; }
8119  } else {
8120    const empresaEl = document.getElementById('ob-empresa');
8121    if (empresaEl) { empresaEl.value = locked.destination; empresaEl.readOnly = true; empresaEl.style.opacity = '0.7'; empresaEl.style.cursor = 'not-allowed'; }
8122  }
8123
8124  // Show locked indicator
8125  const lockMsg = document.getElementById('ob-lock-msg');
8126  if (lockMsg) lockMsg.style.display = '';
8127
8128  return true;
8129}
8130
8131function showLockWarning(dest, callback) {
8132  const lang = OB.lang || 'pt';
8133  const msgs = {
8134    pt: '⚠️ ATENÇÃO: Ao iniciar a primeira análise, seu destino será fixado como "' + dest + '". Você não poderá trocar depois (apenas o administrador pode alterar).\n\nDeseja continuar?',
8135    en: '⚠️ WARNING: After your first analysis, your destination will be locked as "' + dest + '". You won\'t be able to change it later (only the administrator can).\n\nDo you want to continue?',
8136    es: '⚠️ ATENCIÓN: Al iniciar el primer análisis, su destino quedará fijado como "' + dest + '". No podrá cambiarlo después (solo el administrador puede).\n\n¿Desea continuar?',
8137    fr: '⚠️ ATTENTION: Après votre première analyse, votre destination sera verrouillée comme "' + dest + '". Vous ne pourrez pas la changer 
8137(seul l\'administrateur peut).\n\nVoulez-vous continuer?',
8138  };
8139  if (confirm(msgs[lang] || msgs.pt)) {
8140    callback();
8141  }
8142}
8143
8144function obGoStep4() {
8145  // Step 4 is hidden from users — provider is managed by admin
8146  obSkipToAnalysis();
8147}
8148
8149async function obSkipToAnalysis() {
8150  // Provider global vem de system_settings (hidratado em window._GLOBAL_PROVIDER no boot).
8151  OB.provider = window._GLOBAL_PROVIDER || 'openai';
8152
8153  const btn = document.getElementById('ob-btn-step3-analyze');
8154
8155  // Upload pending files before starting analysis
8156  const pendingCount = OB_files.filter(f => f.pending).length;
8157  if (pendingCount > 0) {
8158    if (btn) {
8159      btn.disabled = true;
8160      btn.innerHTML = `⏳ Enviando ${pendingCount} arquivo${pendingCount > 1 ? 's' : ''}...`;
8161    }
8162
8163    await obUploadPendingFiles();
8164
8165    if (btn) {
8166      btn.disabled = true;
8167      btn.innerHTML = '⏳ Iniciando análise...';
8168    }
8169  } else if (btn) {
8170    btn.disabled = true;
8171    btn.innerHTML = '⏳ Iniciando análise...';
8172  }
8173
8174  obStartAnalysis();
8175}
8176
8177function obValidateStep2() {
8178  const isDestino = OB.sector === 'destination';
8179  if (isDestino) {
8180    const cidade = document.getElementById('ob-cidade')?.value.trim();
8181    const btn = document.getElementById('ob-next2-dest');
8182    if (btn) btn.disabled = !cidade;
8183  } else {
8184    const empresa = document.getElementById('ob-empresa')?.value.trim();
8185    const btn = document.getElementById('ob-next2-emp');
8186    if (btn) btn.disabled = !empresa;
8187  }
8188}
8189
8190function obValidateStep4() {
8191  const key = document.getElementById('ob-api-key')?.value.trim();
8192  const btn = document.getElementById('ob-btn-analyze');
8193  // Keys managed server-side — always enable if user is authenticated
8194  const hasStoredKey = true; // Chaves gerenciadas via Edge Function
8195  if (btn) btn.disabled = !hasStoredKey;
8196}
8197
8198function obSelectPeriod(el, p) {
8199  OB.period = p;
8200  el.closest('.ob-period-grid')?.querySelectorAll('.ob-period-btn').forEach(b => b.classList.remove('sel'));
8201  el.classList.add('sel');
8202  obAutoSave();
8203}
8204
8205function obSelectProvider(el, p) {
8206  // Save current key before switching
8207  const inp = document.getElementById('ob-api-key');
8208  if (inp && inp.value.trim() && OB.provider) {
8209    localStorage.setItem('cp_key_' + OB.provider, inp.value.trim());
8210  }
8211  
8212  OB.provider = p;
8213  document.querySelectorAll('[id^="ob-prov-"]').forEach(b => b.classList.remove('sel'));
8214  el.classList.add('sel');
8215  const labels = {claude:'sk-ant-...', openai:'sk-...', gemini:'AIza...', grok:'xai-...'};
8216  const lbl = document.getElementById('ob-api-label');
8217  if (lbl) lbl.textContent = '🔑 API Key ' + p.charAt(0).toUpperCase()+p.slice(1);
8218  if (inp) {
8219    inp.placeholder = labels[p] || 'API Key...';
8220    // Load saved key for this provider
8221    const savedKey = localStorage.getItem('cp_key_' + p) || '';
8222    inp.value = savedKey;
8223  }
8224  obValidateStep4();
8225}
8226
8227// ── File handling (onboarding step 3) ──
8228var OB_files = []; // Array de {name, size, file (File object), pending} ou {id, name, size, extracted_text} (após upload)
8229
8230async function obHandleFiles(fileList) {
8231  for (const f of Array.from(fileList)) {
8232    if (OB_files.find(x => x.name === f.name)) continue;
8233    // Guardar File object na memória (upload adiado para o botão "Analisar Agora")
8234    OB_files.push({ name: f.name, size: f.size, file: f, pending: true });
8235    obRenderFileList();
8236  }
8237}
8238
8239function obHandleDrop(e) {
8240  e.preventDefault();
8241  obHandleFiles(e.dataTransfer.files);
8242  document.getElementById('ob-upload-zone').style.borderColor = 'var(--border)';
8243}
8244
8245function obRenderFileList() {
8246  const el = document.getElementById('ob-file-list');
8247  if (!el) return;
8248  if (!OB_files.length) { el.innerHTML = ''; return; }
8249  el.innerHTML = OB_files.map((f,i) => {
8250    const icon = f.name.endsWith('.pdf')?'📄':f.name.endsWith('.docx')?'📝':'📃';
8251    const status = f.uploading
8252      ? '<span style="color:var(--yellow);font-size:10px;">⏳ enviando...</span>'
8253      : f.pending
8254        ? '<span style="color:var(--accent);font-size:10px;">📎 pronto</span>'
8255        : `<span style="color:var(--green);font-size:10px;">✅ enviado</span>`;
8256    const sizeLabel = `<span style="color:var(--text3);font-size:11px;">${(f.size/1024).toFixed(0)}kb</span>`;
8257    const removeBtn = f.uploading ? '' : `<button onclick="obRemoveFile(${i})" style="background:none;border:none;color:var(--text3);cursor:pointer;font-size:14px;">✕</button>`;
8258    return `<div style="display:flex;align-items:center;gap:8px;padding:8px 12px;border-radius:10px;background:var(--surface2);font-size:13px;">
8259      <span>${icon}</span>
8260      <span style="flex:1;color:var(--text2);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${f.name}</span>
8261      ${sizeLabel} ${status}
8262      ${removeBtn}
8263    </div>`;
8264  }).join('');
8265}
8266
8267function obRemoveFile(i) {
8268  const f = OB_files[i];
8269  // Se já foi enviado ao storage, deletar
8270  if (f?.id) storageDelete(f.id);
8271  // Se ainda está pendente, só remove da memória (zero custo)
8272  OB_files.splice(i, 1);
8273  obRenderFileList();
8274}
8275
8276/**
8277 * Extrai texto de um arquivo no client. PDF via pdfjsLib, DOCX via mammoth.
8278 * Libs já carregadas no <head> do HTML principal (uso histórico do Compass/Gêmeo).
8279 * Em caso de falha, retorna string vazia — server cai no fallback unpdf/mammoth.
8280 * @param {File} f
8281 * @returns {Promise<string>}
8282 */
8283async function _extractTextLocally(f) {
8284  const name = (f.name || '').toLowerCase();
8285  try {
8286    if (name.endsWith('.pdf') && window.pdfjsLib) {
8287      const ab = await f.arrayBuffer();
8288      const pdf = await window.pdfjsLib.getDocument({ data: ab }).promise;
8289      let combined = '';
8290      for (let i = 1; i <= pdf.numPages; i++) {
8291        const page = await pdf.getPage(i);
8292        const tc = await page.getTextContent();
8293        combined += tc.items.map(x => x.str).join(' ') + '\n';
8294      }
8295      return combined.trim();
8296    }
8297    if ((name.endsWith('.docx') || name.endsWith('.doc')) && window.mammoth) {
8298      const ab = await f.arrayBuffer();
8299      const r = await window.mammoth.extractRawText({ arrayBuffer: ab });
8300      return (r.value || '').trim();
8301    }
8302    if (/\.(txt|md|csv|json)$/.test(name)) {
8303      return await f.text();
8304    }
8305  } catch (e) {
8306    console.warn(`[extract] Falha ao extrair texto de ${f.name}: ${e.message} — server fará fallback`);
8307  }
8308  return '';
8309}
8310
8311/**
8312 * Faz upload de todos os arquivos pendentes.
8313 * Chamado antes de iniciar a análise.
8314 * @returns {Promise<boolean>} true se todos os uploads foram bem-sucedidos (ou não havia arquivos)
8315 */
8316async function obUploadPendingFiles() {
8317  const pending = OB_files.filter(f => f.pending && f.file);
8318  if (!pending.length) return true;
8319
8320  console.log(`📤 Uploading ${pending.length} pending files...`);
8321
8322  for (const f of pending) {
8323    const idx = OB_files.indexOf(f);
8324    if (idx < 0) continue;
8325
8326    // Marcar como uploading
8327    OB_files[idx] = { ...f, uploading: true, pending: false };
8328    obRenderFileList();
8329
8330    // Extração client-side (evita estourar RAM do worker server-side)
8331    const localText = await _extractTextLocally(f.file);
8332    const result = await storageUpload(f.file, 'onboarding', localText);
8333    if (result) {
8334      OB_files[idx] = {
8335        id: result.id,
8336        name: result.original_name,
8337        size: result.file_size,
8338        extracted_text: result.extracted_text,
8339        text_length: result.text_length
8340      };
8341    } else {
8342      // Upload falhou — remover da lista
8343      console.warn(`❌ Upload failed: ${f.name}`);
8344      OB_files.splice(idx, 1);
8345    }
8346    obRenderFileList();
8347  }
8348
8349  console.log(`✅ Upload complete. ${OB_files.filter(f => f.id).length} files ready.`);
8350  return true;
8351}
8352
8353// ── Topbar document upload ──
8354let TOPBAR_files = []; // Array de {id, name, size, extracted_text, ...}
8355
8356function openDocsPanel() {
8357  const p = document.getElementById('docs-panel');
8358  if (p) {
8359    const isHidden = p.style.display === 'none';
8360    p.style.display = isHidden ? 'block' : 'none';
8361    if (isHidden) topbarLoadFiles();
8362  }
8363}
8364
8365function closeDocsPanel() {
8366  const p = document.getElementById('docs-panel');
8367  if (p) p.style.display = 'none';
8368}
8369
8370async function topbarLoadFiles() {
8371  const el = document.getElementById('topbar-file-list');
8372  const emptyEl = document.getElementById('topbar-file-empty');
8373  if (!el) return;
8374
8375  el.innerHTML = '<div style="text-align:center;padding:16px;color:var(--text3);font-size:12px;">'+t('docs_loading')+'</div>';
8376  if (emptyEl) emptyEl.style.display = 'none';
8377
8378  try {
8379    const analysisId = sessionStorage.getItem('cp_active_analysis');
8380
8381    // Sem análise ativa, listar apenas arquivos anexados nesta sessão
8382    // (em OB_files com id). Sem fallback global pra "todos os onboarding"
8383    // — isolamento entre análises.
8384    if (!analysisId) {
8385      const obWithId = (Array.isArray(OB_files) ? OB_files : []).filter(f => f && f.id);
8386      TOPBAR_files = obWithId.map(f => ({
8387        id: f.id,
8388        original_name: f.name,
8389        file_size: f.size,
8390        text_length: f.text_length || 0,
8391        module: 'onboarding',
8392        indexing_status: 'indexed',
8393        created_at: new Date().toISOString(),
8394      }));
8395    } else {
8396      // Análise ativa: arquivos vinculados a essa análise
8397      const supabase = await getSupabase();
8398      const { data: { session } } = await supabase.auth.getSession();
8399      if (session) {
8400        const res = await fetch(`${SUPABASE_URL}/functions/v1/file-list`, {
8401          method: 'POST',
8402          headers: {
8403            'Content-Type': 'application/json',
8404            'Authorization': `Bearer ${session.access_token}`,
8405          },
8406          body: JSON.stringify({ action: 'list', analysis_id: analysisId })
8407        });
8408        if (res.ok) {
8409          const data = await res.json();
8410          TOPBAR_files = data.files || [];
8411        } else {
8412          TOPBAR_files = [];
8413        }
8414      } else {
8415        TOPBAR_files = [];
8416      }
8417    }
8418
8419    if (!TOPBAR_files.length) {
8420      el.innerHTML = '';
8421      if (emptyEl) emptyEl.style.display = 'block';
8422      return;
8423    }
8424
8425    if (emptyEl) emptyEl.style.display = 'none';
8426    topbarRenderFileList();
8427  } catch (e) {
8428    console.error('[Docs] Erro ao carregar documentos:', e);
8429    el.innerHTML = '<div style="text-align:center;padding:16px;color:var(--red);font-size:12px;">'+t('docs_load_error')+'</div>';
8430  }
8431}
8432
8433function topbarRenderFileList() {
8434  const el = document.getElementById('topbar-file-list');
8435  if (!el) return;
8436
8437  const getIcon = (name) => {
8438    if (name.endsWith('.pdf')) return '📄';
8439    if (name.endsWith('.docx') || name.endsWith('.doc')) return '📝';
8440    if (name.endsWith('.csv')) return '📊';
8441    return '📃';
8442  };
8443
8444  const formatSize = (bytes) => {
8445    if (bytes < 1024) return bytes + ' B';
8446    if (bytes < 1048576) return (bytes / 1024).toFixed(0) + ' KB';
8447    return (bytes / 1048576).toFixed(1) + ' MB';
8448  };
8449
8450  const formatDate = (iso) => {
8451    if (!iso) return '';
8452    const d = new Date(iso);
8453    const locale = {pt:'pt-BR',en:'en-US',es:'es-ES',fr:'fr-FR'}[G.lang] || 'pt-BR';
8454    return d.toLocaleDateString(locale, { day: '2-digit', month: '2-digit' }) + ' ' + d.toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' });
8455  };
8456
8457  el.innerHTML = TOPBAR_files.map(f =>
8458    `<div style="display:flex;align-items:center;gap:10px;padding:8px 10px;border-radius:8px;background:var(--surface2);font-size:12px;">
8459      <span style="font-size:18px;">${getIcon(f.original_name || f.name || '')}</span>
8460      <div style="flex:1;min-width:0;">
8461        <div style="color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:500;">${f.original_name || f.name}</div>
8462        <div style="color:var(--text3);font-size:10px;margin-top:2px;">${formatSize(f.file_size || f.size || 0)} · ${f.module || ''} · ${formatDate(f.created_at)}</div>
8463      </div>
8464      <span style="font-size:10px;color:var(--text3);">${f.text_length ? f.text_length.toLocaleString() + t('docs_chars_suffix') : ''}</span>
8465    </div>`
8466  ).join('');
8467}
8468
8469async function obStartAnalysis() {
8470  OB.provider = OB.provider || window._GLOBAL_PROVIDER || 'openai';
8471  OB.foco = OB.foco || 'geral';
8472
8473  try {
8474    // Verificar autenticação no Supabase
8475    const supabase = await getSupabase();
8476    const { data: { session } } = await supabase.auth.getSession();
8477    
8478    if (!session) {
8479      const msg = OB.lang === 'en' ? 'Please login to run analyses.'
8480        : OB.lang === 'es' ? 'Inicie sesión para ejecutar análisis.'
8481        : OB.lang === 'fr' ? 'Connectez-vous pour lancer des analyses.'
8482        : 'Faça login para executar análises.';
8483      alert(msg);
8484      return;
8485    }
8486
8487    console.log('✅ User authenticated:', session.user.email);
8488
8489    // Provisionar API keys no localStorage para o usuário logado
8490    provisionApiKeys();
8491
8492    // Save recent
8493    obSaveRecent();
8494
8495    // Transfer OB state to G
8496    G.lang = OB.lang;
8497    G.sector = OB.sector;
8498    G.provider = OB.provider;
8499    G.concs = OB.concs || [];
8500    G.concsData = OB.concsData || [];
8501    G.empresaIG = OB.empresaIG || '';
8502    G.empresaSite = OB.empresaSite || '';
8503    G.dest = _deduplicateDest(OB.dest);
8504    G.periodo = OB.period || '30';
8505    // Análise nova SEMPRE começa sem mercados emissores: eles não vêm mais do
8506    // onboarding, são cadastrados dentro do módulo (/app/emissores). Zerar aqui também
8507    // impede que os mercados da análise ANTERIOR (G.markets, setado por
8508    // _emCadastroSave) vazem para a nova via _meta_emitterMarkets.
8509    G.markets = [];
8510
8511    // Snapshot do que está sendo analisado — usado pelo guarda-corpo do obGoStep3 pra
8512    // detectar "trocou site/IG mas manteve a cidade" na PRÓXIMA análise. Guarda OB.* (o
8513    // valor exato que obGoStep3 compara), não G.dest (que passa por dedupe redundante).
8514    try {
8515      localStorage.setItem('cp_last_analysis_meta', JSON.stringify({
8516        dest: OB.dest || '', site: OB.empresaSite || '', ig: OB.empresaIG || ''
8517      }));
8518    } catch (_e) { /* localStorage indisponível — guarda-corpo apenas não dispara */ }
8519
8520    setProvider(OB.provider);
8521    setLang(OB.lang);
8522    setSector(OB.sector);
8523
8524    // Fill legacy fields
8525    const fDestinoEl = document.getElementById('f-destino');
8526    if (fDestinoEl) fDestinoEl.value = OB.dest;
8527    const fPeriodoEl = document.getElementById('f-periodo');
8528    if (fPeriodoEl) fPeriodoEl.value = OB.period || '30';
8529
8530    // (A saída do onboarding + entrada no painel com spinner acontece dentro de
8531    //  startAnalysis → _enterDashboardLoading. A checagem de limite abaixo roda
8532    //  ainda com o onboarding visível; só após passar é que entramos no painel.)
8533
8534    // Documentos: extração e indexação são server-side (Edge Function file-upload).
8535    // O texto entra no contexto via readUploadedFile() em startAnalysis (linha ~208).
8536
8537    // Verificar limite de análises antes de iniciar
8538    const limitCheck = await checkAnalysisLimit();
8539    if (!limitCheck.allowed) {
8540      showAnalysisLimitAlert(limitCheck.used, limitCheck.limit, limitCheck.plan);
8541      if (window.AppRouter) {
8542        AppRouter.navigateTo('/app/onboarding/2', { replace: true });
8543      } else {
8544        document.body.classList.remove('loading-screen');
8545        document.body.classList.add('onboarding');
8546        document.getElementById('ob-step2').classList.add('active');
8547      }
8548      return;
8549    }
8550
8551    await startAnalysis(true);
8552    
8553  } catch (error) {
8554    console.error('❌ obStartAnalysis error:', error);
8555    alert(error.message);
8556  }
8557}
8558
8559// ══════════════════════════════════════════════
8560// CAN FULL REPORT MODAL
8561// ══════════════════════════════════════════════
8562function openCanReport() {
8563  if (!G.data) {
8564    alert(t('can_report_alert_no_analysis'));
8565    return;
8566  }
8567  // O modal vem de components/page-can.html, carregado dentro de .page#page-can — e
8568  // .page.active tem position:relative + z-index:10 (app.css), que cria um stacking
8569  // context próprio e prende TUDO dentro dele (mesmo um z-index:9000 no modal) abaixo
8570  // de qualquer irmão com z maior no mesmo nível, como .topbar (z-index:50, sticky).
8571  // Resultado: o cabeçalho do modal ficava atrás do header global. Os outros modais
8572  // full-screen do shell (destination-lock-modal, sector-modal) não têm esse bug
8573  // porque vivem fora de qualquer .page — reparentar pro <body> replica isso aqui.
8574  var _modal = document.getElementById('can-report-modal');
8575  if (_modal && _modal.parentElement !== document.body) document.body.appendChild(_modal);
8576  const d = G.data;
8577  const dest = G.dest || '--';
8578  const now = new Date();
8579  const langLocale = {pt:'pt-BR',en:'en-US',es:'es-ES',fr:'fr-FR'}[G.lang]||'pt-BR';
8580  document.getElementById('can-report-date').textContent = now.toLocaleDateString(langLocale, {day:'numeric',month:'long',year:'numeric'});
8581
8582  const fmt = t => (t||'--').replace(/\*\*(.*?)\*\*/g,'<strong>$1</strong>').replace(/\n/g,'<br>');
8583  const section = (icon, title, content, borderColor) => `
8584    <div style="background:var(--surface);border:1px solid ${borderColor||'var(--border)'};border-radius:16px;padding:24px;">
8585      <div style="font-family:Syne,sans-serif;font-size:13px;font-weight:700;text-transform:uppercase;letter-spacing:1px;color:${borderColor||'var(--text2)'};margin-bottom:14px;display:flex;align-items:center;gap:8px;">
8586        <span>${icon}</span> ${title}
8587      </div>
8588      ${content}
8589    </div>`;
8590  const row = (label, val) => `<div style="display:flex;justify-content:space-between;padding:8px 0;border-bottom:1px solid var(--border);font-size:13px;"><span style="color:var(--text2);">${label}</span><strong>${val}</strong></div>`;
8591  const bullet = (items, color) => (items||[]).map(i=>`<div style="display:flex;gap:10px;padding:7px 0;font-size:13px;line-height:1.5;"><span style="color:${color||'var(--accent)'};flex-shrink:0;">•</span><span>${i}</span></div>`).join('');
8592
8593  // Score KPIs
8594  const kpiContent = `
8595    <div style="display:grid;grid-template-columns:repeat(3,1fr);gap:12px;">
8596      ${[
8597        [t('can_report_kpi_reputation'), (d.score||'--')+'/100', 'var(--accent)'],
8598        [t('can_report_kpi_sentiment'), (d.sentimento?.positivo||'--')+'%', 'var(--blue)'],
8599        [t('can_report_kpi_opportunities'), d.n_oportunidades||'--', 'var(--purple)'],
8600        [t('can_report_kpi_crisis_level'), d.crisis_level||'--', 'var(--red)'],
8601        [t('can_report_kpi_happiness'), (d.feliz_score||'--')+'/100', 'var(--yellow)'],
8602        [t('can_report_kpi_search_trend'), d.tendencia_busca||'--', 'var(--accent)'],
8603      ].map(([l,v,c])=>`
8604        <div style="padding:16px;border-radius:12px;background:var(--surface2);border:1px solid var(--border);text-align:center;">
8605          <div style="font-family:Syne,sans-serif;font-size:24px;font-weight:800;color:${c};">${v}</div>
8606          <div style="font-size:11px;color:var(--text3);margin-top:4px;">${l}</div>
8607        </div>`).join('')}
8608    </div>`;
8609
8610  // Insights
8611  const insightsContent = `<div style="font-size:13px;line-height:1.8;color:var(--text2);">${fmt(d.insights)}</div>`;
8612
8613  // Can Briefing
8614  const canContent = `<div style="font-size:14px;line-height:1.8;color:var(--text1);">${fmt(d.can_briefing)}</div>`;
8615
8616  // Priority Actions
8617  const actionsContent = (d.can_actions||[]).map((a,i)=>{
8618    const urgKey = (typeof _canUrgenciaKey === 'function') ? _canUrgenciaKey(a.urgencia) : '';
8619    const col = urgKey==='urgent' ? 'var(--red)' : urgKey==='this_week' ? 'var(--yellow)' : 'var(--accent)';
8620    const urgLabel = urgKey ? ({urgent:'🔴 ',this_week:'🟡 ',next_month:'🟢 '}[urgKey] + t('can_urgencia_'+urgKey)) : (a.urgencia||'');
8621    return `<div style="padding:14px;border-radius:10px;background:var(--surface2);border:1px solid var(--border);border-left:3px solid ${col};margin-bottom:8px;">
8622      <div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;">
8623        <span style="font-size:13px;font-weight:700;">${a.titulo}</span>
8624        <span style="font-size:11px;padding:2px 8px;border-radius:100px;background:${col}22;color:${col};margin-left:auto;">${urgLabel}</span>
8625      </div>
8626      <div style="font-size:12px;color:var(--text2);margin-bottom:4px;">${a.descricao||''}</div>
8627      <div style="font-size:11px;color:var(--accent);">📈 ${a.impacto||''}</div>
8628    </div>`;
8629  }).join('');
8630
8631  // Top Oportunidades
8632  const oppsContent = (d.oportunidades_acoes||d.oportunidades||[]).map(o=>`
8633    <div style="padding:12px 14px;border-radius:10px;background:var(--surface2);border:1px solid var(--border);margin-bottom:8px;">
8634      <div style="font-size:13px;font-weight:600;margin-bottom:4px;">${o.icone||'💡'} ${o.titulo||o.descricao||''}</div>
8635      <div style="font-size:12px;color:var(--text2);">${o.descricao||''}</div>
8636    </div>`).join('');
8637
8638  // Top Atrativos — heatmap_areas.score não existe mais (era 0-100 inventado pela IA);
8639  // hoje é google_rating (nota real do Google Places, 0-5 estrelas) — ver _retryHeatmap.
8640  const atratContent = (d.heatmap_areas||[]).slice(0,5).map(a=>{
8641    const hasRating = a.google_rating != null && !isNaN(parseFloat(a.google_rating));
8642    const ratingLabel = hasRating ? parseFloat(a.google_rating).toFixed(1) + ' ★' : '—';
8643    return `<div style="display:flex;align-items:center;gap:10px;padding:8px 0;border-bottom:1px solid var(--border);font-size:13px;">
8644      <span>${a.emoji||'📍'}</span>
8645      <span style="flex:1;">${a.nome}</span>
8646      <span style="font-family:Syne,sans-serif;font-weight:700;color:var(--accent);">${ratingLabel}</span>
8647    </div>`;
8648  }).join('');
8649
8650  // Viral
8651  const viralContent = (d.viral_predictions||[]).map(v=>
8652    `<div style="display:flex;gap:10px;padding:8px 0;border-bottom:1px solid var(--border);font-size:13px;">
8653      <span>${v.emoji||'🔥'}</span>
8654      <div style="flex:1;"><div style="font-weight:600;">${v.titulo}</div><div style="font-size:11px;color:var(--text3);">⏱ ${v.janela}</div></div>
8655      <span style="font-family:Syne,sans-serif;font-weight:700;color:var(--accent);">${v.probabilidade}%</span>
8656    </div>`).join('');
8657
8658  // Emissores
8659  const emissContent = (d.emissores||[]).slice(0,4).map(e=>
8660    `<div style="display:flex;align-items:center;gap:10px;padding:8px 0;border-bottom:1px solid var(--border);font-size:13px;">
8661      <span style="font-size:18px;">${e.flag||'🌍'}</span>
8662      <span style="flex:1;">${e.pais}</span>
8663      <span style="color:var(--text2);">${e.participacao}%</span>
8664      <span style="color:${e.tendencia==='up'?'var(--accent)':'var(--red)'};">${e.variacao}</span>
8665    </div>`).join('');
8666
8667  // Alertas de crise
8668  const crisisContent = (d.crisis_indicators||[]).length > 0
8669    ? (d.crisis_indicators||[]).map(c=>`<div style="padding:10px 14px;border-radius:8px;background:var(--surface2);border-left:3px solid var(--red);margin-bottom:8px;font-size:13px;"><strong>${c.indicador||c.tema||''}</strong><div style="color:var(--text2);font-size:12px;margin-top:3px;">${c.descricao||''}</div></div>`).join('')
8670    : '<div style="color:var(--accent);font-size:13px;">'+t('can_report_no_crisis')+'</div>';
8671
8672  const body = document.getElementById('can-report-body');
8673  body.innerHTML = `
8674    <!-- Destino header -->
8675    <div style="padding:24px;border-radius:16px;background:linear-gradient(135deg,rgba(34,211,238,0.06),rgba(217,70,239,0.06));border:1px solid rgba(34,211,238,0.2);">
8676      <div style="font-family:Syne,sans-serif;font-size:28px;font-weight:800;color:var(--text);margin-bottom:4px;">${dest}</div>
8677      <div style="font-size:13px;color:var(--text3);">${t('can_report_subtitle')}${now.toLocaleDateString(langLocale,{weekday:'long',day:'numeric',month:'long',year:'numeric'})}</div>
8678    </div>
8679
8680    ${section('📊',t('can_report_sec_kpis'), kpiContent, 'var(--accent)')}
8681    ${section('🧠',t('can_report_sec_briefing'), canContent, 'var(--magenta)')}
8682    ${section('🔍',t('can_report_sec_insights'), insightsContent, 'var(--blue)')}
8683    ${section('🎯',t('can_report_sec_actions'), actionsContent||'<div style="color:var(--text3)">--</div>', 'var(--yellow)')}
8684    ${section('💡',t('can_report_sec_opportunities'), oppsContent||'<div style="color:var(--text3)">--</div>', 'var(--accent)')}
8685    ${section('🗺️',t('can_report_sec_attractions'), atratContent||'<div style="color:var(--text3)">--</div>', 'var(--blue)')}
8686    ${section('🔥',t('can_report_sec_viral'), viralContent||'<div style="color:var(--text3)">--</div>', 'var(--orange)')}
8687    ${section('🌍',t('can_report_sec_markets'), emissContent||'<div style="color:var(--text3)">--</div>', 'var(--blue)')}
8688    ${section('🚨',t('can_report_sec_crisis'), crisisContent, 'var(--red)')}
8689
8690    <div style="text-align:center;padding:20px;font-size:11px;color:var(--text3);">
8691      ${t('can_report_footer')}${now.toLocaleDateString(langLocale)}
8692    </div>
8693  `;
8694
8695  document.getElementById('can-report-modal').style.display = 'block';
8696  document.body.style.overflow = 'hidden';
8697}
8698
8699
8700
8701// Close modal on Escape key
8702document.addEventListener('keydown', e => {
8703  if(e.key==='Escape') closeCanReport();
8704});
8705var obI18n = {
8706  'pt': {
8707    'ob_welcome': 'Bem-vindo ao',
8708    'ob_sub1': 'Inteligência estratégica para turismo. Primeiro, escolha seu idioma e segmento.',
8709    'ob_lang_label': '🌐 Idioma',
8710    'ob_seg_label': '🏢 Segmento',
8711    'ob_continue': 'Continuar →',
8712    'ob_back': '← Voltar',
8713    'ob_dest_title': 'Qual é o seu',
8714    'ob_dest_span': 'destino?',
8715    'ob_dest_sub': 'Informe o local para que a análise busque as informações corretas.',
8716    'ob_biz_title': 'Qual é o seu',
8717    'ob_biz_span': 'negócio?',
8718    'ob_biz_sub': 'Informe o nome e localização para a análise.',
8719    'ob_config_title': 'Últimas',
8720    'ob_config_span': 'configurações',
8721    'ob_config_sub': 'Período de análise, foco e provedor de IA.',
8722    'ob_period_label': '📅 Período de análise',
8723    'ob_focus_label': '🎯 Foco da análise',
8724    'ob_provider_label': '🤖 Provedor de IA',
8725    'ob_analyze': '🚀 Analisar Agora',
8726    'ob_f_country': 'País',
8727    'ob_f_state': 'Estado / Região',
8728    'ob_f_city': 'Cidade / Local',
8729    'ob_f_upload': 'Dados Primários',
8730    'ob_f_upload_opt': '(opcional — CSV, Excel ou JSON)',
8731    'ob_f_conc_dest': 'Concorrentes',
8732    'ob_f_conc_dest_opt': '(opcional — outros destinos para comparar)',
8733    'ob_f_biz_name': 'Nome do Negócio',
8734    'ob_f_biz_city': 'Cidade / Localização',
8735    'ob_f_conc': 'Concorrentes',
8736    'ob_f_conc_opt': '(até 3, nome + cidade)',
8737    'ob_seg_dest': 'Destino Turístico',
8738    'ob_seg_dest_sub': 'Cidade, região ou polo turístico',
8739    'ob_seg_hotel': 'Hotel / Pousada',
8740    'ob_seg_hotel_sub': 'Hospedagem e experiência',
8741    'ob_seg_rest': 'Restaurante',
8742    'ob_seg_rest_sub': 'Gastronomia e delivery',
8743    'ob_seg_attr': 'Atrativo / Experiência',
8744    'ob_seg_attr_sub': 'Parques, tours e vivências',
8745    'ob_seg_agency': 'Agência de Turismo',
8746    'ob_seg_agency_sub': 'Pacotes e roteiros',
8747    'ob_upload_click': 'Clique para anexar dados da sua cidade',
8748    'ob_upload_hint': 'CSV, Excel, JSON, PDF ou Word — relatórios, pesquisas, dados oficiais',
8749    'ob_api_security': 'Sua chave fica apenas no navegador. Não é enviada para nossos servidores.',
8750    'ob_p_7': '7 dias',
8751    'ob_p_15': '15 dias',
8752    'ob_p_30': '30 dias',
8753    'ob_p_90': '3 meses',
8754    'ob_p_180': '6 meses',
8755    'ob_f_city': 'Cidade / Destino *',
8756    'ob_f_conc_dest': '⚔️ Concorrentes (opcional)',
8757    'ob_conc_ph1': 'Concorrente 1', 'ob_conc_ph2': 'Concorrente 2', 'ob_conc_ph3': 'Concorrente 3',
8758    'ob_upload_click_main': 'Clique ou arraste arquivos aqui',
8759    'ob_upload_hint_main': 'PDF, DOCX, TXT — múltiplos arquivos aceitos',
8760    'ob_step3_title': 'Adicione', 'ob_step3_span': 'documentos',
8761    'ob_step3_sub': 'Opcional. Enriqueça a análise com relatórios, pesquisas ou dados internos. PDF, DOCX ou TXT.',
8762    'ob_step4_title': 'Quase lá!', 'ob_step4_span': 'Configure a IA',
8763    'ob_step4_sub': 'Selecione o provedor e insira sua chave de API para iniciar a análise.',
8764    'ob_api_label': '🔑 API Key',
8765    'ob_markets_label': '🌍 Source markets (up to 3)', 'ob_markets_hint': 'Select the countries your tourists come from. The domestic market is included automatically.',
8766    'ob_markets_label': '🌍 Mercados emissores (até 3)', 'ob_markets_hint': 'Selecione os países de onde vêm seus turistas. O mercado doméstico é incluído automaticamente.',
8767    'ob_recently': '⏱ Usados recentemente',
8768    'ob_ph_country': 'Brasil',
8769    'ob_ph_biz_name': 'Ex: Pousada do Sol',
8770    'ob_ph_biz_ig': '@seunegocio',
8771    'ob_ph_biz_site': 'www.seunegocio.com.br',
8772    'ob_lock_title': 'Confirmar destino',
8773    'ob_lock_body': 'Tem certeza de que deseja continuar com este destino? Após a primeira análise, ',
8774    'ob_lock_body_strong': 'não será possível alterar o destino configurado.',
8775    'ob_lock_selected_label': 'Destino selecionado',
8776    'ob_lock_cancel': 'Voltar',
8777    'ob_lock_confirm': 'Confirmar e Continuar',
8778    'ob_dash': 'Dashboard',
8779    'ob_logout': 'Sair',
8780    'ob_f_dest_ig': '📸 Instagram do destino',
8781    'ob_f_dest_site': '🌐 Site oficial',
8782    'ob_f_biz_ig': '📸 Instagram',
8783    'ob_f_biz_site': '🌐 Site',
8784    'ob_ph_conc_ig': '📸 @instagram',
8785    'ob_ph_conc_site': '🌐 site.com.br',
8786    'ob_opt_uf': 'UF',
8787    'ob_opt_city_ph': 'Cidade',
8788    'ob_opt_select_state_first': 'Selecione o estado',
8789    'ob_opt_direto': '⚔️ Direto',
8790    'ob_opt_geografico': '📍 Geográfico',
8791    'ob_opt_select_city_ph': 'Selecione a cidade',
8792    'ob_loading': 'Carregando…',
8793    'ob_ph_estado_free': 'Estado / Região',
8794  },
8795  'en': {
8796    'ob_welcome': 'Welcome to',
8797    'ob_sub1': 'Strategic intelligence for tourism. First, choose your language and segment.',
8798    'ob_lang_label': '🌐 Language',
8799    'ob_seg_label': '🏢 Segment',
8800    'ob_continue': 'Continue →',
8801    'ob_back': '← Back',
8802    'ob_dest_title': 'What\'s your',
8803    'ob_dest_span': 'destination?',
8804    'ob_dest_sub': 'Enter the location so the analysis can find the right data.',
8805    'ob_biz_title': 'What\'s your',
8806    'ob_biz_span': 'business?',
8807    'ob_biz_sub': 'Enter your business name and location for analysis.',
8808    'ob_config_title': 'Final',
8809    'ob_config_span': 'settings',
8810    'ob_config_sub': 'Analysis period, focus and AI provider.',
8811    'ob_period_label': '📅 Analysis period',
8812    'ob_focus_label': '🎯 Analysis focus',
8813    'ob_provider_label': '🤖 AI Provider',
8814    'ob_analyze': '🚀 Analyze Now',
8815    'ob_f_country': 'Country',
8816    'ob_f_state': 'State / Region',
8817    'ob_f_city': 'City / Location',
8818    'ob_f_upload': 'Primary Data',
8819    'ob_f_upload_opt': '(optional — CSV, Excel or JSON)',
8820    'ob_f_conc_dest': 'Competitors',
8821    'ob_f_conc_dest_opt': '(optional — other destinations to compare)',
8822    'ob_f_biz_name': 'Business Name',
8823    'ob_f_biz_city': 'City / Location',
8824    'ob_f_conc': 'Competitors',
8825    'ob_f_conc_opt': '(up to 3, name + city)',
8826    'ob_seg_dest': 'Tourist Destination',
8827    'ob_seg_dest_sub': 'City, region or tourism hub',
8828    'ob_seg_hotel': 'Hotel / Inn',
8829    'ob_seg_hotel_sub': 'Accommodation and experience',
8830    'ob_seg_rest': 'Restaurant',
8831    'ob_seg_rest_sub': 'Gastronomy and delivery',
8832    'ob_seg_attr': 'Attraction / Experience',
8833    'ob_seg_attr_sub': 'Parks, tours and activities',
8834    'ob_seg_agency': 'Travel Agency',
8835    'ob_seg_agency_sub': 'Packages and itineraries',
8836    'ob_upload_click': 'Click to attach your city data',
8837    'ob_upload_hint': 'Demand surveys, visitation data, official reports',
8838    'ob_api_security': 'Your key stays in the browser only. Never sent to our servers.',
8839    'ob_p_7': '7 days',
8840    'ob_p_15': '15 days',
8841    'ob_p_30': '30 days',
8842    'ob_p_90': '3 months',
8843    'ob_p_180': '6 months',
8844    'ob_f_city': 'City / Destination *',
8845    'ob_f_conc_dest': '⚔️ Competitors (optional)',
8846    'ob_conc_ph1': 'Competitor 1', 'ob_conc_ph2': 'Competitor 2', 'ob_conc_ph3': 'Competitor 3',
8847    'ob_upload_click_main': 'Click or drag files here',
8848    'ob_upload_hint_main': 'PDF, DOCX, TXT — multiple files accepted',
8849    'ob_step3_title': 'Add', 'ob_step3_span': 'documents',
8850    'ob_step3_sub': 'Optional. Enrich the analysis with reports, research or internal data. PDF, DOCX or TXT.',
8851    'ob_step4_title': 'Almost there!', 'ob_step4_span': 'Configure AI',
8852    'ob_step4_sub': 'Select the provider and enter your API key to start the analysis.',
8853    'ob_api_label': '🔑 API Key',
8854    'ob_markets_label': '🌍 Source markets (up to 3)', 'ob_markets_hint': 'Select the countries your tourists come from. The domestic market is included automatically.',
8855    'ob_markets_label': '🌍 Mercados emissores (até 3)', 'ob_markets_hint': 'Selecione os países de onde vêm seus turistas. O mercado doméstico é incluído automaticamente.',
8856    'ob_recently': '⏱ Recently used',
8857    'ob_ph_country': 'Brazil',
8858    'ob_ph_biz_name': 'E.g. Sunset Inn',
8859    'ob_ph_biz_ig': '@yourbusiness',
8860    'ob_ph_biz_site': 'www.yourbusiness.com',
8861    'ob_lock_title': 'Confirm destination',
8862    'ob_lock_body': 'Are you sure you want to continue with this destination? After the first analysis, ',
8863    'ob_lock_body_strong': 'you won\'t be able to change the configured destination.',
8864    'ob_lock_selected_label': 'Selected destination',
8865    'ob_lock_cancel': 'Back',
8866    'ob_lock_confirm': 'Confirm and Continue',
8867    'ob_dash': 'Dashboard',
8868    'ob_logout': 'Log out',
8869    'ob_f_dest_ig': '📸 Destination Instagram',
8870    'ob_f_dest_site': '🌐 Official website',
8871    'ob_f_biz_ig': '📸 Instagram',
8872    'ob_f_biz_site': '🌐 Website',
8873    'ob_ph_conc_ig': '📸 @instagram',
8874    'ob_ph_conc_site': '🌐 website.com',
8875    'ob_opt_uf': 'State',
8876    'ob_opt_city_ph': 'City',
8877    'ob_opt_select_state_first': 'Select the state first',
8878    'ob_opt_direto': '⚔️ Direct',
8879    'ob_opt_geografico': '📍 Geographic',
8880    'ob_opt_select_city_ph': 'Select the city',
8881    'ob_loading': 'Loading…',
8882    'ob_ph_estado_free': 'State / Region',
8883  },
8884  'es': {
8885    'ob_welcome': 'Bienvenido a',
8886    'ob_sub1': 'Inteligencia estratégica para el turismo. Primero, elige tu idioma y segmento.',
8887    'ob_lang_label': '🌐 Idioma',
8888    'ob_seg_label': '🏢 Segmento',
8889    'ob_continue': 'Continuar →',
8890    'ob_back': '← Volver',
8891    'ob_dest_title': '¿Cuál es tu',
8892    'ob_dest_span': 'destino?',
8893    'ob_dest_sub': 'Ingresa la ubicación para que el análisis encuentre los datos correctos.',
8894    'ob_biz_title': '¿Cuál es tu',
8895    'ob_biz_span': 'negocio?',
8896    'ob_biz_sub': 'Ingresa el nombre y la ubicación de tu negocio.',
8897    'ob_config_title': 'Últimas',
8898    'ob_config_span': 'configuraciones',
8899    'ob_config_sub': 'Período de análisis, enfoque y proveedor de IA.',
8900    'ob_period_label': '📅 Período de análisis',
8901    'ob_focus_label': '🎯 Enfoque del análisis',
8902    'ob_provider_label': '🤖 Proveedor de IA',
8903    'ob_analyze': '🚀 Analizar Ahora',
8904    'ob_f_country': 'País',
8905    'ob_f_state': 'Estado / Región',
8906    'ob_f_city': 'Ciudad / Lugar',
8907    'ob_f_upload': 'Datos Primarios',
8908    'ob_f_upload_opt': '(opcional — CSV, Excel o JSON)',
8909    'ob_f_conc_dest': 'Competidores',
8910    'ob_f_conc_dest_opt': '(opcional — otros destinos para comparar)',
8911    'ob_f_biz_name': 'Nombre del Negocio',
8912    'ob_f_biz_city': 'Ciudad / Ubicación',
8913    'ob_f_conc': 'Competidores',
8914    'ob_f_conc_opt': '(hasta 3, nombre + ciudad)',
8915    'ob_seg_dest': 'Destino Turístico',
8916    'ob_seg_dest_sub': 'Ciudad, región o polo turístico',
8917    'ob_seg_hotel': 'Hotel / Posada',
8918    'ob_seg_hotel_sub': 'Hospedaje y experiencia',
8919    'ob_seg_rest': 'Restaurante',
8920    'ob_seg_rest_sub': 'Gastronomía y delivery',
8921    'ob_seg_attr': 'Atractivo / Experiencia',
8922    'ob_seg_attr_sub': 'Parques, tours y vivencias',
8923    'ob_seg_agency': 'Agencia de Turismo',
8924    'ob_seg_agency_sub': 'Paquetes e itinerarios',
8925    'ob_upload_click': 'Clic para adjuntar datos de tu ciudad',
8926    'ob_upload_hint': 'Encuestas de demanda, datos de visitación, informes oficiales',
8927    'ob_api_security': 'Tu clave se guarda solo en el navegador. No se envía a nuestros servidores.',
8928    'ob_p_7': '7 días',
8929    'ob_p_15': '15 días',
8930    'ob_p_30': '30 días',
8931    'ob_p_90': '3 meses',
8932    'ob_p_180': '6 meses',
8933    'ob_f_city': 'Cidade / Destino *',
8934    'ob_f_conc_dest': '⚔️ Concorrentes (opcional)',
8935    'ob_conc_ph1': 'Concorrente 1', 'ob_conc_ph2': 'Concorrente 2', 'ob_conc_ph3': 'Concorrente 3',
8936    'ob_upload_click_main': 'Clique ou arraste arquivos aqui',
8937    'ob_upload_hint_main': 'PDF, DOCX, TXT — múltiplos arquivos aceitos',
8938    'ob_step3_title': 'Adicione', 'ob_step3_span': 'documentos',
8939    'ob_step3_sub': 'Opcional. Enriqueça a análise com relatórios, pesquisas ou dados internos. PDF, DOCX ou TXT.',
8940    'ob_step4_title': 'Quase lá!', 'ob_step4_span': 'Configure a IA',
8941    'ob_step4_sub': 'Selecione o provedor e insira sua chave de API para iniciar a análise.',
8942    'ob_api_label': '🔑 API Key',
8943    'ob_markets_label': '🌍 Source markets (up to 3)', 'ob_markets_hint': 'Select the countries your tourists come from. The domestic market is included automatically.',
8944    'ob_markets_label': '🌍 Mercados emissores (até 3)', 'ob_markets_hint': 'Selecione os países de onde vêm seus turistas. O mercado doméstico é incluído automaticamente.',
8945    'ob_recently': '⏱ Usados recientemente',
8946    'ob_ph_country': 'Brasil',
8947    'ob_ph_biz_name': 'Ej: Posada del Sol',
8948    'ob_ph_biz_ig': '@tunegocio',
8949    'ob_ph_biz_site': 'www.tunegocio.com',
8950    'ob_lock_title': 'Confirmar destino',
8951    'ob_lock_body': '¿Estás seguro de que deseas continuar con este destino? Después del primer análisis, ',
8952    'ob_lock_body_strong': 'no será posible cambiar el destino configurado.',
8953    'ob_lock_selected_label': 'Destino seleccionado',
8954    'ob_lock_cancel': 'Volver',
8955    'ob_lock_confirm': 'Confirmar y Continuar',
8956    'ob_dash': 'Dashboard',
8957    'ob_logout': 'Salir',
8958    'ob_f_dest_ig': '📸 Instagram del destino',
8959    'ob_f_dest_site': '🌐 Sitio oficial',
8960    'ob_f_biz_ig': '📸 Instagram',
8961    'ob_f_biz_site': '🌐 Sitio web',
8962    'ob_ph_conc_ig': '📸 @instagram',
8963    'ob_ph_conc_site': '🌐 sitio.com',
8964    'ob_opt_uf': 'Provincia',
8965    'ob_opt_city_ph': 'Ciudad',
8966    'ob_opt_select_state_first': 'Selecciona el estado',
8967    'ob_opt_direto': '⚔️ Directo',
8968    'ob_opt_geografico': '📍 Geográfico',
8969    'ob_opt_select_city_ph': 'Selecciona la ciudad',
8970    'ob_loading': 'Cargando…',
8971    'ob_ph_estado_free': 'Estado / Región',
8972  },
8973  'fr': {
8974    'ob_welcome': 'Bienvenue sur',
8975    'ob_sub1': 'Intelligence stratégique pour le tourisme. D\'abord, choisissez votre langue et segment.',
8976    'ob_lang_label': '🌐 Langue',
8977    'ob_seg_label': '🏢 Segment',
8978    'ob_continue': 'Continuer →',
8979    'ob_back': '← Retour',
8980    'ob_dest_title': 'Quelle est votre',
8981    'ob_dest_span': 'destination?',
8982    'ob_dest_sub': 'Entrez le lieu pour que l\'analyse trouve les bonnes données.',
8983    'ob_biz_title': 'Quel est votre',
8984    'ob_biz_span': 'établissement?',
8985    'ob_biz_sub': 'Entrez le nom et la localisation de votre établissement.',
8986    'ob_config_title': 'Derniers',
8987    'ob_config_span': 'réglages',
8988    'ob_config_sub': 'Période d\'analyse, focus et fournisseur IA.',
8989    'ob_period_label': '📅 Période d\'analyse',
8990    'ob_focus_label': '🎯 Focus de l\'analyse',
8991    'ob_provider_label': '🤖 Fournisseur IA',
8992    'ob_analyze': '🚀 Analyser Maintenant',
8993    'ob_f_country': 'Pays',
8994    'ob_f_state': 'État / Région',
8995    'ob_f_city': 'Ville / Lieu',
8996    'ob_f_upload': 'Données Primaires',
8997    'ob_f_upload_opt': '(optionnel — CSV, Excel ou JSON)',
8998    'ob_f_conc_dest': 'Concurrents',
8999    'ob_f_conc_dest_opt': '(optionnel — autres destinations à comparer)',
9000    'ob_f_biz_name': 'Nom de l\'Établissement',
9001    'ob_f_biz_city': 'Ville / Localisation',
9002    'ob_f_conc': 'Concurrents',
9003    'ob_f_conc_opt': '(jusqu\'à 3, nom + ville)',
9004    'ob_seg_dest': 'Destination Touristique',
9005    'ob_seg_dest_sub': 'Ville, région ou pôle touristique',
9006    'ob_seg_hotel': 'Hôtel / Auberge',
9007    'ob_seg_hotel_sub': 'Hébergement et expérience',
9008    'ob_seg_rest': 'Restaurant',
9009    'ob_seg_rest_sub': 'Gastronomie et livraison',
9010    'ob_seg_attr': 'Attraction / Expérience',
9011    'ob_seg_attr_sub': 'Parcs, tours et activités',
9012    'ob_seg_agency': 'Agence de Tourisme',
9013    'ob_seg_agency_sub': 'Forfaits et itinéraires',
9014    'ob_upload_click': 'Cliquez pour joindre les données de votre ville',
9015    'ob_upload_hint': 'Enquêtes de demande, données de visite, rapports officiels',
9016    'ob_api_security': 'Votre clé reste dans le navigateur. Jamais envoyée à nos serveurs.',
9017    'ob_p_7': '7 jours',
9018    'ob_p_15': '15 jours',
9019    'ob_p_30': '30 jours',
9020    'ob_p_90': '3 mois',
9021    'ob_p_180': '6 mois',
9022    'ob_f_city': 'Ville / Destination *',
9023    'ob_f_conc_dest': '⚔️ Concurrents (optionnel)',
9024    'ob_conc_ph1': 'Concurrent 1', 'ob_conc_ph2': 'Concurrent 2', 'ob_conc_ph3': 'Concurrent 3',
9025    'ob_upload_click_main': 'Cliquez ou glissez des fichiers ici',
9026    'ob_upload_hint_main': 'PDF, DOCX, TXT — fichiers multiples acceptés',
9027    'ob_step3_title': 'Ajoutez des', 'ob_step3_span': 'documents',
9028    'ob_step3_sub': 'Optionnel. Enrichissez l\'analyse avec des rapports, recherches ou données internes. PDF, DOCX ou TXT.',
9029    'ob_step4_title': 'Presque terminé!', 'ob_step4_span': 'Configurez l\'IA',
9030    'ob_step4_sub': 'Sélectionnez le fournisseur et entrez votre clé API pour démarrer l\'analyse.',
9031    'ob_api_label': '🔑 Clé API',
9032    'ob_markets_label': '🌍 Marchés émetteurs (max 3)', 'ob_markets_hint': 'Sélectionnez les pays d\'origine de vos touristes. Le marché domestique est inclus automatiquement.',
9033    'ob_f_city': 'Ville / Destination *',
9034    'ob_f_conc_dest': '⚔️ Concurrents (optionnel)',
9035    'ob_conc_ph1': 'Concurrent 1', 'ob_conc_ph2': 'Concurrent 2', 'ob_conc_ph3': 'Concurrent 3',
9036    'ob_upload_click_main': 'Cliquez ou glissez des fichiers ici',
9037    'ob_upload_hint_main': 'PDF, DOCX, TXT — fichiers multiples acceptés',
9038    'ob_step3_title': 'Ajoutez des', 'ob_step3_span': 'documents',
9039    'ob_step3_sub': 'Optionnel. Enrichissez l\'analyse avec des rapports, recherches ou données internes. PDF, DOCX ou TXT.',
9040    'ob_step4_title': 'Presque terminé!', 'ob_step4_span': 'Configurez l\'IA',
9041    'ob_step4_sub': 'Sélectionnez le fournisseur et entrez votre clé API pour démarrer l\'analyse.',
9042    'ob_api_label': '🔑 Clé API',
9043    'ob_recently': '⏱ Utilisés récemment',
9044    'ob_ph_country': 'Brésil',
9045    'ob_ph_biz_name': 'Ex: Auberge du Soleil',
9046    'ob_ph_biz_ig': '@votreentreprise',
9047    'ob_ph_biz_site': 'www.votreentreprise.com',
9048    'ob_lock_title': 'Confirmer la destination',
9049    'ob_lock_body': 'Êtes-vous sûr de vouloir continuer avec cette destination ? Après la première analyse, ',
9050    'ob_lock_body_strong': 'il ne sera plus possible de modifier la destination configurée.',
9051    'ob_lock_selected_label': 'Destination sélectionnée',
9052    'ob_lock_cancel': 'Retour',
9053    'ob_lock_confirm': 'Confirmer et Continuer',
9054    'ob_dash': 'Dashboard',
9055    'ob_logout': 'Déconnexion',
9056    'ob_f_dest_ig': '📸 Instagram de la destination',
9057    'ob_f_dest_site': '🌐 Site officiel',
9058    'ob_f_biz_ig': '📸 Instagram',
9059    'ob_f_biz_site': '🌐 Site web',
9060    'ob_ph_conc_ig': '📸 @instagram',
9061    'ob_ph_conc_site': '🌐 site.com',
9062    'ob_opt_uf': 'État',
9063    'ob_opt_city_ph': 'Ville',
9064    'ob_opt_select_state_first': 'Sélectionnez l\'état d\'abord',
9065    'ob_opt_direto': '⚔️ Direct',
9066    'ob_opt_geografico': '📍 Géographique',
9067    'ob_opt_select_city_ph': 'Sélectionnez la ville',
9068    'ob_loading': 'Chargement…',
9069    'ob_ph_estado_free': 'État / Région',
9070  },
9071};
9072
9073function ob_t(key) {
9074  return (obI18n[OB.lang] || obI18n['pt'])[key] || obI18n['pt'][key] || key;
9075}
9076
9077function applyObI18n() {
9078  const l = OB.lang;
9079  // Step 1
9080  const titleEl = document.querySelector('#ob-step1 .ob-title');
9081  if(titleEl) titleEl.innerHTML = ob_t('ob_welcome') + ' <span class="gr">Consensus Pulse</span>';
9082  const subEl = document.querySelector('#ob-step1 .ob-sub');
9083  if(subEl) subEl.textContent = ob_t('ob_sub1');
9084  // Labels
9085  const langLbl = document.querySelector('#ob-step1 .ob-field-label');
9086  if(langLbl) langLbl.innerHTML = ob_t('ob_lang_label');
9087  // Segment labels
9088  const segCards = document.querySelectorAll('#ob-step1 [data-sector]');
9089  const segKeys = ['dest','hotel','rest','attr','agency'];
9090  segCards.forEach((c,i) => {
9091    const k = segKeys[i];
9092    const lbl = c.querySelector('.ob-card-label');
9093    const sub = c.querySelector('.ob-card-sub');
9094    if(lbl) lbl.textContent = ob_t('ob_seg_'+k);
9095    if(sub) sub.textContent = ob_t('ob_seg_'+k+'_sub');
9096  });
9097  // Continue button step 1
9098  const btn1 = document.querySelector('#ob-step1 .ob-btn-next');
9099  if(btn1) btn1.textContent = ob_t('ob_continue');
9100
9101  // Step 2 — destino
9102  const d2title = document.querySelector('#ob-form-destino .ob-title');
9103  if(d2title) d2title.innerHTML = ob_t('ob_dest_title') + ' <span class="gr">' + ob_t('ob_dest_span') + '</span>';
9104  const d2sub = document.querySelector('#ob-form-destino .ob-sub');
9105  if(d2sub) d2sub.textContent = ob_t('ob_dest_sub');
9106
9107  // Step 2 — empresa
9108  const e2title = document.querySelector('#ob-form-empresa .ob-title');
9109  if(e2title) e2title.innerHTML = ob_t('ob_biz_title') + ' <span class="gr">' + ob_t('ob_biz_span') + '</span>';
9110  const e2sub = document.querySelector('#ob-form-empresa .ob-sub');
9111  if(e2sub) e2sub.textContent = ob_t('ob_biz_sub');
9112
9113  // Step 3
9114  const s3title = document.querySelector('#ob-step3 .ob-title');
9115  if(s3title) s3title.innerHTML = ob_t('ob_config_title') + ' <span class="gr">' + ob_t('ob_config_span') + '</span>';
9116  const s3sub = document.querySelector('#ob-step3 .ob-sub');
9117  if(s3sub) s3sub.textContent = ob_t('ob_config_sub');
9118  const analyzeBtn = document.getElementById('ob-btn-analyze');
9119  if(analyzeBtn) analyzeBtn.innerHTML = ob_t('ob_analyze');
9120
9121  // Field labels — update all ob-field-label via data-ob-i18n
9122  document.querySelectorAll('[data-ob-i18n]').forEach(el => {
9123    const key = el.getAttribute('data-ob-i18n');
9124    el.textContent = ob_t(key);
9125  });
9126
9127  // Period buttons
9128  const periods = document.querySelectorAll('#ob-period-grid .ob-period-btn');
9129  const pKeys = ['ob_p_7','ob_p_15','ob_p_30','ob_p_90','ob_p_180'];
9130  periods.forEach((b,i) => { if(pKeys[i]) b.textContent = ob_t(pKeys[i]); });
9131
9132  // Back buttons
9133  document.querySelectorAll('.ob-btn-back').forEach(b => b.textContent = ob_t('ob_back'));
9134  document.querySelectorAll('.ob-btn-next:not(#ob-btn-analyze):not(#ob-next2-dest):not(#ob-next2-emp)').forEach(b => { b.textContent = ob_t('ob_continue'); });
9135  const nd = document.getElementById('ob-next2-dest');
9136  const ne = document.getElementById('ob-next2-emp');
9137  if(nd) nd.textContent = ob_t('ob_continue');
9138  if(ne) ne.textContent = ob_t('ob_continue');
9139
9140  // Upload labels
9141  const ul = document.getElementById('ob-upload-label');
9142  if(ul) ul.textContent = ob_t('ob_upload_click');
9143
9144  // Step 2 field labels with data-ob-i18n
9145  document.querySelectorAll('[data-ob-i18n]').forEach(el => {
9146    const key = el.getAttribute('data-ob-i18n');
9147    el.textContent = ob_t(key);
9148  });
9149
9150  // Step 2 placeholder translations
9151  document.querySelectorAll('[data-ob-ph]').forEach(el => {
9152    const key = el.getAttribute('data-ob-ph');
9153    el.placeholder = ob_t(key);
9154  });
9155
9156  // Step 2 focus options
9157  const obFoco = document.getElementById('ob-foco');
9158  if (obFoco) {
9159    const opts = sectorFocusOptions[OB.sector] || sectorFocusOptions.destination;
9160    obFoco.innerHTML = opts.map(o => '<option value="'+o.v+'">'+(o[OB.lang]||o.en||o.pt)+'</option>').join('');
9161  }
9162  const obFocoEmp = document.getElementById('ob-foco-emp');
9163  if (obFocoEmp) {
9164    const opts = sectorFocusOptions[OB.sector] || sectorFocusOptions.destination;
9165    obFocoEmp.innerHTML = opts.map(o => '<option value="'+o.v+'">'+(o[OB.lang]||o.en||o.pt)+'</option>').join('');
9166  }
9167
9168  // Step 3 title/sub
9169  const s3t = document.getElementById('ob-step3-title');
9170  if(s3t) s3t.innerHTML = ob_t('ob_step3_title') + ' <span class="gr">' + ob_t('ob_step3_span') + '</span>';
9171  const s3s = document.getElementById('ob-step3-sub');
9172  if(s3s) s3s.textContent = ob_t('ob_step3_sub');
9173
9174  // Step 4 title/sub
9175  const s4t = document.getElementById('ob-step4-title');
9176  if(s4t) s4t.innerHTML = ob_t('ob_step4_title') + ' <span class="gr">' + ob_t('ob_step4_span') + '</span>';
9177  const s4s = document.getElementById('ob-step4-sub');
9178  if(s4s) s4s.textContent = ob_t('ob_step4_sub');
9179
9180}
9181function sleep(ms){return new Promise(r=>setTimeout(r,ms));}
9182
9183// Init — wrapped in DOMContentLoaded for sandbox compatibility
9184macroLoad();
9185try { appendBenchmarkDigitalCSS(); } catch(e) { console.warn('benchmarkCSS:', e); }
9186try { appendGemeoCSS(); } catch(e) { console.warn('gemeoCSS:', e); }
9187if (document.readyState === 'loading') {
9188  document.addEventListener('DOMContentLoaded', function() {
9189    try { applyI18n(); } catch(e) { console.warn('applyI18n:', e); }
9190    try { refreshHistoryPanel(); } catch(e) { console.warn('refreshHistory:', e); }
9191  });
9192} else {
9193  try { applyI18n(); } catch(e) { console.warn('applyI18n:', e); }
9194  try { refreshHistoryPanel(); } catch(e) { console.warn('refreshHistory:', e); }
9195}
9196
9197// Event delegation — more reliable than inline onclick for local files
9198document.addEventListener('click', function(e) {
9199  // Lang buttons
9200  const langBtn = e.target.closest('.lang-btn');
9201  if (langBtn && langBtn.dataset.lang) {
9202    setLang(langBtn.dataset.lang);
9203    return;
9204  }
9205  // Sector cards
9206  const sectorCard = e.target.closest('.sector-card');
9207  if (sectorCard && sectorCard.dataset.sector) {
9208    setSector(sectorCard.dataset.sector);
9209    return;
9210  }
9211  // Provider buttons
9212  if (e.target.closest('#prov-claude')) { setProvider('claude'); return; }
9213  if (e.target.closest('#prov-openai')) { setProvider('openai'); return; }
9214  if (e.target.closest('#prov-gemini')) { setProvider('gemini'); return; }
9215  if (e.target.closest('#prov-grok')) { setProvider('grok'); return; }
9216  if (e.target.closest('#prov-qwen')) { setProvider('qwen'); return; }
9217  if (e.target.closest('#prov-deepseek')) { setProvider('deepseek'); return; }
9218  if (e.target.closest('#prov-mistral')) { setProvider('mistral'); return; }
9219  // Nav items
9220  const navItem = e.target.closest('.nav-item[data-page]');
9221  if (navItem && navItem.classList.contains('locked')) {
9222    e.preventDefault(); e.stopPropagation();
9223    // Toast de upgrade
9224    let t = document.getElementById('plan-upgrade-toast');
9225    if (!t) {
9226      t = document.createElement('div');
9227      t.id = 'plan-upgrade-toast';
9228      t.style.cssText = 'position:fixed;bottom:24px;left:50%;transform:translateX(-50%) translateY(80px);background:linear-gradient(135deg,#1a1a2e,#16213e);color:#e0e0e0;padding:12px 24px;border-radius:12px;font-size:13px;z-index:99999;opacity:0;transition:all 0.3s ease;border:1px solid rgba(34,211,238,0.2);box-shadow:0 8px 32px rgba(0,0,0,0.4);pointer-events:none;';
9229      document.body.appendChild(t);
9230    }
9231    t.textContent = '🔒 Recurso disponível no plano superior. Faça upgrade para desbloquear.';
9232    t.style.opacity = '1';
9233    t.style.transform = 'translateX(-50%) translateY(0)';
9234    clearTimeout(t._tid);
9235    t._tid = setTimeout(function(){ t.style.opacity='0'; t.style.transform='translateX(-50%) translateY(80px)'; }, 3000);
9236    return;
9237  }
9238  if (navItem && navItem.dataset.page) { goPage(navItem.dataset.page); return; }
9239});
9240
9241// Logout — Supabase signOut + limpa storage/cookies + redireciona
9242// ══════════════════════════════════════════
9243// ONBOARDING USER MENU
9244// ══════════════════════════════════════════
9245function obToggleUserMenu(btn) {
9246  const menu = btn.closest('.ob-user-menu');
9247  menu.classList.toggle('open');
9248}
9249
9250// Close onboarding user menu when clicking outside
9251document.addEventListener('click', (e) => {
9252  document.querySelectorAll('.ob-user-menu.open').forEach(m => {
9253    if (!m.contains(e.target)) m.classList.remove('open');
9254  });
9255});
9256
9257// Update all onboarding user menus with current user data + check for analyses
9258async function obUpdateUserMenu() {
9259  try {
9260    const supabase = await getSupabase();
9261    const { data: { session } } = await supabase.auth.getSession();
9262    if (!session) return;
9263
9264    const user = session.user;
9265    const email = user.email || '';
9266    const fullName = user.user_metadata?.full_name || email.split('@')[0];
9267    const initial = fullName.charAt(0).toUpperCase();
9268
9269    // Check if user has analyses
9270    const { data: latest } = await supabase
9271      .from('analysis_history')
9272      .select('id')
9273      .eq('user_id', user.id)
9274      .order('created_at', { ascending: false })
9275      .limit(1)
9276      .maybeSingle();
9277
9278    const hasAnalysis = !!(latest && latest.id);
9279
9280    // Buscar destino salvo do usuário (reaproveitando a mesma sessão)
9281    const { data: savedDest } = await supabase
9282      .from('user_destinations')
9283      .select('country, state, city, sector')
9284      .eq('user_id', user.id)
9285      .maybeSingle();
9286
9287    if (savedDest && savedDest.city) {
9288      savedDest.city = _deduplicateDest(savedDest.city);
9289    }
9290    window._userDestination = savedDest || null;
9291    window._userEmail = email || '';
9292
9293    // Update all step menus
9294    ['s1','s2','s3'].forEach(s => {
9295      const menu = document.getElementById('ob-user-menu-' + s);
9296      if (!menu) return;
9297      menu.style.display = 'block';
9298      const av = document.getElementById('ob-avatar-' + s);
9299      const un = document.getElementById('ob-uname-' + s);
9300      const fn = document.getElementById('ob-fullname-' + s);
9301      const em = document.getElementById('ob-email-' + s);
9302      const dash = document.getElementById('ob-dash-' + s);
9303      if (av) av.textContent = initial;
9304      if (un) un.textContent = fullName;
9305      if (fn) fn.textContent = fullName;
9306      if (em) em.textContent = email;
9307      if (dash) dash.style.display = hasAnalysis ? 'flex' : 'none';
9308    });
9309  } catch(e) {
9310    console.warn('Erro ao atualizar menu do onboarding:', e);
9311  }
9312}
9313
9314// Navigate to dashboard (load most recent analysis)
9315async function obGoToDashboard() {
9316  document.querySelectorAll('.ob-page').forEach(p => p.classList.remove('active'));
9317  document.body.classList.remove('onboarding');
9318  document.body.classList.add('loading-screen');
9319
9320  // Atualizar URL para /app/dashboard
9321  try { window.history.replaceState({ path: '/app/dashboard' }, '', '/app/dashboard'); } catch(e) {}
9322
9323  goPage('loading');
9324
9325  try {
9326    const supabase = await getSupabase();
9327    const { data: { session } } = await supabase.auth.getSession();
9328    if (session) {
9329      const { data: latest } = await supabase
9330        .from('analysis_history')
9331        .select('id')
9332        .eq('user_id', session.user.id)
9333        .order('created_at', { ascending: false })
9334        .limit(1)
9335        .maybeSingle();
9336      if (latest && latest.id) {
9337        sessionStorage.setItem('cp_active_analysis', latest.id);
9338        const { data: row } = await supabase
9339          .from('analysis_history')
9340          .select('*')
9341          .eq('id', latest.id)
9342          .single();
9343        if (row) {
9344          document.body.classList.remove('loading-screen');
9345          loadRecord({
9346            dest: row.destination,
9347            sector: row.sector,
9348            lang: row.lang,
9349            data: row.analysis_data,
9350            concs: row.competitors || [],
9351            periodo: row.period || '30'
9352          });
9353          return;
9354        }
9355      }
9356    }
9357  } catch(e) {
9358    console.warn('Erro ao navegar para dashboard:', e);
9359  }
9360  // Fallback — sem análise encontrada, voltar ao onboarding
9361  if (window.AppRouter) {
9362    AppRouter.navigateTo('/app/onboarding', { replace: true });
9363  } else {
9364    document.body.classList.remove('loading-screen');
9365    document.body.classList.add('onboarding');
9366    if (window._userDestination && !_isBypassUser()) {
9367      if (window._userDestination.sector) OB.sector = window._userDestination.sector;
9368      document.getElementById('ob-step2').classList.add('active');
9369    } else {
9370      document.getElementById('ob-step1').classList.add('active');
9371    }
9372  }
9373}
9374
9375async function doLogout() {
9376  if (!confirm('Deseja sair da plataforma?')) return;
9377  try {
9378    var sbUrl = 'https://mhijkifejmycvcnybinw.supabase.co';
9379    var sbKey = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im1oaWpraWZlam15Y3ZjbnliaW53Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3ODY5MDA4NjAsImV4cCI6MjEwMjQ3Njg2MH0.iF9DyFRxz8OCh3oppxdAjaHylBxb9Ht4qbbbVXcHbNQ';
9380    var accessToken = '';
9381    for (var i = 0; i < localStorage.length; i++) {
9382      var k = localStorage.key(i);
9383      if (k && k.startsWith('sb-') && k.endsWith('-auth-token')) {
9384        try { accessToken = JSON.parse(localStorage.getItem(k)).access_token || ''; } catch(e){}
9385      }
9386    }
9387    // Registrar logout no feed de atividade antes de deslogar
9388    if (accessToken) {
9389      try {
9390        var sb = window.supabase || (typeof getSupabase === 'function' ? await getSupabase() : null);
9391        if (sb) {
9392          var sess = await sb.auth.getSession();
9393          if (sess?.data?.session?.user?.email) {
9394            await sb.rpc('log_auth_event', { p_event_type: 'logout', p_metadata: { email: sess.data.session.user.email } });
9395          }
9396        }
9397      } catch(e) { console.warn('Feed log error:', e); }
9398      await fetch(sbUrl + '/auth/v1/logout', {
9399        method: 'POST',
9400        headers: { 'Authorization': 'Bearer ' + accessToken, 'apikey': sbKey }
9401      }).catch(function(){});
9402    }
9403  } catch(e) { console.warn('Logout API error:', e); }
9404  // Limpar localStorage (cp_, sb-, supabase) — preservando a sessão ADMIN isolada
9405  var ADMIN_STORAGE_KEY = 'sb-pulse-admin';
9406  var keysToRemove = [];
9407  for (var j = 0; j < localStorage.length; j++) {
9408    var key = localStorage.key(j);
9409    if (!key || key === ADMIN_STORAGE_KEY) continue; // preservar sessão admin
9410    if (key.startsWith('cp_') || key.startsWith('sb-') || key.startsWith('supabase')) keysToRemove.push(key);
9411  }
9412  keysToRemove.forEach(function(k){ localStorage.removeItem(k); });
9413  sessionStorage.clear();
9414  // Limpar cookies do Supabase
9415  document.cookie.split(';').forEach(function(c) {
9416    var name = c.split('=')[0].trim();
9417    if (name.startsWith('sb-') || name.startsWith('supabase')) {
9418      document.cookie = name + '=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/';
9419    }
9420  });
9421  window.location.href = '/';
9422}
9423
9424// ===== USER PLAN PAGE =====
9425async function loadUserPlanPage() {
9426  try {
9427    const supabase = await getSupabase();
9428    const { data: { session } } = await supabase.auth.getSession();
9429    if (!session) return;
9430
9431    const user = session.user;
9432    document.getElementById('up-email').textContent = user.email || '';
9433
9434    // Buscar perfil
9435    const { data: profile } = await supabase
9436      .from('profiles')
9437      .select('plan_type, stripe_customer_id')
9438      .eq('id', user.id)
9439      .single();
9440
9441    const plan = profile?.plan_type || 'free';
9442    const planNames = { free: t('up_plan_free'), basic: t('up_plan_basic'), premium: t('up_plan_premium'), oracle: t('up_plan_oracle'), enterprise: t('up_plan_enterprise') };
9443    document.getElementById('up-plan-name').textContent = planNames[plan] || plan;
9444
9445    // Buscar assinatura (mais recente, qualquer status)
9446    const { data: sub } = await supabase
9447      .from('subscriptions')
9448      .select('status, current_period_end, cancel_at_period_end, stripe_subscription_id')
9449      .eq('user_id', user.id)
9450      .order('created_at', { ascending: false })
9451      .limit(1)
9452      .maybeSingle();
9453
9454    const statusEl = document.getElementById('up-status');
9455    const periodEl = document.getElementById('up-period-end');
9456    const cancelInfo = document.getElementById('up-cancel-info');
9457    const btnCancel = document.getElementById('up-btn-cancel');
9458
9459    const upLocale = { pt: 'pt-BR', en: 'en-US', es: 'es-ES', fr: 'fr-FR' }[(window.G && window.G.lang) || 'pt'] || 'pt-BR';
9460
9461    if (sub && sub.stripe_subscription_id) {
9462      const statusMap = { active: t('up_status_active'), canceled: t('up_status_canceled'), past_due: t('up_status_past_due'), trialing: t('up_status_trialing') };
9463      statusEl.textContent = statusMap[sub.status] || sub.status;
9464      statusEl.style.color = sub.status === 'active' ? 'var(--green)' : sub.status === 'canceled' ? 'var(--red)' : 'var(--yellow)';
9465
9466      if (sub.current_period_end) {
9467        const endDate = new Date(sub.current_period_end);
9468        periodEl.textContent = endDate.toLocaleDateString(upLocale, { day: '2-digit', month: 'long', year: 'numeric' });
9469      } else {
9470        periodEl.textContent = t('up_renewal_auto');
9471      }
9472
9473      if (sub.cancel_at_period_end) {
9474        cancelInfo.style.display = 'block';
9475        const endDate = sub.current_period_end ? new Date(sub.current_period_end).toLocaleDateString(upLocale, { day: '2-digit', month: 'long', year: 'numeric' }) : '—';
9476        document.getElementById('up-cancel-msg').textContent = t('up_cancel_msg').replace('{date}', endDate);
9477        btnCancel.style.display = 'none';
9478      }
9479
9480      if (sub.status !== 'active') {
9481        btnCancel.style.display = 'none';
9482      }
9483    } else {
9484      // Sem assinatura Stripe — plano atribuído manualmente
9485      if (plan !== 'free') {
9486        statusEl.textContent = t('up_status_active');
9487        statusEl.style.color = 'var(--green)';
9488        periodEl.textContent = t('up_renewal_admin');
9489        btnCancel.style.display = 'none'; // Não pode cancelar sem Stripe
9490      } else {
9491        statusEl.textContent = t('up_status_no_plan');
9492        statusEl.style.color = 'var(--text3)';
9493        periodEl.textContent = '—';
9494        btnCancel.style.display = 'none';
9495      }
9496    }
9497
9498    // Buscar contagem de análises do mês
9499    const now = new Date();
9500    const firstDay = new Date(now.getFullYear(), now.getMonth(), 1).toISOString();
9501    const { count } = await supabase
9502      .from('analysis_history')
9503      .select('id', { count: 'exact', head: true })
9504      .eq('user_id', user.id)
9505      .gte('created_at', firstDay);
9506    document.getElementById('up-analyses-count').textContent = (count || 0) + ' ' + t('up_analyses_suffix');
9507
9508  } catch (e) {
9509    console.error('Erro ao carregar página de plano:', e);
9510  }
9511}
9512
9513async function cancelUserPlan() {
9514  if (!confirm(t('up_confirm_cancel'))) return;
9515
9516  const btn = document.getElementById('up-btn-cancel');
9517  btn.disabled = true;
9518  btn.textContent = t('up_btn_cancelling');
9519
9520  try {
9521    const supabase = await getSupabase();
9522    const { data: { session } } = await supabase.auth.getSession();
9523    if (!session) throw new Error(t('up_not_authenticated'));
9524
9525    const res = await fetch(SUPABASE_URL + '/functions/v1/stripe-cancel', {
9526      method: 'POST',
9527      headers: {
9528        'Content-Type': 'application/json',
9529        'Authorization': 'Bearer ' + session.access_token,
9530      },
9531    });
9532
9533    const data = await res.json();
9534    if (!data.success) throw new Error(data.error || t('up_cancel_generic_error'));
9535
9536    const upLocale = { pt: 'pt-BR', en: 'en-US', es: 'es-ES', fr: 'fr-FR' }[(window.G && window.G.lang) || 'pt'] || 'pt-BR';
9537    alert(t('up_cancel_success').replace('{date}', new Date(data.period_end).toLocaleDateString(upLocale, { day: '2-digit', month: 'long', year: 'numeric' })));
9538    loadUserPlanPage(); // Recarregar dados
9539  } catch (e) {
9540    console.error('Erro ao cancelar plano:', e);
9541    alert(t('up_cancel_error').replace('{msg}', e.message));
9542    btn.disabled = false;
9543    btn.textContent = t('up_btn_cancel');
9544  }
9545}
9546
9547// ===== TROCAR SENHA =====
9548async function changeUserPassword() {
9549  const newPass = (document.getElementById('up-new-password')?.value || '').trim();
9550  const confirmPass = (document.getElementById('up-confirm-password')?.value || '').trim();
9551  const msgEl = document.getElementById('up-password-msg');
9552  const btn = document.getElementById('up-btn-password');
9553
9554  function showMsg(text, type) {
9555    if (!msgEl) return;
9556    msgEl.style.display = 'block';
9557    msgEl.textContent = text;
9558    msgEl.style.background = type === 'ok' ? 'rgba(16,185,129,0.08)' : 'rgba(239,68,68,0.08)';
9559    msgEl.style.color = type === 'ok' ? 'var(--green)' : 'var(--red)';
9560    msgEl.style.border = '1px solid ' + (type === 'ok' ? 'rgba(16,185,129,0.2)' : 'rgba(239,68,68,0.2)');
9561  }
9562
9563  if (!newPass || newPass.length < 6) {
9564    showMsg(t('up_pass_too_short'), 'err');
9565    return;
9566  }
9567  if (newPass !== confirmPass) {
9568    showMsg(t('up_pass_mismatch'), 'err');
9569    return;
9570  }
9571
9572  btn.disabled = true;
9573  btn.textContent = t('up_btn_changing');
9574
9575  try {
9576    const supabase = await getSupabase();
9577    const { error } = await supabase.auth.updateUser({ password: newPass });
9578    if (error) throw error;
9579
9580    showMsg(t('up_pass_success'), 'ok');
9581    document.getElementById('up-new-password').value = '';
9582    document.getElementById('up-confirm-password').value = '';
9583  } catch (e) {
9584    console.error('Erro ao trocar senha:', e);
9585    showMsg(t('up_pass_error').replace('{msg}', e.message || t('up_pass_generic_error')), 'err');
9586  } finally {
9587    btn.disabled = false;
9588    btn.textContent = t('up_btn_password');
9589  }
9590}
9591
9592// ===== EXPIRED PLAN CHECK =====
9593async function checkExpiredPlan() {
9594  try {
9595    const supabase = await getSupabase();
9596    const { data: { session } } = await supabase.auth.getSession();
9597    if (!session) return;
9598
9599    const { data: profile } = await supabase
9600      .from('profiles')
9601      .select('plan_type')
9602      .eq('id', session.user.id)
9603      .single();
9604
9605    if (!profile || profile.plan_type !== 'free') return;
9606
9607    // Verificar se já teve plano antes
9608    const { data: pastSub } = await supabase
9609      .from('subscriptions')
9610      .select('id, status')
9611      .eq('user_id', session.user.id)
9612      .eq('status', 'canceled')
9613      .limit(1)
9614      .maybeSingle();
9615
9616    if (!pastSub) return; // Nunca teve plano, é free normal
9617
9618    // Mostrar modal de reativação
9619    const reactivate = confirm('⚠️ Seu plano expirou.\n\nDeseja ver os planos disponíveis para reativar sua conta?');
9620    if (reactivate) {
9621      window.location.href = '/auth';
9622    } else {
9623      doLogout();
9624    }
9625  } catch (e) {
9626    console.warn('Erro ao verificar plano expirado:', e);
9627  }
9628}
9629
9630// Reset app to onboarding flow
9631async function resetToOnboarding() {
9632  // Clear active analysis from sessionStorage
9633  sessionStorage.removeItem('cp_active_analysis');
9634  // Limpa arquivos da análise anterior da memória. Sem isso, o próximo
9635  // callClaude pegaria file_ids residuais via getActiveAnalysisFileIds()
9636  // e dispararia RAG indevidamente contra documento de análise antiga.
9637  if (typeof OB_files !== 'undefined' && Array.isArray(OB_files)) {
9638    OB_files.length = 0;
9639    if (typeof obRenderFileList === 'function') {
9640      try { obRenderFileList(); } catch (_e) {}
9641    }
9642  }
9643  if (typeof TOPBAR_files !== 'undefined' && Array.isArray(TOPBAR_files)) {
9644    TOPBAR_files.length = 0;
9645  }
9646  // Reset CTA attempt flags — nova análise zera o "já tentou rodar este módulo"
9647  window._ctaAttempted = {};
9648  // Reset flags dos módulos básicos (background + auto-load)
9649  window._autoLoadAttempted = {};
9650  window._bgLoading = {};
9651  window._analysisRunToken = (window._analysisRunToken || 0) + 1;  // aborta qualquer background em andamento
9652  // Clear G state
9653  G.dest = ''; G.periodo = ''; G.concs = []; G.data = null;
9654  G.lastData = null;
9655  window._lastAnalysisData = null;
9656  window._benchmarkDigital = null;  // SEO/PageSpeed do benchmark (evita vazar no export após reset)
9657  if (typeof resetCanActionPlan === 'function') resetCanActionPlan();  // limpa plano do Can (memória + DOM)
9658  if (typeof resetGemeoSimulation === 'function') resetGemeoSimulation();  // limpa simulação do Gêmeo (memória + DOM)
9659  // Clear page-specific chat histories
9660  if (typeof oracleDebateHistory !== 'undefined') oracleDebateHistory = [];
9661  if (typeof oracleChatHistory !== 'undefined') oracleChatHistory = [];
9662  if (typeof oracleAnalysisCtx !== 'undefined') oracleAnalysisCtx = '';
9663  if (typeof canChatHistory !== 'undefined') canChatHistory = [];
9664  // Reset OB state (keep lang and sector)
9665  if (typeof OB !== 'undefined') {
9666    OB.dest = ''; OB.period = ''; OB.concs = [];
9667  }
9668
9669  // Hide post-analysis topbar buttons
9670  const docsBtn = document.getElementById('btn-docs');
9671  if (docsBtn) docsBtn.style.display = 'none';
9672  const reportBtn = document.getElementById('btn-report');
9673  if (reportBtn) reportBtn.style.display = 'none';
9674  if (typeof closeDocsPanel === 'function') closeDocsPanel();
9675
9676  // Campos de destino que NÃO devem ser limpos quando lock existe
9677  const destFieldIds = ['ob-pais','ob-estado','ob-cidade','ob-empresa-pais','ob-empresa-estado','ob-empresa-cidade'];
9678  const hasLock = !!window._userDestination;
9679
9680  // Clear onboarding inputs (exceto destinos se lock ativo)
9681  ['ob-pais','ob-estado','ob-cidade','ob-empresa','ob-empresa-pais','ob-empresa-estado','ob-empresa-cidade',
9682   'ob-dest-conc1','ob-dest-conc2','ob-dest-conc3','ob-conc1','ob-conc2','ob-conc3'].forEach(id => {
9683    if (hasLock && destFieldIds.includes(id)) return; // Não limpar campos de destino travados
9684    const el = document.getElementById(id);
9685    if (el) el.value = '';
9686  });
9687
9688  // Carregar dados do usuário ANTES de navegar
9689  await obUpdateUserMenu();
9690
9691  // Restaurar sector do destino salvo
9692  if (window._userDestination && window._userDestination.sector) {
9693    OB.sector = window._userDestination.sector;
9694  }
9695
9696  // Usar router para navegar ao step correto
9697  if (window.AppRouter) {
9698    AppRouter.navigateTo('/app/onboarding', { replace: true });
9699  } else {
9700    // Fallback sem router
9701    const _sb = document.querySelector('.sidebar');
9702    const _tb = document.querySelector('.topbar');
9703    const _ct = document.querySelector('.content');
9704    if (_sb) _sb.style.removeProperty('display');
9705    if (_tb) _tb.style.removeProperty('display');
9706    if (_ct) { _ct.style.removeProperty('display'); _ct.style.removeProperty('margin-left'); }
9707
9708    document.querySelectorAll('.ob-page').forEach(p => p.classList.remove('active'));
9709    document.body.classList.add('onboarding');
9710
9711    if (window._userDestination && !_isBypassUser()) {
9712      document.getElementById('ob-step2').classList.add('active');
9713    } else {
9714      document.getElementById('ob-step1').classList.add('active');
9715    }
9716  }
9717
9718  // Re-apply i18n
9719  if (typeof applyObI18n === 'function') applyObI18n();
9720}
9721
9722// ══════════════════════════════════════════
9723// CRISIS IMMEDIATE ACTIONS
9724// ══════════════════════════════════════════
9725function showLiderDetails() {
9726  const modal = document.getElementById('lider-modal');
9727  const content = document.getElementById('lider-modal-content');
9728  const data = G.lastData;
9729  if (!data || !modal || !content) return;
9730  const sorted = [...(data.concorrentes||[])].sort((a,b)=>(b.score||0)-(a.score||0));
9731  const lider = sorted[0];
9732  const meu = sorted.find(c=>c.principal);
9733  if (!lider) return;
9734  const meuScore = meu ? meu.score : 0;
9735  const kpis = lider.kpis || {Limpeza:lider.score, Acesso:Math.max(0,lider.score-8), Atendimento:Math.max(0,lider.score-5), 'Infra':Math.max(0,lider.score-12)};
9736  content.innerHTML = '<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:14px;"><div style="font-size:22px;font-weight:800;color:var(--accent);">
9736'+lider.nome+'</div><div style="font-size:28px;font-weight:900;color:var(--accent);">'+lider.score+'</div></div><div style="font-size:12px;color:var(--text3);margin-bottom:12px;">Comparativo KPI a KPI vs. '+(meu?meu.nome:'seu destino')+' ('+meuScore+' pts)</div>'+Object.entries(kpis).map(function(e){var k=e[0],v=e[1];var diff=v-(meuScore*(v/lider.score));return '<div style="margin-bottom:8px;"><div style="display:flex;justify-content:space-between;font-size:13px;margin-bottom:4px;"><span>'+k+'</span><span style="color:'+(diff>0?'var(--green)':'var(--text3)')+';">'+v+(diff>0?' ▲'+Math.round(diff)+'pts acima':'')+'</span></div><div style="height:6px;background:var(--surface3);border-radius:3px;overflow:hidden;"><div style="height:100%;width:'+v+'%;background:var(--accent);border-radius:3px;"></div></div></div>';}).join('')+'<div style="margin-top:14px;padding:10px 12px;border-radius:8px;background:rgba(239,68,68,0.08);border-left:3px solid var(--red);font-size:13px;color:var(--text2);">💡 Para alcançar <strong style="color:var(--text1);">'+lider.nome+'</strong>, foque nos KPIs com maior gap acima.</div>';
9737  modal.style.display = 'flex';
9738}
9739
9740function renderSWOT(d) {
9741  const grid = document.getElementById('swot-grid');
9742  if (!grid) return;
9743  const swot = d.benchmark_swot || {
9744    forcas: d.diferenciais_competitivos || ['Natureza preservada','Identidade cultural única','Comunidade local engajada'],
9745    fraquezas: (d.crisis_indicators||[]).filter(function(c){return c.nivel==='high';}).map(function(c){return c.tema;}) || ['Infraestrutura','Acesso'],
9746    oportunidades: (d.oportunidades_estrategicas||[]).map(function(o){return typeof o==='string'?o:o.titulo;}) || ['Turismo sustentável','Nômades digitais'],
9747    ameacas: (d.concorrentes||[]).filter(function(c){return !c.principal;}).slice(0,2).map(function(c){return c.nome+' crescendo';}) || ['Concorrência regional']
9748  };
9749  var quadrants=[
9750    {label:t('swot_forcas'),items:swot.forcas,color:'var(--green)',bg:'rgba(16,185,129,0.07)'},
9751    {label:t('swot_fraquezas'),items:swot.fraquezas,color:'var(--red)',bg:'rgba(239,68,68,0.07)'},
9752    {label:t('swot_oportunidades'),items:swot.oportunidades,color:'var(--accent)',bg:'rgba(34,211,238,0.07)'},
9753    {label:t('swot_ameacas'),items:swot.ameacas,color:'var(--yellow)',bg:'rgba(245,158,11,0.07)'},
9754  ];
9755  grid.innerHTML=quadrants.map(function(q){return '<div style="padding:14px;border-radius:10px;background:'+q.bg+';border:1px solid '+q.color+'33;"><div style="font-size:12px;font-weight:800;color:'+q.color+';margin-bottom:8px;text-transform:uppercase;letter-spacing:.5px;">'+q.label+'</div>'+(q.items||[]).map(function(i){return '<div style="font-size:12px;color:var(--text2);padding:3px 0;border-bottom:1px solid rgba(255,255,255,0.04);">• '+i+'</div>';}).join('')+'</div>';}).join('');
9756}
9757
9758function filterProfileByInterest(interesse) {
9759  var paEl = document.getElementById('profile-analysis');
9760  if (!paEl) return;
9761  var base = (G.lastData && G.lastData.turista_analise)||'';
9762  var highlighted = base.replace(new RegExp(interesse,'gi'), '<mark style="background:rgba(34,211,238,0.2);color:var(--accent);border-radius:3px;padding:0 3px;">'+interesse+'</mark>');
9763  paEl.innerHTML = highlighted.replace(/\*\*(.*?)\*\*/g,'<strong>$1</strong>').replace(/\n/g,'<br>');
9764  paEl.innerHTML += '<div style="margin-top:14px;padding:12px 14px;border-radius:10px;background:rgba(34,211,238,0.07);border-left:3px solid var(--accent);"><div style="font-size:11px;text-transform:uppercase;letter-spacing:1px;color:var(--accent);font-weight:700;margin-bottom:5px;">🎯 Foco: '+interesse+'</div><div style="font-size:13px;color:var(--text2);line-height:1.6;">Análise filtrada para o segmento <strong style="color:var(--text1);">'+interesse+'</strong>. Este público representa uma oportunidade de conteúdo específico e comunicação segmentada para maximizar conversão.</div></div>';
9765  document.querySelectorAll('.interest-tag').forEach(function(t){
9766    t.style.background = t.textContent.includes(interesse) ? 'rgba(34,211,238,0.25)' : '';
9767    t.style.borderColor = t.textContent.includes(interesse) ? 'var(--accent)' : '';
9768  });
9769}
9770
9771// ===== ORACLE TABS =====
9772
9773// ===== PROJEÇÃO DE PÚBLICO =====
9774let projHorizon = 1;
9775let projHistoricalData = null;
9776let projChartInstance = null;
9777
9778function projSetHorizon(n, btn) {
9779  projHorizon = n;
9780  document.querySelectorAll('.horizon-btn').forEach(b => b.classList.remove('active'));
9781  btn.classList.add('active');
9782  if (window._lastProjData && document.getElementById('proj-results').style.display !== 'none')
9783    projRenderChart(window._lastProjData);
9784}
9785
9786function projLoadFile(input) {
9787  const file = input.files[0]; if (!file) return;
9788  const reader = new FileReader();
9789  reader.onload = e => {
9790    projHistoricalData = projParseCSV(e.target.result);
9791    document.getElementById('proj-upload-label').textContent = '✅ ' + file.name + ' — ' + projHistoricalData.length + ' anos';
9792    document.getElementById('proj-upload-area').style.borderColor = 'var(--accent)';
9793  };
9794  reader.readAsText(file);
9795}
9796
9797function projParseCSV(text) {
9798  return text.trim().split('\n').map(l => {
9799    const p = l.split(/[,;]/);
9800    const year = parseInt(p[0]), visitors = parseInt((p[1]||'').replace(/\D/g,''));
9801    return (!isNaN(year) && !isNaN(visitors)) ? {year, visitors} : null;
9802  }
9802).filter(Boolean).sort((a,b) => a.year - b.year);
9803}
9804
9805function projParseManual(text) {
9806  if (!text.trim()) return null;
9807  const result = text.split(',').map(pair => {
9808    const p = pair.trim().split(':');
9809    const year = parseInt(p[0]), visitors = parseInt((p[1]||'').replace(/\D/g,''));
9810    return (!isNaN(year) && !isNaN(visitors)) ? {year, visitors} : null;
9811  }).filter(Boolean).sort((a,b) => a.year - b.year);
9812  return result.length ? result : null;
9813}
9814
9815/**
9816 * Valida o campo de entrada manual de dados históricos.
9817 * Verifica formato (AAAA:NNNNN), intervalo de anos (1900-2100) e quantidade >= 0.
9818 * @returns {{ valid: boolean, errors: string[], parsed: Array|null }}
9819 */
9820function projValidateManualInput() {
9821  const input = document.getElementById('proj-manual-input');
9822  const text = input.value.trim();
9823  if (!text) return { valid: true, errors: [], parsed: null };
9824
9825  const errors = [];
9826  const pairs = text.split(',');
9827  const parsed = [];
9828
9829  pairs.forEach(function(pair, idx) {
9830    const trimmed = pair.trim();
9831    if (!trimmed) return;
9832
9833    // Verificar se contém o separador ':'
9834    if (!trimmed.includes(':')) {
9835      errors.push('Item "' + trimmed + '" — use o formato ano:quantidade (ex: 2023:52000)');
9836      return;
9837    }
9838
9839    const parts = trimmed.split(':');
9840    if (parts.length !== 2) {
9841      errors.push('Item "' + trimmed + '" — deve ter exatamente um ":" separando ano e quantidade');
9842      return;
9843    }
9844
9845    const yearStr = parts[0].trim();
9846    const qtyStr = parts[1].trim();
9847    const year = parseInt(yearStr);
9848    const qty = parseInt(qtyStr.replace(/\D/g, ''));
9849
9850    // Validar ano
9851    if (isNaN(year) || yearStr.length !== 4) {
9852      errors.push('Ano "' + yearStr + '" inválido — use 4 dígitos (ex: 2023)');
9853      return;
9854    }
9855    if (year < 1900 || year > 2100) {
9856      errors.push('Ano ' + year + ' fora do intervalo permitido (1900-2100)');
9857      return;
9858    }
9859
9860    // Validar quantidade
9861    if (isNaN(qty) || qtyStr === '') {
9862      errors.push('Quantidade inválida para o ano ' + year + ' — informe um número (ex: 52000)');
9863      return;
9864    }
9865    if (qty < 0) {
9866      errors.push('Quantidade para ' + year + ' não pode ser negativa');
9867      return;
9868    }
9869
9870    parsed.push({ year: year, visitors: qty });
9871  });
9872
9873  parsed.sort(function(a, b) { return a.year - b.year; });
9874  return { valid: errors.length === 0, errors: errors, parsed: parsed.length ? parsed : null };
9875}
9876
9877/**
9878 * Mostra erro visual no campo manual.
9879 */
9880function projShowError(msg) {
9881  const input = document.getElementById('proj-manual-input');
9882  const errorDiv = document.getElementById('proj-manual-error');
9883  if (input) input.style.borderColor = 'var(--red)';
9884  if (errorDiv) { errorDiv.style.display = 'block'; errorDiv.innerHTML = '⚠️ ' + msg; }
9885}
9886
9887/**
9888 * Limpa erro visual do campo manual.
9889 */
9890function projClearError() {
9891  const input = document.getElementById('proj-manual-input');
9892  const errorDiv = document.getElementById('proj-manual-error');
9893  if (input) input.style.borderColor = '';
9894  if (errorDiv) { errorDiv.style.display = 'none'; errorDiv.innerHTML = ''; }
9895}
9896
9897async function projGenerate() {
9898  // Garantir que as API keys estejam no localStorage
9899  provisionApiKeys();
9900
9901  const btn = document.getElementById('proj-gen-btn');
9902  btn.disabled = true; btn.textContent = '⏳ Gerando projeção...';
9903
9904  // Validar campo manual antes de prosseguir
9905  const validation = projValidateManualInput();
9906  if (!validation.valid) {
9907    projShowError(validation.errors.join('<br>⚠️ '));
9908    btn.disabled = false; btn.textContent = '✨ Gerar Projeção de Público';
9909    return;
9910  }
9911  projClearError();
9912
9913  // Usar dados validados ou do CSV
9914  const manualData = validation.parsed;
9915  const historicalData = projHistoricalData || manualData;
9916
9917  // Calcular base de visitantes a partir dos dados fornecidos
9918  const baseYear = historicalData ? historicalData[historicalData.length - 1] : null;
9919  const baseVisitors = baseYear ? baseYear.visitors : null;
9920  const baseInstruction = baseVisitors
9921    ? '"base_visitantes": ' + baseVisitors + '  /* USE EXATAMENTE ESTE VALOR como base, referência do ano ' + baseYear.year + ' */'
9922    : '"base_visitantes": <estime o número atual de visitantes anuais com base no tipo e contexto do destino>';
9923
9924  const ctx = oracleCollectContext();
9925  const consensusCtx = oracleChatHistory.length > 0
9926    ? 'Consenso da Mesa: ' + oracleChatHistory.map(m=>m.content).join(' ')
9927    : 'Sem debate realizado ainda.';
9928  const historicalCtx = historicalData
9929    ? 'Dados históricos fornecidos pelo usuário: ' + historicalData.map(d=>
9929d.year+': '+d.visitors.toLocaleString()).join(', ') + '. USE O VALOR DO ÚLTIMO ANO (' + baseYear.year + ': ' + baseYear.visitors.toLocaleString() + ') como base_visitantes.'
9930    : 'Sem dados históricos fornecidos. Estime base_visitantes com base no contexto do destino.';
9931
9932  const systemPrompt = await getPrompt('demand-forecast', { baseInstruction });
9933
9934  const userMessage = 'DESTINO: '+(document.getElementById('ob-dest')?.value||G.dest||'destino')+'\nHORIZONTE: '+projHorizon+' ano(s)\nTIPO: '+(document.getElementById('proj-context-type')?.value||'geral')+'\nDETALHE: '+(document.getElementById('proj-context-detail')?.value||'sem detalhe adicional')+'\n\nBASE ATUAL DE VISITANTES: '+(baseVisitors ? baseVisitors.toLocaleString()+' (ref. '+baseYear.year+')' : 'não informada — estime com base no destino')+'\n\n'+ctx+'\n\n'+consensusCtx+'\n\n'+historicalCtx;
9935
9936  try {
9937    const reply = await oracleAPICall(systemPrompt, [{
9938      role:'user',
9939      content: userMessage
9940    }], 1500, 'openai');
9941    const data = JSON.parse(reply.replace(/```json|```/g,'').trim());
9942    data._historical = historicalData;
9943    window._lastProjData = data;
9944    projRenderPersonas(data.personas || []);
9945    projRenderAtratividade(data.atratividade || []);
9946    projRenderChart(data);
9947    document.getElementById('proj-results').style.display = 'block';
9948    document.getElementById('proj-results').scrollIntoView({behavior:'smooth',block:'start'});
9949  } catch(e) {
9950    console.error('Projeção error:', e);
9951    alert('Erro ao gerar projeção: ' + e.message + '\n\nVerifique se o provedor (' + (G.provider||'não definido') + ') e a API key estão corretos em ⚙️ Configurações.');
9952  }
9953  btn.disabled = false; btn.textContent = '✨ Gerar Projeção de Público';
9954}
9955
9956function projRenderPersonas(personas) {
9957  document.getElementById('proj-personas-count').textContent = personas.length + ' personas';
9958  document.getElementById('proj-personas-grid').innerHTML = personas.map(p => `
9959    <div class="persona-card">
9960      <div class="persona-silhouette" style="background:${p.cor}22;border:2px solid ${p.cor}55;">
9961        <span>${p.emoji||'👤'}</span>
9962      </div>
9963      <div class="persona-name">${p.nome}</div>
9964      <div class="persona-meta">${p.idade} · ${p.origem}</div>
9965      <div class="persona-tags">${(p.tags||[]).map(t=>`<span class="persona-tag" style="background:${p.cor}22;color:${p.cor};">${t}</span>`).join('')}</div>
9966      <div class="persona-stats">
9967        <div class="persona-stat"><div class="persona-stat-val" style="color:${p.cor};">${p.gasto_medio}</div><div class="persona-stat-label">Gasto/dia</div></div>
9968        <div class="persona-stat"><div class="persona-stat-val">⭐</div><div class="persona-stat-label">Alta conv.</div></div>
9969      </div>
9970      <div class="persona-row"><div class="persona-row-title">✨ Encanta</div><div class="persona-row-text">${p.encanta}</div></div>
9971      <div class="persona-row"><div class="persona-row-title">⚠️ Frustra</div><div class="persona-row-text">${p.frustra}</div></div>
9972    </div>`).join('');
9973}
9974
9975function projRenderAtratividade(items) {
9976  document.getElementById('proj-attract-list').innerHTML = items.map((item,i) => `
9977    <div class="attract-item">
9978      <div class="attract-rank">#${i+1}</div>
9979      <div class="attract-info">
9980        <div class="attract-name">${item.segmento}</div>
9981        <div class="attract-reason">${item.razao}</div>
9982        <div class="attract-bar-bg"><div class="attract-bar-fill" id="abar-${i}" style="background:${item.cor||'var(--accent)'}"></div></div>
9983      </div>
9984      <div class="attract-score" style="color:${item.cor||'var(--accent)'};">${item.score}pts</div>
9985    </div>`).join('');
9986  setTimeout(() => items.forEach((_,i) => {
9987    const b = document.getElementById('abar-'+i);
9988    if (b) b.style.width = items[i].score + '%';
9989  }), 100);
9990}
9991
9992function projRenderChart(data) {
9993  const canvas = document.getElementById('proj-chart');
9994  if (!canvas || typeof Chart === 'undefined') return;
9995  const yr = new Date().getFullYear();
9996  const years = Array.from({length:projHorizon+1},(_,i)=>yr+i);
9997  const curva = data.curva || data.curve || {};
9998  const base = curva.base_visitantes || curva.base_visitors || 50000;
9999  const optScenario = curva.cenario_otimista || curva.optimistic || curva.optimista || {};
10000  const realScenario = curva.cenario_realista || curva.realistic || curva.realista || {};
10001  const consScenario = curva.cenario_conservador || curva.conservative || curva.conservador || {};
10002  const rO = optScenario.taxa_anual || optScenario.annual_rate || 0.15;
10003  const rR = realScenario.taxa_anual || realScenario.annual_rate || 0.08;
10004  const rC = consScenario.taxa_anual || consScenario.annual_rate || 0.02;
10005  const datasets = [
10006    {label:'Otimista',    data:years.map((_,i)=>Math.round(base*Math.pow(1+rO,i))), borderColor:'#10b981', tension:0.4, fill:false},
10007    {label:'Realista',    data:years.map((_,i)=>Math.round(base*Math.pow(1+rR,i))), borderColor:'#f59e0b', tension:0.4, fill:false},
10008    {label:'Conservador', data:years.map((_,i)=>Math.round(base*Math.pow(1+rC,i))), borderColor:'#ef4444', tension:0.4, fill:false},
10009  ];
10010  const optimistic = datasets[0].data;
10011  const realistic = datasets[1].data;
10012  const conserv = datasets[2].data;
10013  if (data._historical?.length) {
10014    document.getElementById('proj-historical-legend').style.display='flex';
10015    datasets.push({label:'Histórico', data:data._historical.map(d=>d.visitors), borderColor:'#8B5CF6', borderDash:[6,3], tension:0.4, fill:false});
10016  }
10017  if (projChartInstance) { projChartInstance.destroy(); projChartInstance=null; }
10018  const gc='rgba(255,255,255,0.06)', tc='rgba(255,255,255,0.4)';
10019  document.getElementById('proj-horizon-label').textContent = projHorizon+' ano'+(projHorizon>1?'s':'');
10020  projChartInstance = new Chart(canvas, {
10021    type:'line', data:{labels:years, datasets},
10022    options:{responsive:true,
10023      plugins:{legend:{display:false},tooltip:{callbacks:{label:c=>` ${c.dataset.label}: ${c.parsed.y.toLocaleString('pt-BR')} visitantes`}}},
10024      scales:{
10025        x:{grid:{color:gc},ticks:{color:tc}},
10026        y:{grid:{color:gc},ticks:{color:tc,callback:v=>v>=1000000?(v/1e6).toFixed(1)+'M':v>=1000?(v/1000).toFixed(0)+'k':v}}
10027      }
10028    }
10029  });
10030  // Render scenario number cards
10031  const finalYear = years[years.length - 1];
10032  const scenarios = [
10033    { label:'🟢 Otimista', color:'var(--green)', bg:'rgba(16,185,129,0.1)', border:'rgba(16,185,129,0.3)',
10034      val: optimistic[optimistic.length-1], rate: rO },
10035    { label:'🟡 Realista', color:'var(--orange)', bg:'rgba(245,158,11,0.1)', border:'rgba(245,158,11,0.3)',
10036      val: realistic[realistic.length-1],   rate: rR },
10037    { label:'🔴 Conservador', color:'var(--red)', bg:'rgba(239,68,68,0.1)', border:'rgba(239,68,68,0.3)',
10038      val: conserv[conserv.length-1],       rate: rC },
10039  ];
10040  const numContainer = document.getElementById('proj-curve-numbers');
10041  if (numContainer) {
10042    numContainer.innerHTML = scenarios.map(s => `
10043      <div class="curve-num-card" style="background:${s.bg};border-color:${s.border};">
10044        <div class="curve-num-label" style="color:${s.color};">${s.label}</div>
10045        <div class="curve-num-val" style="color:${s.color};">${s.val >= 1000000 ? (s.val/1000000).toFixed(2)+'M' : s.val >= 1000 ? (s.val/1000).toFixed(1)+'k' : s.val.toLocaleString('pt-BR')}</div>
10046        <div class="curve-num-detail">visitantes em ${finalYear}<br>+${(s.rate*100).toFixed(0)}% ao ano</div>
10047      </div>`).join('');
10048  }
10049
10050  document.getElementById('proj-triggers').innerHTML = [
10051    {label:'🟢 Otimista',    color:'rgba(16,185,129,0.1)', border:'rgba(16,185,129,0.3)', text:optScenario.gatilho||optScenario.trigger||'--'},
10052    {label:'🟡 Realista',    color:'rgba(245,158,11,0.1)', border:'rgba(245,158,11,0.3)', text:realScenario.gatilho||realScenario.trigger||'--'},
10053    {label:'🔴 Conservador', color:'rgba(239,68,68,0.1)',  border:'rgba(239,68,68,0.3)',  text:consScenario.gatilho||consScenario.trigger||'--'},
10054  ].map(t=>`<div style="background:${t.color};border:1px solid ${t.border};border-radius:10px;padding:12px;">
10055    <div style="font-size:11px;font-weight:700;margin-bottom:6px;">${t.label}</div>
10056    <div style="font-size:12px;color:var(--text2);line-height:1.5;">${t.text}</div>
10057  </div>`).join('');
10058}
10059
10060// ===== TERMOMETRO MACRO =====
10061const MACRO_KEY = 'cp_macro_data';
10062
10063function macroLoad() {
10064  try {
10065    const saved = JSON.parse(localStorage.getItem(MACRO_KEY) || '{}');
10066    ['qav','confianca','cancel','golfo'].forEach(f => {
10067      const inp = document.getElementById('minput-'+f);
10068      if (inp && saved[f] !== undefined) { inp.value = saved[f]; macroUpdateKpi(f, saved[f]); }
10069    });
10070    if (saved.dolar)  macroSetKpi('dolar', saved.dolar, saved.dolar_prev, 'R$', '');
10071    if (saved.brent)  macroSetKpi('brent', saved.brent, saved.brent_prev, 'US$', '/bbl');
10072    const geo = document.getElementById('macro-geo-context');
10073    if (geo && saved.geo_context) geo.value = saved.geo_context;
10074    if (saved.updated) { const el=document.getElementById('macro-last-update'); if(el) el.textContent=t('macro_last_update_prefix')+saved.updated; }
10075    macroUpdateBadge();
10076  } catch(e) {}
10077}
10078
10079function macroSave() {
10080  try {
10081    const saved = JSON.parse(localStorage.getItem(MACRO_KEY) || '{}');
10082    ['qav','confianca','cancel','golfo'].forEach(f => {
10083      const inp = document.getElementById('minput-'+f);
10084      if (inp && inp.value) saved[f] = inp.value;
10085    });
10086    const geo = document.getElementById('macro-geo-context');
10087    if (geo) saved.geo_context = geo.value;
10088    const langLocale = {pt:'pt-BR',en:'en-US',es:'es-ES',fr:'fr-FR'}[G.lang] || 'pt-BR';
10089    saved.updated = new Date().toLocaleDateString(langLocale,{day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'});
10090    localStorage.setItem(MACRO_KEY, JSON.stringify(saved));
10091    const el = document.getElementById('macro-last-update');
10092    if (el) el.textContent = t('macro_last_update_prefix')+saved.updated;
10093    const badge = document.getElementById('macro-status-badge');
10094    if (badge) { badge.style.background='rgba(16,185,129,0.15)'; badge.style.color='var(--green)'; badge.textContent=t('macro_week_saved'); }
10095  } catch(e) {}
10096}
10097
10098function macroUpdateKpi(field, value) {
10099  const v = parseFloat(value) || 0;
10100  const saved = JSON.parse(localStorage.getItem(MACRO_KEY) || '{}');
10101  const prev = parseFloat(saved[field]) || null;
10102  const valEl = document.getElementById('mkv-'+field);
10103  const deltaEl = document.getElementById('mkd-'+field);
10104  const card = document.getElementById('mkpi-'+field);
10105  if (field === 'golfo') {
10106    const colors = {normal:'var(--green)',atencao:'var(--orange)',critico:'var(--red)',fechado:'var(--violet)'};
10107    const labels = {normal:t('macro_golfo_val_normal'),atencao:t('macro_golfo_val_atencao'),critico:t('macro_golfo_val_critico'),fechado:t('macro_golfo_val_fechado')};
10108    if (valEl) { valEl.textContent = labels[value]||'—'; valEl.style.fontSize='14px'; valEl.style.color=colors[value]||'var(--text)'; }
10109    if (card) card.className = 'macro-kpi-card '+(value==='normal'?'alert-ok':value==='atencao'?'alert-med':'alert-high');
10110    return;
10111  }
10112  const fmt = field==='qav'?'R$'+v.toFixed(2):field==='cancel'?v.toFixed(1)+'%':field==='confianca'?v.toFixed(1):v;
10113  if (valEl) valEl.textContent = fmt||'—';
10114  if (prev && prev!==v && deltaEl) {
10115    const diff = ((v-prev)/prev*100).toFixed(1);
10116    const up = v>prev;
10117    const isGood = field==='cancel'?!up:(field==='confianca'?up:!up);
10118    deltaEl.textContent=(up?'▲':'▼')+' '+Math.abs(diff)+'% '+t('macro_delta_vs_prev');
10119    deltaEl.style.color=isGood?'var(--green)':'var(--red)';
10120    if (card) card.className='macro-kpi-card '+(isGood?'alert-ok':(Math.abs(diff)>5?'alert-high':'alert-med'));
10121  }
10122}
10123
10124function macroSetKpi(field, value, prev, prefix, suffix) {
10125  const valEl=document.getElementById('mkv-'+field);
10126  const deltaEl=document.getElementById('mkd-'+field);
10127  const card=document.getElementById('mkpi-'+field);
10128  if (valEl) valEl.textContent=prefix+parseFloat(value).toFixed(2)+suffix;
10129  if (prev && deltaEl) {
10130    const diff=((value-prev)/prev*100).toFixed(1);
10131    const up=value>prev;
10132    deltaEl.textContent=(up?'▲':'▼')+' '+Math.abs(diff)+'%';
10133    deltaEl.style.color=up?'var(--red)':'var(--green)';
10134    if (card) card.className='macro-kpi-card '+(up?(Math.abs(diff)>3?'alert-high':'alert-med'):'alert-ok');
10135  }
10136}
10137
10138async function callAIGlobal(prompt, maxTok, forceJSON, meta) {
10139  if (forceJSON === undefined) forceJSON = true;
10140  const provider = G.provider || window._GLOBAL_PROVIDER || 'openai';
10141  // meta opcional (de getPromptWithMeta) dá slug/model/version reais ao log;
10142  // sem meta cai em 'module-call' (compat). Aceita slug literal: { slug: 'x' }.
10143  const _slug = (meta && meta.slug) || 'module-call';
10144  const _model = (meta && meta.model_api_id) || null;
10145  const _ver = (meta && meta.version) || null;
10146  const _start = Date.now();
10147  const _u = {};
10148
10149  // RAG (docs/domains/rag/adrs/adr-005.md): callAIGlobal é o caminho de TODOS
10150  // os `_retry*` (heatmap, social, emissores, felicidade, viral, oracle) — só
10151  // aqui eles ganham o documento. Resolve file_ids uma vez por chamada; sem
10152  // meta.rag_enabled ou sem arquivo indexado, segue idêntico ao comportamento
10153  // anterior (file_ids/retrievalQuery undefined → ai-analyze não recupera).
10154  let _ragFileIds, _ragQuery;
10155  if (meta && meta.rag_enabled === true && typeof getActiveAnalysisFileIds === 'function') {
10156    try {
10157      const fids = await getActiveAnalysisFileIds();
10158      if (fids && fids.length > 0) {
10159        _ragFileIds = fids;
10160        _ragQuery = meta.rag_vocabulary_hints || '';
10161      }
10162    } catch (_eRag) { /* RAG opcional — segue sem recuperação */ }
10163  }
10164
10165  try {
10166    const result = await callAISecure(provider, prompt, maxTok, forceJSON, undefined, _u, _ragFileIds, _ragQuery);
10167    // Best-effort: só pra cravar document_grounded na telemetria de auditoria do
10168    // RAG — não afeta o parse "de verdade" que o caller (_retry*) faz depois.
10169    let _docGrounded;
10170    if (_u.rag) { try { _docGrounded = JSON.parse(result).document_grounded; } catch (_eParse) { /* ignora — auditoria fica sem esse campo */ } }
10171    logPromptCall({ slug: _slug, provider: _u.provider || provider, model: _model || _u.model || null, promptVersion: _ver, promptSent: prompt, responseRaw: result, responseParsed: true, tokensInput: _u.tokensInput, tokensOutput: _u.tokensOutput, durationMs: Date.now() - _start, success: true, ...(_u.rag ? { apisDataSummary: Object.assign({}, _currentApisData, { rag: Object.assign({}, _u.rag, { document_grounded: _docGrounded }) }) } : {}) });
10172    return result;
10173  } catch(e) {
10174    logPromptCall({ slug: _slug, provider: _u.provider || provider, model: _model, promptVersion: _ver, promptSent: prompt, responseRaw: '', responseParsed: false, durationMs: Date.now() - _start, success: false, errorMessage: e.message });
10175    throw e;
10176  }
10177}
10178
10179// Compatibilidade — redireciona para Edge Function
10180async function _callAIProvider(provider, apiKey, prompt, maxTok, forceJSON) {
10181  return await callAISecure(provider, prompt, maxTok, forceJSON);
10182}
10183
10184async function macroAnalyzeWithAI() {
10185  const btn=document.getElementById('macro-ai-btn');
10186  const result=document.getElementById('macro-ai-result');
10187  if (!btn||!result) return;
10188  btn.disabled=true; btn.textContent=t('macro_analyzing');
10189  const ctx=macroGetContextString();
10190  const dest=G.dest||'destino';
10191  const langName = {pt:'Portuguese',en:'English',es:'Spanish',fr:'French'}[G.lang] || 'Portuguese';
10192  try {
10193    const prompt = await getPrompt('macro-analysis', { dest, macroContext: ctx, langName });
10194    const raw = await callAIGlobal(prompt, 900, true, { slug: 'macro-analysis' });
10195    const analysis=JSON.parse(raw.replace(/```json|```/g,'').trim());
10196    // nivel_alerta/urgencia vêm em tokens fixos (high|medium|low) do prompt v2+; _crisisLevelKey
10197    // (definida em pages/crisis.js) também aceita os tokens PT antigos de respostas em cache.
10198    const alertKey = (typeof _crisisLevelKey === 'function') ? _crisisLevelKey(analysis.nivel_alerta) : '';
10199    const colors={high:'#ef4444',medium:'#f59e0b',low:'#10b981'};
10200    const color=colors[alertKey]||'#f59e0b';
10201    const emoji=alertKey==='high'?'🔴':alertKey==='medium'?'🟡':'🟢';
10202    const alertLabel = alertKey ? t('crisis_urgency_'+alertKey).toUpperCase() : '--';
10203    result.style.display='block';
10204    result.innerHTML='<div style="border:1px solid '+color+'44;border-radius:14px;overflow:hidden;margin-top:4px;">'
10205      +'<div style="padding:14px 16px;background:'+color+'11;display:flex;align-items:center;gap:10px;">'
10206      +'<span style="font-size:20px;">'+emoji+'</span>'
10207      +'<div><div style="font-family:Syne,sans-serif;font-size:13px;font-weight:800;color:'+color+';">'+t('macro_alert_prefix')+' '+alertLabel+'</div>'
10208      +'<div style="font-size:12px;color:var(--text2);margin-top:2px;">'+(analysis.resumo||'')+'</div></div></div>'
10209      +'<div style="padding:14px 16px;">'
10210      +'<div style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:1px;color:var(--text3);margin-bottom:10px;">'+t('macro_impacts_label')+'</div>'
10211      +(analysis.impactos||[]).map(function(i){var k=(typeof _crisisLevelKey === 'function') ? _crisisLevelKey(i.urgencia) : ''; return '<div style="display:flex;gap:8px;margin-bottom:7px;padding:8px 10px;background:var(--surface2);border-radius:8px;"><span>'+(k==='high'?'🔴':k==='medium'?'🟡':'🟢')+'</span><div><div style="font-size:12px;font-weight:700;">'+i.area+'</div><div style="font-size:11px;color:var(--text3);">'+i.impacto+'</div></div></div>';}).join('')
10212      +'<div style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:1px;color:var(--text3);margin:12px 0 8px;">'+t('macro_actions_label')+'</div>'
10213      +(analysis.acoes||[]).map(function(a){return '<div style="font-size:12px;padding:6px 0;border-bottom:1px solid var(--border);color:var(--text2);">→ '+a+'</div>';}).join('')
10214      +'</div></div>';
10215  } catch(e) {
10216    result.style.display='block';
10217    result.innerHTML='<div style="padding:12px;border-radius:10px;background:rgba(239,68,68,0.1);color:var(--red);font-size:12px;">'+t('macro_error_prefix')+e.message+'</div>';
10218  }
10219  btn.disabled=false; btn.innerHTML=t('macro_ai_btn');
10220}
10221
10222async function macroFetchAuto() {
10223  const lbl=document.getElementById('macro-fetch-label');
10224  if (lbl) lbl.textContent=t('macro_fetching');
10225  try {
10226    const saved=JSON.parse(localStorage.getItem(MACRO_KEY)||'{}');
10227    
10228    // 1. Get real exchange rate from ExchangeRate API via api-proxy
10229    try {
10230      const supabase = await getSupabase();
10231      const { data: { session } } = await supabase.auth.getSession();
10232      if (session) {
10233        const res = await fetch(SUPABASE_URL + '/functions/v1/api-proxy', {
10234          method: 'POST',
10235          headers: {
10236            'Content-Type': 'application/json',
10237            'Authorization': 'Bearer ' + session.access_token,
10238            'apikey': SUPABASE_ANON_KEY
10239          },
10240          body: JSON.stringify({ service: 'exchange', params: { base: 'BRL' } })
10241        });
10242        const proxyData = await res.json();
10243        const d = proxyData.result || {};
10244        if (d.result === 'success' && d.conversion_rates?.USD) {
10245          const usdBrl = (1 / d.conversion_rates.USD).toFixed(2);
10246          saved.dolar_prev = saved.dolar;
10247          saved.dolar = parseFloat(usdBrl);
10248          macroSetKpi('dolar', usdBrl, saved.dolar_prev, 'R$', '');
10249          console.log('[Macro] ✅ Real USD/BRL from API:', usdBrl);
10250        }
10251      }
10252    } catch(e) { console.warn('[Macro] ExchangeRate failed, falling back to AI'); }
10253    
10254    // 2. Get Brent from real API (Yahoo Finance → CrudePriceAPI fallback)
10255    const brentData = await fetchBrentPrice();
10256    if (brentData) {
10257      saved.brent_prev = saved.brent;
10258      saved.brent = brentData.price;
10259      saved.brent_source = brentData.source;
10260      macroSetKpi('brent', brentData.price, saved.brent_prev || brentData.previousClose, 'US$', '/bbl');
10261      console.log('[Macro] ✅ Brent from', brentData.source, ':', brentData.price);
10262    }
10263
10264    // 3. If no exchange data, get USD/BRL from AI as fallback
10265    if (!saved.dolar) {
10266      try {
10267        const prompt='Return ONLY valid JSON: {"usd_brl":X} with current approximate USD to BRL exchange rate.';
10268        const raw = await callAIGlobal(prompt, 80, true, { slug: 'macro-usdbrl-fallback' });
10269        const vals = JSON.parse(raw.replace(/```json|```/g,'').trim());
10270        if (vals.usd_brl) { saved.dolar_prev=saved.dolar; saved.dolar=vals.usd_brl; macroSetKpi('dolar',vals.usd_brl,saved.dolar_prev,'R$',''); }
10271      } catch(e) { console.warn('[Macro] USD/BRL AI fallback failed'); }
10272    }
10273    
10274    localStorage.setItem(MACRO_KEY,JSON.stringify(saved));
10275    if (lbl) { lbl.textContent=t('macro_fetch_updated'); setTimeout(()=>{lbl.textContent=t('macro_fetch_btn');},2000); }
10276  } catch(e) {
10277    if(lbl){lbl.textContent=t('macro_fetch_error'); setTimeout(()=>{lbl.textContent=t('macro_fetch_btn');},3000);}
10278    console.error('macroFetchAuto error:', e.message);
10279  }
10280}
10281
10282function macroGetContextString() {
10283  try {
10284    const saved=JSON.parse(localStorage.getItem(MACRO_KEY)||'{}');
10285    const parts=[];
10286    if (saved.dolar)     parts.push('USD/BRL: R$'+parseFloat(saved.dolar).toFixed(2));
10287    if (saved.brent)     parts.push('Brent: US$'+parseFloat(saved.brent).toFixed(2)+'/bbl');
10288    if (saved.qav)       parts.push('QAV Brasil: R$'+parseFloat(saved.qav).toFixed(2)+'/L');
10289    if (saved.confianca) parts.push('Conf.Turistica: '+saved.confianca);
10290    if (saved.cancel)    parts.push('Cancelamentos: '+saved.cancel+'%');
10291    if (saved.golfo)     parts.push('Hubs Golfo: '+saved.golfo);
10292    const geo=document.getElementById('macro-geo-context');
10293    if (geo && geo.value) parts.push('Geopolitico: '+geo.value);
10294    return parts.join(' | ');
10295  } catch(e) { return ''; }
10296}
10297
10298function macroUpdateBadge() {
10299  try {
10300    const saved=JSON.parse(localStorage.getItem(MACRO_KEY)||'{}');
10301    const badge=document.getElementById('macro-status-badge');
10302    if (!badge) return;
10303    if (saved.dolar || saved.brent || saved.qav) {
10304      badge.style.background='rgba(16,185,129,0.15)'; badge.style.color='var(--green)';
10305      badge.textContent = saved.updated ? saved.updated : t('macro_with_data');
10306    }
10307  } catch(e) {}
10308}
10309
10310
10311// ===== ROUTER INIT =====
10312// Inicializar router após DOM ready e módulos core carregados
10313if (window.AppRouter) {
10314  window.AppRouter.init();
10315}

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.