nordabiz/static/js/push-client.js
Maciej Pienczyn 31c8272e31
Some checks are pending
NordaBiz Tests / Unit & Integration Tests (push) Waiting to run
NordaBiz Tests / E2E Tests (Playwright) (push) Blocked by required conditions
NordaBiz Tests / Smoke Tests (Production) (push) Blocked by required conditions
NordaBiz Tests / Send Failure Notification (push) Blocked by required conditions
feat(push): nowa ikona (smartfon z falami) + toast per urządzenie
Odróżnia push od portalowych powiadomień w navbarze. Po pierwszym
udanym włączeniu pokazuje komunikat że subskrypcja działa tylko na
tym urządzeniu i zachęca do kliknięcia również na innych telefonach
i komputerach. Tooltipy zaktualizowane pod kątem "na tym urządzeniu".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 17:25:09 +02:00

218 lines
8.7 KiB
JavaScript

// push-client.js — Web Push subscription flow dla Norda Biznes
//
// Rejestruje subskrypcję pod /push/subscribe, obsługuje dzwonek w navbarze,
// wykrywa iOS PWA, obsługuje diagnostykę ?pushdiag=1, pending-url dla iOS.
(function() {
'use strict';
function log(msg, data) {
try { console.log('[push] ' + msg, data !== undefined ? data : ''); } catch (e) {}
}
function detectIOS() {
return /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
}
function isStandalone() {
return (window.matchMedia && window.matchMedia('(display-mode: standalone)').matches) ||
window.navigator.standalone === true;
}
function supportsPush() {
return ('serviceWorker' in navigator) && ('PushManager' in window) && ('Notification' in window);
}
function urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - base64String.length % 4) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const rawData = atob(base64);
const output = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; i++) output[i] = rawData.charCodeAt(i);
return output;
}
function getBell() { return document.getElementById('pushBellBtn'); }
function setBellState(state) {
const bell = getBell();
if (!bell) return;
bell.dataset.state = state;
const waves = bell.querySelectorAll('.push-wave');
if (state === 'enabled') {
bell.title = 'Powiadomienia włączone na tym urządzeniu — kliknij, żeby wyłączyć';
bell.style.opacity = '1';
waves.forEach(function(w) { w.style.opacity = ''; });
} else if (state === 'disabled') {
bell.title = 'Kliknij, aby włączyć powiadomienia na tym urządzeniu';
bell.style.opacity = '0.55';
waves.forEach(function(w, i) { w.style.opacity = i === 0 ? '0.5' : '0.35'; });
} else if (state === 'blocked') {
bell.title = 'Powiadomienia zablokowane w przeglądarce. Zmień w ustawieniach strony.';
bell.style.opacity = '0.4';
waves.forEach(function(w) { w.style.opacity = '0.2'; });
} else if (state === 'unsupported') {
bell.title = 'Ta przeglądarka nie obsługuje powiadomień';
bell.style.opacity = '0.3';
}
}
function showToast(text, durationMs) {
try {
const old = document.getElementById('pushInfoToast');
if (old) old.remove();
const toast = document.createElement('div');
toast.id = 'pushInfoToast';
toast.textContent = text;
toast.style.cssText = 'position:fixed;bottom:24px;left:50%;transform:translateX(-50%);' +
'background:#233e6d;color:#fff;padding:14px 20px;border-radius:10px;' +
'box-shadow:0 6px 24px rgba(0,0,0,0.25);z-index:9999;max-width:420px;' +
'font-size:14px;line-height:1.45;text-align:center;font-family:inherit;';
document.body.appendChild(toast);
setTimeout(function() {
toast.style.transition = 'opacity 0.4s';
toast.style.opacity = '0';
setTimeout(function() { toast.remove(); }, 450);
}, durationMs || 7000);
} catch (e) { /* ignore */ }
}
async function getSubscription() {
const reg = await navigator.serviceWorker.ready;
return reg.pushManager.getSubscription();
}
async function enablePush() {
if (detectIOS() && !isStandalone()) {
alert('Na iPhone powiadomienia działają tylko po dodaniu strony do ekranu początkowego:\n\n1) Dotknij Udostępnij (strzałka na dole)\n2) Wybierz „Do ekranu początkowego"\n3) Uruchom aplikację z ikonki na pulpicie\n4) Zaloguj się i kliknij ponownie dzwoneczek');
return;
}
if (!supportsPush()) {
alert('Twoja przeglądarka nie obsługuje powiadomień.');
setBellState('unsupported');
return;
}
const perm = await Notification.requestPermission();
if (perm !== 'granted') {
setBellState(perm === 'denied' ? 'blocked' : 'disabled');
return;
}
try {
const keyResp = await fetch('/push/vapid-public-key', { credentials: 'include' });
if (!keyResp.ok) throw new Error('vapid-public-key HTTP ' + keyResp.status);
const { key } = await keyResp.json();
const reg = await navigator.serviceWorker.ready;
let sub = await reg.pushManager.getSubscription();
if (!sub) {
sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(key),
});
}
const subObj = sub.toJSON();
const resp = await fetch('/push/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
endpoint: subObj.endpoint,
keys: subObj.keys,
}),
credentials: 'include',
});
if (!resp.ok) throw new Error('subscribe HTTP ' + resp.status);
setBellState('enabled');
// Welcome push — test że wszystko działa
await fetch('/push/test', { method: 'POST', credentials: 'include' });
// Edukacyjny komunikat: subskrypcja jest per urządzenie/przeglądarka
showToast('✓ Powiadomienia włączone na tym urządzeniu. Na innych telefonach i komputerach kliknij ten sam przycisk, żeby włączyć je także tam.', 9000);
} catch (e) {
log('enable error', e);
alert('Nie udało się włączyć powiadomień: ' + (e.message || e));
setBellState('disabled');
}
}
async function disablePush() {
try {
const sub = await getSubscription();
if (sub) {
const endpoint = sub.endpoint;
await sub.unsubscribe();
await fetch('/push/unsubscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ endpoint }),
credentials: 'include',
});
}
setBellState('disabled');
} catch (e) {
log('disable error', e);
}
}
window.togglePush = async function(event) {
if (event) event.preventDefault();
const bell = getBell();
if (!bell) return;
const state = bell.dataset.state || 'disabled';
if (state === 'enabled') {
await disablePush();
} else {
await enablePush();
}
};
async function initPushState() {
const bell = getBell();
if (!bell) return;
if (!supportsPush()) {
// iOS Safari bez PWA — przeglądarka w ogóle bez PushManager.
// Dzwonek nadal klikalny, zobaczy komunikat o PWA.
if (detectIOS() && !isStandalone()) {
setBellState('disabled');
return;
}
setBellState('unsupported');
return;
}
if (Notification.permission === 'denied') {
setBellState('blocked');
return;
}
try {
const sub = await getSubscription();
setBellState(sub && Notification.permission === 'granted' ? 'enabled' : 'disabled');
} catch (e) {
setBellState('disabled');
}
}
async function checkPendingUrl() {
// iOS PWA: jeśli otworzono PWA przez klik w powiadomienie, SW mógł zapisać URL
if (!isStandalone()) return;
try {
const resp = await fetch('/push/pending-url', { credentials: 'include' });
if (!resp.ok) return;
const { url } = await resp.json();
if (url && url !== window.location.pathname + window.location.search) {
window.location.href = url;
}
} catch (e) { /* ignore */ }
}
document.addEventListener('DOMContentLoaded', function() {
initPushState();
checkPendingUrl();
if (/[?&]pushdiag=1/.test(window.location.search)) {
log('diagnostics', {
supportsPush: supportsPush(),
iOS: detectIOS(),
standalone: isStandalone(),
permission: (window.Notification && Notification.permission) || 'n/a',
userAgent: navigator.userAgent,
});
}
});
})();