1/* Velvet Push â soft-prompt UX (PUSH_NOTIFICATIONS.md §3). 2 * Never fires the native permission prompt on load: a denied native prompt is 3 * permanently lost in Chrome. A branded 2-step in-page prompt gates it, shown only on 4 * engagement, with hard frequency caps. Config comes from VELVET_PUSH_CFG (localized). */ 5(function () { 6 'use strict'; 7 var CFG = window.VELVET_PUSH_CFG; 8 if (!CFG || !CFG.vapidKey) { return; } 9 10 // iOS Safari (not an installed PWA) has no PushManager â web push is impossible in a tab. 11 // Nudge the visitor to add the site to the Home Screen instead, then stop. 12 var pushSupported = ('serviceWorker' in navigator) && ('PushManager' in window); 13 if (!pushSupported) { 14 var iOS = /iPhone|iPad|iPod/.test(navigator.userAgent); 15 var standalone = (window.navigator.standalone === true) || 16 (window.matchMedia && window.matchMedia('(display-mode: standalone)').matches); 17 if (iOS && !standalone && CFG.ios) { velvetPushIosHint(); } 18 return; 19 } 20 if (typeof Notification === 'undefined' || Notification.permission === 'denied') { 21 return; // already decided â never nag 22 } 23 24 var LS = window.localStorage; 25 var K_DISMISS = 'velvet_push_dismiss_until'; 26 var K_COUNT = 'velvet_push_prompt_count'; 27 var K_SESSION = 'velvet_push_sessions'; 28 var MAX_LIFETIME = 3; // §3.1 max soft-prompt exposures 29 var SUPPRESS_DAYS = 14; 30 31 function now() { return Date.now(); } 32 function getNum(k) { var v = parseInt(LS.getItem(k) || '0', 10); return isNaN(v) ? 0 : v; } 33 34 // Count a session (once per tab-open) to support the "2nd session" trigger. 35 if (!sessionStorage.getItem('velvet_push_session_counted')) { 36 sessionStorage.setItem('velvet_push_session_counted', '1'); 37 LS.setItem(K_SESSION, String(getNum(K_SESSION) + 1)); 38 } 39 40 function suppressed() { 41 var until = parseInt(LS.getItem(K_DISMISS) || '0', 10); 42 if (until && now() < until) { return true; } 43 if (getNum(K_COUNT) >= MAX_LIFETIME) { return true; } 44 return false; 45 } 46 if (suppressed()) { return; } 47 48 function detectBrowser() { 49 var ua = navigator.userAgent; 50 if (/Edg\//.test(ua)) { return 'edge'; } 51 if (/Firefox\//.test(ua)) { return 'firefox'; } 52 if (/Chrome\//.test(ua)) { return 'chrome'; } 53 if (/Safari\//.test(ua)) { return 'safari'; } 54 return 'other'; 55 } 56 function detectOS() { 57 var ua = navigator.userAgent; 58 if (/Android/.test(ua)) { return 'android'; } 59 if (/iPhone|iPad|iPod/.test(ua)) { return 'ios'; } 60 if (/Windows/.test(ua)) { return 'windows'; } 61 if (/Mac OS X/.test(ua)) { return 'macos'; } 62 if (/Linux/.test(ua)) { return 'linux'; } 63 return 'other'; 64 } 65 function deviceClass() { 66 return /Mobi|Android|iPhone|iPod/.test(navigator.userAgent) ? 'mobile' 67 : (/iPad|Tablet/.test(navigator.userAgent) ? 'tablet' : 'desktop'); 68 } 69 70 function urlBase64ToUint8Array(base64) { 71 var padding = '='.repeat((4 - (base64.length % 4)) % 4); 72 var b64 = (base64 + padding).replace(/-/g, '+').replace(/_/g, '/'); 73 var raw = window.atob(b64); 74 var out = new Uint8Array(raw.length); 75 for (var i = 0; i < raw.length; i++) { out[i] = raw.charCodeAt(i); } 76 return out; 77 } 78 79 var shown = false; 80 81 function render() { 82 if (shown) { return; } 83 shown = true; 84 var isMobile = deviceClass() !== 'desktop'; 85 var box = document.createElement('div'); 86 box.setAttribute('role', 'dialog'); 87 box.style.cssText = [ 88 'position:fixed', 'z-index:2147483000', 89 isMobile ? 'left:12px' : 'right:20px', isMobile ? 'right:12px' : 'right:20px', 90 'bottom:' + (isMobile ? '12px' : '20px'), isMobile ? '' : 'max-width:360px', 91 'background:#fff', 'color:#111', 'border-radius:14px', 92 'box-shadow:0 10px 40px rgba(0,0,0,.18)', 'padding:16px 18px', 93 'font:14px/1.45 -apple-system,Segoe UI,Roboto,sans-serif' 94 ].join(';'); 95 var h = document.createElement('div'); 96 h.textContent = CFG.copy.headline; 97 h.style.cssText = 'font-weight:600;margin-bottom:12px'; 98 var row = document.createElement('div'); 99 row.style.cssText = 'display:flex;gap:8px;justify-content:flex-end'; 100 var no = document.createElement('button'); 101 no.textContent = CFG.copy.dismiss; 102 no.style.cssText = 'background:transparent;border:0;color:#666;padding:8px 12px;cursor:pointer;font:inherit'; 103 var yes = document.createElement('button'); 104 yes.textContent = CFG.copy.cta; 105 yes.style.cssText = 'background:#111;border:0;color:#fff;padding:8px 16px;border-radius:8px;
105cursor:pointer;font:inherit;font-weight:600'; 106 row.appendChild(no); row.appendChild(yes); 107 box.appendChild(h); box.appendChild(row); 108 document.body.appendChild(box); 109 LS.setItem(K_COUNT, String(getNum(K_COUNT) + 1)); 110 111 function close() { if (box.parentNode) { box.parentNode.removeChild(box); } } 112 no.addEventListener('click', function () { 113 LS.setItem(K_DISMISS, String(now() + SUPPRESS_DAYS * 864e5)); 114 close(); 115 }); 116 yes.addEventListener('click', function () { 117 close(); 118 requestAndSubscribe(); 119 }); 120 } 121 122 // Small visible message â user feedback + a diagnostic channel on iOS (no console there). 123 function toast(msg) { 124 var t = document.createElement('div'); 125 t.textContent = msg; 126 t.style.cssText = 'position:fixed;left:12px;right:12px;bottom:12px;z-index:2147483001;' + 127 'background:#111;color:#fff;border-radius:10px;padding:12px 14px;text-align:center;' + 128 'font:13px/1.4 -apple-system,Segoe UI,Roboto,sans-serif;box-shadow:0 8px 30px rgba(0,0,0,.25)'; 129 document.body.appendChild(t); 130 setTimeout(function () { if (t.parentNode) { t.parentNode.removeChild(t); } }, 8000); 131 } 132 133 function requestAndSubscribe() { 134 Notification.requestPermission().then(function (perm) { 135 if (perm !== 'granted') { 136 LS.setItem(K_DISMISS, String(now() + SUPPRESS_DAYS * 864e5)); 137 return; 138 } 139 // Register, then WAIT for the SW to be active (serviceWorker.ready) before 140 // subscribing â iOS rejects pushManager.subscribe() on a not-yet-active worker. 141 // Fallback: hosts with broken/blocked rewrites (the pretty /velvet-push-sw.js 142 // 404s) retry via the query-var route â same scope '/', served by index.php. 143 navigator.serviceWorker.register(CFG.swUrl, { scope: '/' }) 144 .catch(function () { 145 var qv = (CFG.swUrl.indexOf('://') > -1 ? CFG.swUrl.split('/').slice(0, 3).join('/') : '') + '/index.php?velvet_push=sw'; 146 return navigator.serviceWorker.register(qv, { scope: '/' }); 147 }) 148 .then(function () { return navigator.serviceWorker.ready; }) 149 .then(function (reg) { 150 return reg.pushManager.subscribe({ 151 userVisibleOnly: true, 152 applicationServerKey: urlBase64ToUint8Array(CFG.vapidKey) 153 }); 154 }).then(function (sub) { 155 var json = sub.toJSON(); 156 return fetch(CFG.subscribeUrl, { 157 method: 'POST', 158 headers: { 'Content-Type': 'application/json' }, 159 body: JSON.stringify({ 160 endpoint: sub.endpoint, 161 keys: json.keys, 162 ua_browser: detectBrowser(), 163 ua_os: detectOS(), 164 device_class: deviceClass(), 165 lang: (navigator.language || 'it').slice(0, 2), 166 page_url: location.href, 167 prompt_variant: 'soft_v1' 168 }) 169 }); 170 }).then(function (res) { 171 if (res && res.ok) { toast('Notifiche attivate â'); } 172 else if (res) { toast('Errore invio iscrizione (HTTP ' + res.status + ')'); } 173 }).catch(function (e) { 174 toast('Attivazione non riuscita: ' + (e ? (e.name || '') + ' â ' + (e.message || '') : 'errore')); 175 }); 176 }).catch(function (e) { 177 toast('Permesso non richiesto: ' + (e && e.message ? e.message : 'errore')); 178 }); 179 } 180 181 /* Engagement triggers (§3.2) â whichever comes first. */ 182 var t = CFG.triggers || {}; 183 var fired = false; 184 function fire() { if (!fired && !suppressed()) { fired = true; render(); } } 185 186 // 2nd session (returning visitor â highest grant rate). 187 if (getNum(K_SESSION) >= (t.session_n || 2)) { 188 setTimeout(fire, 3000); 189 } 190 // Dwell time. 191 if (t.dwell_s) { setTimeout(fire, t.dwell_s * 1000); } 192 // Scroll depth. 193 var pct = t.scroll_pct || 60; 194 window.addEventListener('scroll', function onScroll() { 195 var st = window.scrollY || document.documentElement.scrollTop; 196 var h = document.documentElement.scrollHeight - window.innerHeight; 197 if (h > 0 && (st / h) * 100 >= pct) { 198 window.removeEventListener('scroll', onScroll); 199 fire(); 200 } 201 }, { passive: true }); 202 203 /* iOS Safari tab: an "Add to Home Screen" hint (separate 2-max cap, 30-day snooze), 204 * shown on the same engagement triggers. Web push starts only once installed. */ 205 function velvetPushIosHint() { 206 var LS2 = window.localStorage, ios = CFG.ios, tg = CFG.triggers || {}; 207 var K_D = 'velvet_push_ios_dismiss_until', K_C = 'velvet_push_ios_count'; 208 function n(k) { var v = parseInt(LS2.getItem(k) || '0', 10); return isNaN(v) ? 0 : v; } 209 if (n(K_C) >= 2) { return; } 210 var until = parseInt(LS2.getItem(K_D) || '0', 10); 211 if (until && Date.now() < until) { return; } 212 var shown = false;
213 function fire() { 214 if (shown) { return; } 215 shown = true; 216 var box = document.createElement('div'); 217 box.setAttribute('role', 'dialog'); 218 box.style.cssText = 'position:fixed;z-index:2147483000;left:12px;right:12px;bottom:12px;' + 219 'background:#fff;color:#111;border-radius:14px;box-shadow:0 10px 40px rgba(0,0,0,.18);' + 220 'padding:16px 18px;font:14px/1.45 -apple-system,Segoe UI,Roboto,sans-serif'; 221 var h = document.createElement('div'); h.textContent = ios.headline; 222 h.style.cssText = 'font-weight:600;margin-bottom:6px'; 223 var b = document.createElement('div'); b.textContent = ios.body; 224 b.style.cssText = 'margin-bottom:12px;color:#333'; 225 var row = document.createElement('div'); row.style.cssText = 'display:flex;justify-content:flex-end'; 226 var ok = document.createElement('button'); ok.textContent = ios.dismiss; 227 ok.style.cssText = 'background:#111;border:0;color:#fff;padding:8px 16px;border-radius:8px;' + 228 'cursor:pointer;font:inherit;font-weight:600'; 229 row.appendChild(ok); 230 box.appendChild(h); box.appendChild(b); box.appendChild(row); 231 document.body.appendChild(box); 232 LS2.setItem(K_C, String(n(K_C) + 1)); 233 ok.addEventListener('click', function () { 234 LS2.setItem(K_D, String(Date.now() + 30 * 864e5)); 235 if (box.parentNode) { box.parentNode.removeChild(box); } 236 }); 237 } 238 if (tg.dwell_s) { setTimeout(fire, tg.dwell_s * 1000); } 239 var p = tg.scroll_pct || 60; 240 window.addEventListener('scroll', function onScroll() { 241 var st = window.scrollY || document.documentElement.scrollTop; 242 var hh = document.documentElement.scrollHeight - window.innerHeight; 243 if (hh > 0 && (st / hh) * 100 >= p) { window.removeEventListener('scroll', onScroll); fire(); } 244 }, { passive: true }); 245 } 246})();
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.