PageSourceSearch

https://scanqr.org/js/pwa.js

js scanqr.org collected 2026-10-03 19:52:07 UTC 8,666 bytes, 230 lines download raw bytes

1/**
2 * ScanQR — pwa.js
3 * Link before </body> on every page:
4 *   <script src="/js/pwa.js" defer><\/script>
5 *
6 * Handles:
7 *  1. Service worker registration + update detection
8 *  2. Nav "Install App" button (appears only when installable)
9 *  3. SW update toast (user-initiated update — no auto-reload)
10 *  4. Offline ad hiding
11 *  5. Network quality indicator (optional nav badge)
12 *
13 * Nav HTML required (add once to your navbar):
14 *   <button id="pwa-install-nav-btn" hidden aria-label="Install ScanQR app">
15 *     📲 Install App
16 *   </button>
17 *
18 * Optional network badge (add to navbar):
19 *   <span id="pwa-net-badge" hidden></span>
20 *
21 * NO inline install banner — removed. Nav button only.
22 * NO auto-reload on update — user must click Update in the toast.
23 * This prevents data loss if the user has an active scan result open.
24 */
25
26(function () {
27  'use strict';
28
29  if (!('serviceWorker' in navigator)) return;
30
31  // ── 1. Register SW ──────────────────────────────────────
32  let newWorker = null;
33  let reg = null;
34
35  navigator.serviceWorker.register('/service-worker.js', { scope: '/' })
36    .then(registration => {
37      reg = registration;
38
39      // Detect new SW waiting to install
40      reg.addEventListener('updatefound', () => {
41        newWorker = reg.installing;
42        newWorker.addEventListener('statechange', () => {
43          if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
44            // New version available — show toast, do NOT auto-reload
45            showUpdateToast();
46          }
47        });
48      });
49
50      // Poll for updates every hour
51      setInterval(() => reg.update(), 60 * 60 * 1000);
52    })
53    .catch(err => console.warn('[PWA] SW registration failed:', err));
54
55  // ── REMOVED: auto-reload on controllerchange ────────────
56  // Previously: navigator.serviceWorker.addEventListener('controllerchange', () => location.reload())
57  // Removed because it fires immediately when user clicks Update in the toast,
58  // causing an unexpected reload mid-use. The toast's Update button now handles
59  // the reload explicitly after skipWaiting, giving the user control.
60
61  // ── 2. Nav install button ───────────────────────────────
62  let deferredPrompt = null;
63
64  window.addEventListener('beforeinstallprompt', e => {
65    e.preventDefault();
66    deferredPrompt = e;
67    showInstallButton();
68  });
69
70  window.addEventListener('appinstalled', () => {
71    deferredPrompt = null;
72    hideInstallButton();
73    localStorage.setItem('pwa-installed', '1');
74    trackEvent('pwa_install', 'PWA', 'installed');
75  });
76
77  function showInstallButton() {
78    if (localStorage.getItem('pwa-installed')) return;
79    const btn = document.getElementById('pwa-install-nav-btn');
80    if (!btn) return;
81    btn.hidden = false;
82    btn.style.display = '';
83    btn.addEventListener('click', triggerInstall, { once: true });
84  }
85
86  function hideInstallButton() {
87    const btn = document.getElementById('pwa-install-nav-btn');
88    if (btn) { btn.hidden = true; btn.style.display = 'none'; }
89  }
90
91  function triggerInstall() {
92    if (!deferredPrompt) return;
93    deferredPrompt.prompt();
94    deferredPrompt.userChoice.then(choice => {
95      if (choice.outcome === 'accepted') {
96        localStorage.setItem('pwa-installed', '1');
97        trackEvent('pwa_install', 'PWA', 'installed');
98      }
99      deferredPrompt = null;
100      hideInstallButton();
101    });
102  }
103
104  // ── 3. Update toast ─────────────────────────────────────
105  // Injected into DOM automatically — no markup needed in HTML.
106  // User MUST click Update to reload — no automatic reload.
107  function createUpdateToast() {
108    if (document.getElementById('pwa-update-toast')) return;
109
110    const toast = document.createElement('div');
111    toast.id = 'pwa-update-toast';
112    toast.setAttribute('role', 'alert');
113    toast.setAttribute('aria-live', 'polite');
114    toast.style.cssText = [
115      'position:fixed', 'top:16px', 'right:16px',
116      'background:#1a2e35', 'color:#fff',
117      'padding:12px 16px', 'border-radius:12px',
118      'display:none', 'align-items:center', 'gap:12px',
119      'z-index:9999', 'font-size:14px', 'font-family:inherit',
120      'box-shadow:0 4px 20px rgba(0,0,0,.25)',
121      'max-width:calc(100vw - 32px)'
122    ].join(';');
123
124    toast.innerHTML = `
125      <span>🔄 New version available</span>
126      <button id="pwa-update-btn" style="
127        background:#2b6777;color:#fff;border:none;
128        padding:5px 14px;border-radius:7px;
129        cursor:pointer;font-size:13px;font-weight:600;
130        font-family:inherit;
131      ">Update</button>
132      <button id="pwa-update-dismiss" style="
133        background:transparent;border:none;color:#aaa;
134        cursor:pointer;font-size:18px;line-height:1;padding:0 2px;
135      " aria-label="Dismiss">×</button>
136    `;
137    document.body.appendChild(toast);
138  }
139
140  function showUpdateToast() {
141    createUpdateToast();
142    const toast = document.getElementById('pwa-update-toast');
143    if (!toast) return;
144    toast.style.display = 'flex';
145
146    document.getElementById('pwa-update-btn')?.addEventListener('click', () => {
147      toast.style.display = 'none';
148      if (newWorker) {
149        // Tell the new SW to take over
150        newWorker.postMessage({ action: 'skipWaiting' });
151        // Wait briefly then reload — user clicked Update so this is intentional
152        setTimeout(() => window.location.reload(), 300);
153      }
154    }, { once: true });
155
156    document.getElementById('pwa-update-dismiss')?.addEventListener('click', () => {
157      toast.style.display = 'none';
158    }, { once: true });
159  }
160
161  // ── 4. Offline ad hiding ────────────────────────────────
162  function handleAdVisibility() {
163    const ads = document.querySelectorAll('.adsbygoogle');
164    if (!navigator.onLine) {
165      ads.forEach(ad => {
166        if (ad.dataset.offlineHidden) return;
167        ad.dataset.offlineHidden = '1';
168        ad.style.display = 'none';
169        const msg = document.createElement('div');
170        msg.className = 'ad-offline-placeholder';
171        msg.style.cssText = 'text-align:center;padding:8px;font-size:11px;color:#9aa0a6;';
172        msg.textContent = 'Ad unavailable offline';
173        ad.after(msg);
174      });
175    } else {
176      ads.forEach(ad => {
177        if (!ad.dataset.offlineHidden) return;
178        delete ad.dataset.offlineHidden;
179        ad.style.display = '';
180      });
181      document.querySelectorAll('.ad-offline-placeholder').forEach(m => m.remove());
182    }
183  }
184
185  // ── 5. Network quality badge (optional) ────────────────
186  function updateNetworkBadge() {
187    const badge = document.getElementById('pwa-net-badge');
188    if (!badge) return;
189
190    const cn = navigator.connection || navigator.mozConnection || navigator.webkitConnection;
191
192    if (!navigator.onLine) {
193      badge.hidden = false;
194      badge.textContent = '● Offline';
195      badge.style.color = '#c62828';
196      return;
197    }
198
199    if (cn) {
200      const type = cn.effectiveType || '';
201      const slow = type === 'slow-2g' || type === '2g' || cn.rtt > 500;
202      if (slow) {
203        badge.hidden = false;
204        badge.textContent = '● Slow';
205        badge.style.color = '#e65100';
206        return;
207      }
208    }
209
210    badge.hidden = true;
211  }
212
213  // ── Init ────────────────────────────────────────────────
214  handleAdVisibility();
215  updateNetworkBadge();
216
217  window.addEventListener('online',  () => { handleAdVisibility(); updateNetworkBadge(); });
218  window.addEventListener('offline', () => { handleAdVisibility(); updateNetworkBadge(); });
219
220  const cn = navigator.connection || navigator.mozConnection || navigator.webkitConnection;
221  if (cn) cn.addEventListener('change', updateNetworkBadge);
222
223  // ── Utility ─────────────────────────────────────────────
224  function trackEvent(name, category, label) {
225    if (typeof gtag !== 'undefined') {
226      gtag('event', name, { event_category: category, event_label: label });
227    }
228  }
229
230})();

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.