1/* ========================================================================= 2 Encontra Push â captura de assinantes no front-end. 3 4 Seções 19 (solicitação de permissão), 20 (iOS/iPadOS), 113 a 115. 5 6 Principio que organiza este arquivo: a chamada NATIVA de permissão só 7 acontece depois de um clique do usuário no nosso pré-prompt. Chamar 8 Notification.requestPermission() no carregamento da página é a maneira mais 9 rápida de perder a origem â o Chrome passa a bloquear o prompt em sites com 10 taxa alta de recusa, e a decisão é por domÃnio, sem volta fácil. 11 ========================================================================= */ 12 13(function () { 14 'use strict'; 15 16 /* 17 * Diagnóstico no console do navegador (F12 > Console, filtro "Encontra"). 18 * 19 * Toda falha de inscrição era silenciosa: o visitante concedia a 20 * permissão, nada chegava ao painel, e não havia como saber por que. 21 * Cada desistencia agora deixa uma linha explicando o motivo. 22 * 23 * A primeira linha ("carregado") também responde a uma pergunta comum: 24 * se ELA não aparece, o script nem está na página â quase sempre cache 25 * de página servindo uma versão anterior a conexão do plugin. 26 */ 27 function log(message, detail) { 28 try { 29 if (detail !== undefined) { 30 console.info('[Encontra Push] ' + message, detail); 31 } else { 32 console.info('[Encontra Push] ' + message); 33 } 34 } catch (e) { /* console indisponÃvel */ } 35 } 36 37 var config = window.EncontraPush; 38 39 if (!config || !config.vapidPublicKey) { 40 log('sem chave VAPID na configuração da página; o plugin ainda não está conectado ao painel ou a página veio de um cache antigo.'); 41 42 return; 43 } 44 45 log('carregado (modo ' + config.integrationMode + ', permissão atual: ' 46 + ('Notification' in window ? Notification.permission : 'indisponÃvel') + ').'); 47 48 var STORAGE_PREFIX = 'encontra_push_'; 49 50 /* ------------------------------------------------ deteccao de suporte */ 51 52 /* 53 * Seção 115: feature detection, nunca user-agent. O navegador que não 54 * suporta simplesmente não vê CTA nenhum â mostrar um botão que não 55 * funciona é pior do que não mostrar nada. 56 */ 57 var supported = 58 'serviceWorker' in navigator && 59 'PushManager' in window && 60 'Notification' in window; 61 62 if (!supported) { 63 log('este navegador não oferece Web Push neste contexto (no iPhone/iPad, só com o site instalado na Tela de InÃcio).'); 64 onReady(initWidget); 65 maybeShowIosGuide(); 66 return; 67 } 68 69 if (Notification.permission === 'denied') { 70 log('notificações bloqueadas neste navegador; o pré-prompt não será exibido.'); 71 } 72 73 /* --------------------------------------------------------- utilidades */ 74 75 function store(key, value) { 76 try { 77 localStorage.setItem(STORAGE_PREFIX + key, String(value)); 78 } catch (e) { 79 // Seção 19.3: cai para cookie first-party quando o localStorage 80 // está bloqueado (modo anônimo, polÃticas restritivas). 81 document.cookie = 82 STORAGE_PREFIX + key + '=' + encodeURIComponent(String(value)) + 83 ';path=/;max-age=' + 60 * 60 * 24 * 365 + ';SameSite=Lax'; 84 } 85 } 86 87 function read(key) { 88 try { 89 var value = localStorage.getItem(STORAGE_PREFIX + key); 90 if (value !== null) return value; 91 } catch (e) { /* segue para o cookie */ } 92 93 var match = document.cookie.match(new RegExp('(^|; )' + STORAGE_PREFIX + key + '=([^;]*)')); 94 95 return match ? decodeURIComponent(match[2]) : null; 96 } 97 98 function deviceType() { 99 if (/iPad|Tablet/i.test(navigator.userAgent)) return 'tablet'; 100 if (/Mobi|iPhone|Android.*Mobile/i.test(navigator.userAgent)) return 'mobile'; 101 return 'desktop'; 102 } 103 104 function isMobile() { 105 return deviceType() !== 'desktop'; 106 } 107 108 /* 109 * Configuração resolvida para o aparelho atual. 110 * 111 * O que esta na raiz de `prompt` e de `appearance` e o conjunto do 112 * desktop â era o formato antes de existir configuração por aparelho, e 113 * manter assim significa que nenhum site muda de comportamento ao 114 * atualizar. O conjunto do celular vive em `mobile_settings` e, quando 115 * existe, sobrescreve o da raiz. 116 * 117 * A sobreposição (em vez de troca) importa: se um dia um campo novo for 118 * acrescentado e o conjunto do celular tiver sido gravado antes dele, o 119 * campo cai no valor do desktop em vez de virar indefinido. 120 */ 121 function forDevice(group) { 122 var mobile = group && group.mobile_settings; 123 124 if (!isMobile() || !mobile) { 125 return group || {}; 126 } 127 128 var out = {}; 129 var key; 130 131 for (key in group) { 132 if (Object.prototype.hasOwnProperty.call(group, key)) out[key] = group[key]; 133 } 134 135 for (key in mobile) { 136 if (Object.prototype.hasOwnProperty.call(mobile, key)) out[key] = mobile[key]; 137 } 138 139 return out; 140 } 141 142 function promptConfig() { 143 return forDevice(config.prompt); 144 } 145 146 function appearanceConfig() { 147 return forDevice(config.appearance); 148 } 149 150 function report(event) { 151 // Seção 112: contadores do funil de opt-in, sempre agregados. 152 try { 153 fetch(config.endpoints.optin, { 154 method: 'POST', 155 headers: { 'Content-Type': 'application/json' }, 156 body: JSON.stringify({ event: event, device_type: deviceType() }), 157 keepalive: true, 158 }); 159 } catch (e) { /* métrica nunca pode quebrar a inscrição */ } 160 } 161 162 function urlBase64ToUint8Array(base64String) { 163 var padding = '='.repeat((4 - (base64String.length % 4)) % 4); 164 var base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/'); 165 var raw = window.atob(base64); 166 var output = new Uint8Array(raw.length); 167 168 for (var i = 0; i < raw.length; ++i) { 169 output[i] = raw.charCodeAt(i); 170 } 171 172 return output; 173 } 174 175 /* ------------------------------------------------ elegibilidade do prompt */ 176 177 function shouldShowPrompt() { 178 var prompt = promptConfig(); 179 180 if (prompt.mode === 'disabled') return false;
181 if (!prompt.desktop && !isMobile()) return false; 182 if (!prompt.mobile && isMobile()) return false; 183 184 // Seção 113: permissão negada no navegador encerra o assunto. Insistir 185 // com um botão que abre um prompt que não vai aparecer só frustra. 186 if (Notification.permission === 'denied') return false; 187 188 // Já inscrito: não há o que pedir. 189 if (Notification.permission === 'granted') return false; 190 191 // Seção 19.3: respeitar a janela de reexibição. 192 var snoozeUntil = parseInt(read('snooze_until') || '0', 10); 193 194 if (snoozeUntil && Date.now() < snoozeUntil) return false; 195 196 return true; 197 } 198 199 function trackVisit() { 200 var visits = parseInt(read('visits') || '0', 10) + 1; 201 store('visits', visits); 202 203 var pageviews = parseInt(sessionStorageGet('pageviews') || '0', 10) + 1; 204 sessionStorageSet('pageviews', pageviews); 205 206 return { visits: visits, pageviews: pageviews }; 207 } 208 209 function sessionStorageGet(key) { 210 try { 211 return sessionStorage.getItem(STORAGE_PREFIX + key); 212 } catch (e) { 213 return null; 214 } 215 } 216 217 function sessionStorageSet(key, value) { 218 try { 219 sessionStorage.setItem(STORAGE_PREFIX + key, String(value)); 220 } catch (e) { /* ignora */ } 221 } 222 223 /* --------------------------------------------------------- pré-prompt */ 224 225 function buildPrompt() { 226 var prompt = promptConfig(); 227 var appearance = appearanceConfig(); 228 229 var root = document.createElement('div'); 230 root.className = 'ep-prompt ep-prompt--' + (prompt.position || 'top-center') 231 + ' ep-prompt--theme-' + promptTheme(appearance, prompt); 232 root.setAttribute('role', 'dialog'); 233 root.setAttribute('aria-live', 'polite'); 234 root.setAttribute('aria-label', prompt.title || ''); 235 236 applyAppearance(root, appearance); 237 238 var bell = document.createElement('span'); 239 bell.className = 'ep-prompt__icon'; 240 bell.setAttribute('aria-hidden', 'true'); 241 bell.textContent = 'ð'; 242 243 var content = document.createElement('div'); 244 content.className = 'ep-prompt__content'; 245 246 var title = document.createElement('p'); 247 title.className = 'ep-prompt__title'; 248 title.textContent = prompt.title || ''; 249 250 var body = document.createElement('p'); 251 body.className = 'ep-prompt__body'; 252 body.textContent = prompt.body || ''; 253 254 content.appendChild(title); 255 content.appendChild(body); 256 257 var actions = document.createElement('div'); 258 actions.className = 'ep-prompt__actions'; 259 260 var decline = document.createElement('button'); 261 decline.type = 'button'; 262 decline.className = 'ep-prompt__btn ep-prompt__btn--ghost'; 263 decline.textContent = prompt.decline_label || 'Agora não'; 264 265 var accept = document.createElement('button'); 266 accept.type = 'button'; 267 accept.className = 'ep-prompt__btn ep-prompt__btn--primary'; 268 accept.textContent = prompt.accept_label || 'Ativar notificações'; 269 270 actions.appendChild(decline); 271 actions.appendChild(accept); 272 content.appendChild(actions); 273 274 root.appendChild(bell); 275 root.appendChild(content); 276 277 decline.addEventListener('click', function () { 278 // Seção 19.3: "Agora não" adia por mais tempo que fechar. 279 snooze(prompt.redisplay_later_days || 30); 280 report('preprompt_dismiss'); 281 close(root); 282 }); 283 284 accept.addEventListener('click', function () { 285 report('preprompt_accept'); 286 close(root); 287 requestPermission(); 288 }); 289 290 return root; 291 } 292 293 /** 294 * Tema escolhido em Aparência: light, dark ou auto (padrão). Antes da
295 * versão 1.0.5 o valor era salvo mas nunca aplicado â o pré-prompt 296 * seguia sempre o sistema do visitante. 297 */ 298 function promptTheme(appearance, prompt) { 299 var theme = appearance.theme || prompt.theme || 'auto'; 300 301 return theme === 'light' || theme === 'dark' ? theme : 'auto'; 302 } 303 304 /** Cores e borda da tela Aparência. Só hexadecimal e número passam. */ 305 function applyAppearance(root, appearance) { 306 var hex = /^#[0-9a-f]{6}$/i; 307 308 if (appearance.accent && hex.test(appearance.accent)) { 309 root.style.setProperty('--ep-accent', appearance.accent); 310 } 311 312 if (appearance.button_color && hex.test(appearance.button_color)) { 313 root.style.setProperty('--ep-button', appearance.button_color); 314 } 315 316 var radius = parseInt(appearance.radius, 10); 317 318 if (!isNaN(radius)) { 319 root.style.setProperty('--ep-radius', Math.max(0, Math.min(40, radius)) + 'px'); 320 } 321 } 322 323 function close(root) { 324 root.classList.add('ep-prompt--leaving'); 325 setTimeout(function () { 326 if (root.parentNode) root.parentNode.removeChild(root); 327 }, 200); 328 } 329 330 function snooze(days) { 331 store('snooze_until', Date.now() + days * 24 * 60 * 60 * 1000); 332 } 333 334 function showPrompt() { 335 if (document.querySelector('.ep-prompt')) return; 336 337 var element = buildPrompt(); 338 document.body.appendChild(element); 339 340 // Força o reflow antes de animar, senão a transicao não acontece. 341 requestAnimationFrame(function () { 342 element.classList.add('ep-prompt--visible'); 343 }); 344 345 report('preprompt_shown'); 346 } 347 348 /* ------------------------------------------------------ inscrição ---- */ 349 350 async function requestPermission() { 351 var permission; 352 353 try { 354 permission = await Notification.requestPermission(); 355 } catch (e) { 356 return; 357 } 358 359 if (permission !== 'granted') { 360 report('native_permission_denied'); 361 // Bloqueio nativo: não insistir mais (seção 113). 362 snooze(365); 363 refreshWidget(); 364 365 return; 366 } 367 368 report('native_permission_granted'); 369 370 await subscribe(); 371 } 372 373 async function subscribe() { 374 try { 375 var registration = await registerServiceWorker(); 376 377 if (!registration) return; 378 379 var serverKey = urlBase64ToUint8Array(config.vapidPublicKey); 380 var subscription = await registration.pushManager.getSubscription(); 381 382 /* 383 * Subscription criada com OUTRA chave VAPID â chave do site 384 * rotacionada, ou herdada de um provedor anterior. O Push Service 385 * recusaria todos os nossos envios para ela. Descarta e cria de 386 * novo com a chave atual, avisando o painel da antiga. 387 */ 388 if (subscription && !sameKey(subscription, serverKey)) { 389 log('a inscrição existente foi feita com outra chave VAPID; recriando.'); 390 391 var oldEndpoint = subscription.endpoint; 392 await subscription.unsubscribe(); 393 subscription = null; 394 395 notifyUnsubscribe(oldEndpoint); 396 } 397 398 if (!subscription) { 399 subscription = await registration.pushManager.subscribe({ 400 // userVisibleOnly é obrigatório: o navegador exige que toda 401 // mensagem resulte em notificação visÃvel. 402 userVisibleOnly: true, 403 applicationServerKey: serverKey, 404 }); 405 } 406 407 await sendSubscription(subscription); 408 } catch (e) { 409 // Antes este bloco era vazio, e toda falha de inscrição virava um 410 // assinante que simplesmente nunca chegava ao painel. Uma linha no 411 // console, para quem investiga, não incomoda o visitante. 412 log('falha ao inscrever este navegador:', e && e.message ? e.message : e); 413 widgetError = 'Não foi possÃvel ativar neste navegador. Recarregue a página e tente de novo.'; 414 } 415 } 416 417 /** 418 * navigator.serviceWorker.ready nunca rejeita: se o worker não ativar, 419 * a promessa fica pendente para sempre â e o botão do sino ficava em 420 * "Ativando..." indefinidamente. Com prazo, vira erro tratavel. 421 */ 422 function readyWithTimeout(ms) { 423 return Promise.race([ 424 navigator.serviceWorker.ready, 425 new Promise(function (resolve, reject) { 426 setTimeout(function () { 427 reject(new Error('o Service Worker não ativou em ' + (ms / 1000) + ' s')); 428 }, ms); 429 }), 430 ]); 431 } 432 433 /** A inscrição existente foi feita com a chave VAPID atual do site? */ 434 function sameKey(subscription, serverKey) { 435 var current = subscription.options && subscription.options.applicationServerKey; 436 437 // Navegador que não expoe a chave: sem como comparar, assume a atual. 438 if (!current) return true; 439 440 var bytes = new Uint8Array(current); 441 442 if (bytes.length !== serverKey.length) return false;
443 444 for (var i = 0; i < bytes.length; i++) { 445 if (bytes[i] !== serverKey[i]) return false; 446 } 447 448 return true; 449 } 450 451 function notifyUnsubscribe(endpoint) { 452 try { 453 fetch(config.endpoints.unsubscribe, { 454 method: 'POST', 455 headers: { 'Content-Type': 'application/json' }, 456 body: JSON.stringify({ endpoint: endpoint }), 457 keepalive: true, 458 }); 459 } catch (e) { /* o painel descobre sozinho no próximo envio */ } 460 } 461 462 /** 463 * Seção 17 â compatibilidade com Service Workers existentes. 464 * 465 * No modo "integration" ou "custom", o site já tem o próprio Service 466 * Worker e o plugin NÃO registra o seu: sobrescrever o registro na raiz 467 * quebraria o PWA ou o cache offline do site. 468 * 469 * Em todos os caminhos, a função só devolve um registro com worker ATIVO. 470 * O Chrome e o Edge recusam pushManager.subscribe() enquanto o worker 471 * recém-registrado ainda está instalando ("no active Service Worker"); o 472 * Safari espera. Sem aguardar a ativacao, a primeira visita falhava nos 473 * navegadores Chromium e só a página seguinte funcionava. 474 */ 475 async function registerServiceWorker() { 476 if (config.integrationMode !== 'exclusive') { 477 var siteRegistration = await navigator.serviceWorker.getRegistration('/'); 478 479 if (!siteRegistration) { 480 // Sem Service Worker do site, não há onde enxertar os listeners. 481 log('modo de integração "' + config.integrationMode + '", mas o site não tem Service Worker registrado.'); 482 widgetError = 'As notificações ainda não estão disponÃveis neste site.'; 483 484 return null; 485 } 486 487 return readyWithTimeout(15000); 488 } 489 490 var existing = await navigator.serviceWorker.getRegistration('/'); 491 var worker = existing && (existing.active || existing.waiting || existing.installing); 492 493 if (worker) { 494 var script = worker.scriptURL || ''; 495 496 // Outro Service Worker controla a raiz: não substituimos (seção 17). 497 // O motivo vai para o console, e a tela de Diagnóstico do plugin 498 // mostra o conflito ao operador. 499 if (script.indexOf('encontra-push-sw.js') === -1) { 500 log('outro Service Worker controla este site e não será substituÃdo: ' + script 501 + ' â veja Encontra Push > Diagnóstico no wp-admin.'); 502 widgetError = 'Este navegador já recebe notificações deste site por outro serviço.'; 503 504 return null; 505 } 506 507 return readyWithTimeout(15000); 508 } 509 510 await navigator.serviceWorker.register(config.serviceWorker, { scope: '/' }); 511 512 return readyWithTimeout(15000); 513 } 514 515 async function sendSubscription(subscription) { 516 var json = subscription.toJSON(); 517 var params = new URLSearchParams(window.location.search); 518 519 var response = await fetch(config.endpoints.subscribe, { 520 method: 'POST', 521 headers: { 'Content-Type': 'application/json' }, 522 body: JSON.stringify({ 523 endpoint: json.endpoint, 524 p256dh: json.keys && json.keys.p256dh, 525 auth: json.keys && json.keys.auth, 526 expiration_time: subscription.expirationTime || null, 527 language: navigator.language, 528 timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, 529 device_type: deviceType(), 530 source_url: window.location.href, 531 referrer: document.referrer || '', 532 utm_source: params.get('utm_source') || '', 533 utm_medium: params.get('utm_medium') || '', 534 utm_campaign: params.get('utm_campaign') || '', 535 utm_content: params.get('utm_content') || '', 536 }), 537 }); 538 539 // Antes a resposta era ignorada: um 403 de firewall ou um erro do 540 // painel deixava o navegador inscrito mas ausente da base. 541 if (!response.ok) { 542 log('o site recusou a inscrição (HTTP ' + response.status + '). Veja Encontra Push > Diagnóstico.'); 543 widgetError = 'O site não conseguiu registrar a inscrição agora. Tente de novo em instantes.'; 544 545 return; 546 } 547 548 store('subscribed', '1'); 549 store('unsubscribed', '0'); 550 log('inscrição registrada no painel.'); 551 552 refreshWidget(); 553 } 554 555 /* ------------------------------------------------------------ iOS ---- */ 556 557 /** 558 * Seção 20 â iPhone e iPad. 559 * 560 * No iOS/iPadOS, Web Push só funciona com o site instalado na Tela de 561 * InÃcio é aberto pelo Ãcone. Em Safari comum, PushManager sequer existe. 562 * 563 * A instrução só aparece quando o ambiente realmente exige o passo extra: 564 * se já estiver em modo standalone com suporte, não dizemos nada. 565 */ 566 function maybeShowIosGuide() { 567 var isAppleMobile = /iPad|iPhone|iPod/.test(navigator.userAgent) || 568 (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1); 569 570 if (!isAppleMobile) return; 571 572 var standalone = window.navigator.standalone === true || 573 window.matchMedia('(display-mode: standalone)').matches; 574 575 // Já instalado e ainda sem suporte: não há instrução que resolva. 576 if (standalone) return; 577 578 if (read('ios_guide_seen')) return; 579 580 var prompt = promptConfig(); 581 582 if (prompt.mode === 'disabled') return; 583 584 var root = document.createElement('div'); 585 root.className = 'ep-prompt ep-prompt--bottom-center ep-prompt--ios ep-prompt--theme-' 586 + promptTheme(appearanceConfig(), prompt); 587 applyAppearance(root, appearanceConfig()); 588 root.setAttribute('role', 'dialog'); 589 590 var content = document.createElement('div'); 591 content.className = 'ep-prompt__content'; 592 593 var title = document.createElement('p'); 594 title.className = 'ep-prompt__title'; 595 title.textContent = config.i18n.iosTitle; 596 content.appendChild(title); 597 598 var list = document.createElement('ol'); 599 list.className = 'ep-prompt__steps'; 600 601 config.i18n.iosSteps.forEach(function (step) { 602 var item = document.createElement('li'); 603 item.textContent = step; 604 list.appendChild(item); 605 }); 606 607 content.appendChild(list); 608 609 var actions = document.createElement('div'); 610 actions.className = 'ep-prompt__actions'; 611 612 var close_button = document.createElement('button'); 613 close_button.type = 'button'; 614 close_button.className = 'ep-prompt__btn ep-prompt__btn--ghost'; 615 close_button.textContent = config.i18n.close; 616 617 close_button.addEventListener('click', function () { 618 store('ios_guide_seen', '1'); 619 close(root); 620 }); 621 622 actions.appendChild(close_button); 623 content.appendChild(actions); 624 root.appendChild(content); 625 626 document.body.appendChild(root); 627 requestAnimationFrame(function () { 628 root.classList.add('ep-prompt--visible'); 629 }); 630 } 631 632 /* ---------------------------------------------------------- disparo -- */ 633 634 function boot() { 635 var counters = trackVisit(); 636 637 initWidget(); 638 639 // Já concedida: garante que a subscription atual chegou ao painel 640 // (o navegador pode ter trocado o endpoint) â sem pedir nada de novo. 641 // Exceto se o visitante escolheu "Parar de receber": a permissão do 642 // navegador continua concedida, mas a vontade dele é não receber. 643 if (Notification.permission === 'granted') { 644 if (read('unsubscribed') !== '1') { 645 subscribe(); 646 } 647 648 return; 649 } 650 651 if (!shouldShowPrompt()) return; 652 653 var prompt = promptConfig(); 654 655 switch (prompt.mode) { 656 case 'visits': 657 if (counters.visits >= (prompt.visits || 2)) ask(); 658 break; 659 660 case 'pageviews': 661 if (counters.pageviews >= (prompt.pageviews || 2)) ask(); 662 break; 663 664 // Seletor e manual dependem de um clique, e clique já e a ação do 665 // usuário que autoriza o pedido nativo: os dois modos vão direto 666 // ao navegador, independentemente do estilo escolhido. 667 case 'selector': 668 bindSelector(prompt.css_selector); 669 break; 670 671 case 'manual': 672 bindSelector('[data-encontra-push-subscribe]'); 673 break; 674 675 default: 676 setTimeout(ask, (prompt.delay_seconds || 8) * 1000); 677 } 678 } 679 680 /* 681 * Ponto único de "chegou a hora de pedir". 682 * 683 * `style` decide COMO pedir; o modo (segundos, visitas, páginas) decide 684 * QUANDO. São coisas separadas de propósito: dá para pedir direto o nativo 685 * depois de 3 páginas, ou mostrar o pré-prompt na hora. 686 */ 687 function ask() { 688 if (promptConfig().style === 'native') { 689 askNative(); 690 691 return; 692 } 693 694 showPrompt(); 695 } 696 697 /**
698 * Pedido nativo sem passar pelo pré-prompt. 699 * 700 * Só o Chromium abre esse pedido sem um gesto do usuário. O Firefox 701 * ignora a chamada em silêncio e o Safari a recusa â nos dois, a promessa 702 * volta com "default" (ou estoura), e sem tratamento o visitante desses 703 * navegadores nunca teria como se inscrever. Por isso a queda para o 704 * pré-prompt, que funciona em todos. 705 * 706 * Detectar assim, pelo resultado, e melhor do que olhar o user-agent: o 707 * mesmo caminho cobre o Chrome com pedido silencioso (aquele que vira só 708 * um Ãcone na barra de endereço), que também devolve "default". 709 */ 710 async function askNative() { 711 var permission; 712 713 try { 714 permission = await Notification.requestPermission(); 715 } catch (e) { 716 showPrompt(); 717 718 return; 719 } 720 721 if (permission === 'granted') { 722 report('native_permission_granted'); 723 await subscribe(); 724 725 return; 726 } 727 728 if (permission === 'denied') { 729 report('native_permission_denied'); 730 // Bloqueio nativo: não insistir mais (seção 113). 731 snooze(365); 732 refreshWidget(); 733 734 return; 735 } 736 737 showPrompt(); 738 } 739 740 function bindSelector(selector) { 741 if (!selector) return; 742 743 document.addEventListener('click', function (event) { 744 var trigger = event.target.closest(selector); 745 746 if (!trigger) return; 747 748 event.preventDefault(); 749 750 // Clique explÃcito já é a ação do usuário que a seção 19.1 exige: 751 // pode ir direto para o prompt nativo. 752 report('preprompt_accept'); 753 requestPermission(); 754 }); 755 } 756 757 /* ------------------------------------------------ sino de notÃcias -- */ 758 759 /* 760 * Botão fixo com sino na lateral do site (Encontra Push > Sino de notÃcias). 761 * 762 * inscrito -> últimas notÃcias, com contador de não lidas e o botão 763 * "Parar de receber"; 764 * não inscrito -> convite com o botão de ativar; 765 * bloqueado -> como liberar no navegador; 766 * sem suporte -> no iPhone, os passos da Tela de InÃcio. 767 * 768 * Tudo montado com textContent: nenhum texto vindo do site vira HTML. 769 */ 770 771 var widget = null; // { root, button, badge, panel, body, foot, open } 772 773 // Ãltimo motivo de falha da inscrição, mostrado no convite do sino. 774 var widgetError = ''; 775 776 function onReady(callback) { 777 if (document.readyState === 'loading') { 778 document.addEventListener('DOMContentLoaded', callback); 779 } else { 780 callback(); 781 } 782 } 783 784 function el(tag, className, text) { 785 var node = document.createElement(tag); 786 787 if (className) node.className = className; 788 if (text !== undefined && text !== null) node.textContent = text; 789 790 return node; 791 } 792 793 function widgetConfig() { 794 return config.widget || null; 795 } 796 797 function widgetItems() { 798 var cfg = widgetConfig(); 799 800 return (cfg && Array.isArray(cfg.items)) ? cfg.items : []; 801 } 802 803 function readIds() { 804 return (read('widget_read') || '').split(',').filter(Boolean); 805 } 806 807 function isUnread(item) { 808 var seen = parseInt(read('widget_seen') || '0', 10); 809 var published = Date.parse(item.date); 810 811 if (readIds().indexOf(String(item.id)) !== -1) return false; 812 813 return isNaN(published) || published > seen; 814 } 815 816 function unreadCount() { 817 return widgetItems().filter(isUnread).length; 818 } 819 820 function markRead(id) { 821 var ids = readIds(); 822 823 if (ids.indexOf(String(id)) === -1) { 824 ids.unshift(String(id)); 825 } 826 827 store('widget_read', ids.slice(0, 50).join(',')); 828 } 829 830 function markAllRead() { 831 store('widget_seen', Date.now()); 832 store('widget_read', ''); 833 } 834 835 function timeAgo(iso) { 836 var time = Date.parse(iso); 837 838 if (isNaN(time)) return ''; 839 840 var seconds = Math.round((time - Date.now()) / 1000); 841 var units = [ 842 ['year', 31536000], ['month', 2592000], ['week', 604800], 843 ['day', 86400], ['hour', 3600], ['minute', 60], 844 ]; 845 846 try { 847 var rtf = new Intl.RelativeTimeFormat('pt-BR', { numeric: 'auto' }); 848 849 for (var i = 0; i < units.length; i++) { 850 if (Math.abs(seconds) >= units[i][1]) { 851 return rtf.format(Math.round(seconds / units[i][1]), units[i][0]); 852 } 853 } 854 855 return rtf.format(0, 'minute'); 856 } catch (e) { 857 return new Date(time).toLocaleDateString('pt-BR'); 858 } 859 } 860 861 function isAppleMobile() { 862 return /iPad|iPhone|iPod/.test(navigator.userAgent) || 863 (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1); 864 } 865 866 /** Estado atual do visitante, conferido no próprio navegador. */ 867 async function widgetState() { 868 if (!supported) return 'unsupported'; 869 if (Notification.permission === 'denied') return 'blocked'; 870 if (Notification.permission !== 'granted') return 'default'; 871 872 try { 873 var registration = await navigator.serviceWorker.getRegistration('/'); 874 var subscription = registration && await registration.pushManager.getSubscription(); 875 876 return subscription && read('unsubscribed') !== '1' ? 'subscribed' : 'default'; 877 } catch (e) { 878 return 'default'; 879 } 880 } 881 882 function bellIcon() { 883 var ns = 'http://www.w3.org/2000/svg'; 884 var svg = document.createElementNS(ns, 'svg'); 885 886 svg.setAttribute('viewBox', '0 0 24 24'); 887 svg.setAttribute('width', '26'); 888 svg.setAttribute('height', '26'); 889 svg.setAttribute('aria-hidden', 'true'); 890 svg.setAttribute('fill', 'none'); 891 svg.setAttribute('stroke', 'currentColor'); 892 svg.setAttribute('stroke-width', '2'); 893 svg.setAttribute('stroke-linecap', 'round'); 894 svg.setAttribute('stroke-linejoin', 'round'); 895 896 ['M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9', 'M13.73 21a2 2 0 0 1-3.46 0'].forEach(function (d) { 897 var path = document.createElementNS(ns, 'path'); 898 path.setAttribute('d', d); 899 svg.appendChild(path); 900 }); 901 902 return svg; 903 } 904 905 function initWidget() { 906 var cfg = widgetConfig(); 907 908 if (!cfg || !cfg.enabled || widget) return; 909 if (isMobile() ? !cfg.mobile : !cfg.desktop) return; 910 911 var appearance = appearanceConfig(); 912 var side = cfg.position === 'left' ? 'left' : 'right'; 913 914 var root = el('div', 'ep-bell ep-bell--' + side + ' ep-prompt--theme-' + promptTheme(appearance, promptConfig())); 915 applyAppearance(root, appearance); 916 root.style.setProperty('--ep-bell-offset', Math.max(0, Math.min(200, parseInt(cfg.offset, 10) || 20)) + 'px'); 917 918 var button = el('button', 'ep-bell__button'); 919 button.type = 'button'; 920 button.setAttribute('aria-label', cfg.title || 'NotÃcias'); 921 button.setAttribute('aria-expanded', 'false'); 922 button.setAttribute('aria-controls', 'ep-bell-panel'); 923 button.appendChild(bellIcon()); 924 925 var badge = el('span', 'ep-bell__badge'); 926 badge.setAttribute('aria-hidden', 'true'); 927 button.appendChild(badge); 928 929 var panel = el('div', 'ep-bell__panel'); 930 panel.id = 'ep-bell-panel'; 931 panel.setAttribute('role', 'dialog'); 932 panel.setAttribute('aria-label', cfg.title || 'NotÃcias'); 933 panel.hidden = true; 934 935 var head = el('div', 'ep-bell__head'); 936 var headTitle = el('p', 'ep-bell__title'); 937 var closeButton = el('button', 'ep-bell__close', 'Ã'); 938 closeButton.type = 'button'; 939 closeButton.setAttribute('aria-label', 'Fechar'); 940 head.appendChild(headTitle); 941 head.appendChild(closeButton); 942 943 var body = el('div', 'ep-bell__body'); 944 var foot = el('div', 'ep-bell__foot'); 945 946 panel.appendChild(head); 947 panel.appendChild(body); 948 panel.appendChild(foot); 949 950 root.appendChild(panel); 951 root.appendChild(button); 952 document.body.appendChild(root); 953 954 widget = { root: root, button: button, badge: badge, panel: panel, title: headTitle, body: body, foot: foot, open: false }; 955 956 button.addEventListener('click', function () { 957 toggleWidget(!widget.open); 958 }); 959 960 closeButton.addEventListener('click', function () { 961 toggleWidget(false); 962 button.focus(); 963 }); 964 965 document.addEventListener('keydown', function (event) { 966 if (event.key === 'Escape' && widget.open) { 967 toggleWidget(false); 968 button.focus(); 969 } 970 }); 971 972 document.addEventListener('click', function (event) { 973 if (widget.open && !root.contains(event.target)) { 974 toggleWidget(false); 975 } 976 }); 977 978 updateBadge(); 979 maybeShowTeaser(); 980 } 981 982 /** 983 * Card da última notÃcia ao lado do sino, assim que a página carrega. 984 * 985 * E a amostra que chama a atenção para o sino: some sozinho depois de 986 * alguns segundos e aparece UMA vez por sessão do navegador, para não 987 * incomodar quem está navegando pelo site. 988 */ 989 function maybeShowTeaser() { 990 var cfg = widgetConfig(); 991 992 if (!cfg.teaser || sessionStorageGet('teaser_shown')) return; 993 994 var items = widgetItems().filter(function (item) { 995 return /^https?:\/\//i.test(item.url || ''); 996 }); 997 998 if (items.length === 0) return; 999 1000 var item = items[0]; 1001 1002 sessionStorageSet('teaser_shown', '1'); 1003 1004 var card = el('div', 'ep-bell__teaser'); 1005 1006 var link = el('a', 'ep-bell__teaser-link'); 1007 link.href = item.url; 1008 1009 if (item.image && /^https?:\/\//i.test(item.image)) { 1010 var img = el('img', 'ep-bell__teaser-thumb'); 1011 img.src = item.image; 1012 img.alt = ''; 1013 img.loading = 'lazy'; 1014 img.width = 56; 1015 img.height = 56; 1016 link.appendChild(img); 1017 } 1018 1019 var text = el('span', 'ep-bell__teaser-text'); 1020 text.appendChild(el('span', 'ep-bell__teaser-title', item.title)); 1021 text.appendChild(el('span', 'ep-bell__item-time', timeAgo(item.date))); 1022 link.appendChild(text); 1023 1024 var close = el('button', 'ep-bell__teaser-close', 'Ã'); 1025 close.type = 'button'; 1026 close.setAttribute('aria-label', 'Fechar'); 1027 1028 card.appendChild(link); 1029 card.appendChild(close); 1030 widget.root.appendChild(card); 1031 1032 requestAnimationFrame(function () { 1033 card.classList.add('ep-bell__teaser--visible'); 1034 }); 1035 1036 var timer = setTimeout(hide, Math.max(3, Math.min(30, parseInt(cfg.teaser_seconds, 10) || 8)) * 1000); 1037 1038 function hide() { 1039 clearTimeout(timer); 1040 card.classList.remove('ep-bell__teaser--visible'); 1041 setTimeout(function () { 1042 if (card.parentNode) card.parentNode.removeChild(card); 1043 }, 220); 1044 } 1045 1046 close.addEventListener('click', function (event) { 1047 event.preventDefault(); 1048 hide(); 1049 }); 1050 1051 // Não some enquanto a pessoa está lendo ou com o dedo em cima. 1052 card.addEventListener('mouseenter', function () { 1053 clearTimeout(timer); 1054 }); 1055 1056 link.addEventListener('click', function () { 1057 markRead(item.id); 1058 updateBadge(); 1059 }); 1060 } 1061 1062 function toggleWidget(open) { 1063 if (!widget) return; 1064 1065 widget.open = open; 1066 widget.panel.hidden = !open; 1067 widget.button.setAttribute('aria-expanded', open ? 'true' : 'false');
1068 widget.root.classList.toggle('ep-bell--open', open); 1069 1070 if (open) { 1071 renderWidget(); 1072 } 1073 } 1074 1075 function updateBadge() { 1076 if (!widget) return; 1077 1078 var cfg = widgetConfig(); 1079 var count = unreadCount(); 1080 1081 widget.badge.textContent = count > 9 ? '9+' : String(count); 1082 widget.badge.hidden = !cfg.badge || count === 0; 1083 1084 widget.button.setAttribute( 1085 'aria-label', 1086 (cfg.title || 'NotÃcias') + (count > 0 ? ' (' + count + ' não lidas)' : '') 1087 ); 1088 } 1089 1090 /** Redesenha o painel se estiver aberto; o contador sempre. */ 1091 function refreshWidget() { 1092 updateBadge(); 1093 1094 if (widget && widget.open) { 1095 renderWidget(); 1096 } 1097 } 1098 1099 async function renderWidget() { 1100 var cfg = widgetConfig(); 1101 var state = await widgetState(); 1102 1103 widget.body.textContent = ''; 1104 widget.foot.textContent = ''; 1105 widget.root.setAttribute('data-state', state); 1106 1107 if (state === 'subscribed') { 1108 widget.title.textContent = cfg.title; 1109 renderNews(cfg); 1110 1111 return; 1112 } 1113 1114 /* 1115 * "Amostra grátis": quem ainda não ativou também vê as notÃcias, e o 1116 * convite fica logo abaixo. Ver o que anda sendo publicado convence 1117 * mais do que qualquer texto pedindo permissão. 1118 */ 1119 var preview = cfg.preview && widgetItems().length > 0; 1120 1121 widget.title.textContent = preview ? cfg.title : cfg.subscribe_title; 1122 1123 if (preview) { 1124 renderList(); 1125 } 1126 1127 if (state === 'blocked') { 1128 widget.foot.appendChild(el('p', 'ep-bell__text ep-bell__text--hint', cfg.blocked_text)); 1129 } else if (state === 'unsupported') { 1130 renderUnsupported(preview); 1131 } else { 1132 renderInvite(cfg, preview); 1133 } 1134 } 1135 1136 function renderNews(cfg) { 1137 renderList(); 1138 1139 if (unreadCount() > 0) { 1140 var markAll = el('button', 'ep-bell__link', 'Marcar tudo como lido'); 1141 markAll.type = 'button'; 1142 markAll.addEventListener('click', function () { 1143 markAllRead(); 1144 refreshWidget(); 1145 }); 1146 widget.foot.appendChild(markAll); 1147 } 1148 1149 widget.foot.appendChild(el('p', 'ep-bell__status', cfg.subscribed_text)); 1150 1151 var stop = el('button', 'ep-bell__btn ep-bell__btn--ghost', 'Parar de receber'); 1152 stop.type = 'button'; 1153 stop.addEventListener('click', async function () { 1154 stop.disabled = true; 1155 await window.EncontraPushUnsubscribe(); 1156 refreshWidget(); 1157 }); 1158 widget.foot.appendChild(stop); 1159 } 1160 1161 /** Lista de notÃcias, usada tanto para inscritos quanto na amostra. */ 1162 function renderList() { 1163 var items = widgetItems(); 1164 1165 if (items.length === 0) { 1166 widget.body.appendChild(el('p', 'ep-bell__text', 'Nenhuma notÃcia por enquanto.')); 1167 1168 return; 1169 } 1170 1171 var list = el('ul', 'ep-bell__list'); 1172 1173 items.forEach(function (item) { 1174 if (!/^https?:\/\//i.test(item.url || '')) return; 1175 1176 var li = el('li', 'ep-bell__entry'); 1177 var link = el('a', 'ep-bell__item' + (isUnread(item) ? ' ep-bell__item--unread' : '')); 1178 link.href = item.url; 1179 1180 if (item.image && /^https?:\/\//i.test(item.image)) { 1181 var img = el('img', 'ep-bell__thumb'); 1182 img.src = item.image; 1183 img.alt = ''; 1184 img.loading = 'lazy'; 1185 img.width = 56; 1186 img.height = 56; 1187 link.appendChild(img); 1188 } 1189 1190 var text = el('span', 'ep-bell__item-text'); 1191 text.appendChild(el('span', 'ep-bell__item-title', item.title)); 1192 text.appendChild(el('span', 'ep-bell__item-time', timeAgo(item.date))); 1193 link.appendChild(text); 1194 1195 link.addEventListener('click', function () { 1196 markRead(item.id); 1197 updateBadge(); 1198 }); 1199 1200 li.appendChild(link); 1201 list.appendChild(li); 1202 }); 1203 1204 widget.body.appendChild(list); 1205 } 1206 1207 /** 1208 * Convite para ativar. Com a amostra ligada, ele vem DEPOIS da lista, no 1209 * rodapé do painel; sem ela, ocupa o painel inteiro. 1210 */ 1211 function renderInvite(cfg, preview) { 1212 var target = preview ? widget.foot : widget.body; 1213 1214 target.appendChild(el('p', preview ? 'ep-bell__status' : 'ep-bell__text', cfg.subscribe_text)); 1215 1216 if (widgetError) { 1217 widget.body.appendChild(el('p', 'ep-bell__text ep-bell__text--error', widgetError)); 1218 } 1219 1220 var hint = el('p', 'ep-bell__text ep-bell__text--hint'); 1221 hint.hidden = true; 1222 widget.body.appendChild(hint); 1223 1224 var activate = el('button', 'ep-bell__btn ep-bell__btn--primary', cfg.button_label); 1225 activate.type = 'button'; 1226 activate.addEventListener('click', async function () { 1227 activate.disabled = true; 1228 activate.textContent = 'Ativandoâ¦'; 1229 widgetError = ''; 1230 1231 report('preprompt_accept'); 1232 store('unsubscribed', '0'); 1233 1234 /* 1235 * Chrome e Edge à s vezes não abrem a janela de permissão: mostram 1236 * só um Ãcone de sino na barra de endereço ("pedido silencioso"), 1237 * e a promessa do navegador fica esperando até a pessoa clicar lá. 1238 * Sem este aviso, o botão parecia travado em "Ativando...". 1239 */ 1240 var quietTimer = setTimeout(function () { 1241 if (Notification.permission === 'default') { 1242 hint.textContent = 'Confirme o pedido do navegador. Se nenhuma janela abriu, clique no Ãcone de sino ou de cadeado n
1242a barra de endereço e escolha "Permitir".'; 1243 hint.hidden = false; 1244 } 1245 }, 4000); 1246 1247 try { 1248 // Permissão já concedida (ex.: parou de receber antes): só inscreve. 1249 if (Notification.permission === 'granted') { 1250 await subscribe(); 1251 } else { 1252 await requestPermission(); 1253 } 1254 } finally { 1255 clearTimeout(quietTimer); 1256 } 1257 1258 refreshWidget(); 1259 }); 1260 1261 widget.foot.appendChild(activate); 1262 } 1263 1264 function renderUnsupported(preview) { 1265 var target = preview ? widget.foot : widget.body; 1266 1267 if (!isAppleMobile()) { 1268 target.appendChild(el('p', 'ep-bell__text', 'Este navegador não permite notificações neste site.')); 1269 return; 1270 } 1271 1272 target.appendChild(el('p', 'ep-bell__text', config.i18n.iosTitle + ':')); 1273 1274 var steps = el('ol', 'ep-bell__steps'); 1275 1276 config.i18n.iosSteps.forEach(function (step) { 1277 steps.appendChild(el('li', null, step)); 1278 }); 1279 1280 target.appendChild(steps); 1281 } 1282 1283 /** API publica para o site desinscrever o visitante (seção 22.1). */ 1284 window.EncontraPushUnsubscribe = async function () { 1285 try { 1286 var registration = await navigator.serviceWorker.getRegistration('/'); 1287 1288 if (!registration) return false; 1289 1290 var subscription = await registration.pushManager.getSubscription(); 1291 1292 if (!subscription) return false; 1293 1294 var endpoint = subscription.endpoint; 1295 1296 await subscription.unsubscribe(); 1297 1298 // Lembra a escolha: sem isto, a próxima página reinscreveria o 1299 // navegador sozinha (a permissão continua "granted"). 1300 store('unsubscribed', '1'); 1301 store('subscribed', '0'); 1302 1303 await fetch(config.endpoints.unsubscribe, { 1304 method: 'POST', 1305 headers: { 'Content-Type': 'application/json' }, 1306 body: JSON.stringify({ endpoint: endpoint }), 1307 }); 1308 1309 return true; 1310 } catch (e) { 1311 return false; 1312 } 1313 }; 1314 1315 if (document.readyState === 'loading') { 1316 document.addEventListener('DOMContentLoaded', boot); 1317 } else { 1318 boot(); 1319 } 1320})();
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.