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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); } 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} | <strong>${t('sim_doc_date_label')}</strong> ${new Date().toLocaleDateString(_simLocale())} | <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> _________________________ | <strong>${t('sim_doc_date_label')}</strong> ${new Date().toLocaleDateString(_simLocale())}</p> 6784 <p><strong>${t('sim_doc_dest_label')}</strong> ${G.dest} | <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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); 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.