PageSourceSearch

https://www.estadiodopacaembu.com.br/wp-content/plugins/encontra-push/assets/js/subscribe.js?ver=1.0.20

js estadiodopacaembu.com.br collected 2026-10-02 13:05:09 UTC 45,162 bytes, 1,320 lines download raw bytes

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.