/** * Fociz Browser Push Notificaties Engine * Monitort actuele agenda-afspraken en stuurt browser notificaties op basis van persoonlijk profiel. */ (function () { // 1. Globale state window.focizNotifSettings = { enabled: 1, lead_time_minutes: 5, repeat_count: 2, repeat_interval_minutes: 2, sound_enabled: 1 }; let cachedEvents = []; let isFetchingEvents = false; // 2. Service Worker Registratie if ('serviceWorker' in navigator) { navigator.serviceWorker.register('/sw.js', { scope: '/' }) .then(reg => { // Service worker geregistreerd }) .catch(err => { // Stille fallback indien niet op HTTPS of lokaal }); } // 3. Web Audio API Chime Synthesizer window.playNotificationChime = function () { try { const AudioContext = window.AudioContext || window.webkitAudioContext; if (!AudioContext) return; const ctx = new AudioContext(); const now = ctx.currentTime; // Noot 1: 587.33 Hz (D5) const osc1 = ctx.createOscillator(); const gain1 = ctx.createGain(); osc1.type = 'sine'; osc1.frequency.setValueAtTime(587.33, now); gain1.gain.setValueAtTime(0.2, now); gain1.gain.exponentialRampToValueAtTime(0.001, now + 0.35); osc1.connect(gain1); gain1.connect(ctx.destination); osc1.start(now); osc1.stop(now + 0.35); // Noot 2: 880 Hz (A5) const osc2 = ctx.createOscillator(); const gain2 = ctx.createGain(); osc2.type = 'sine'; osc2.frequency.setValueAtTime(880.0, now + 0.12); gain2.gain.setValueAtTime(0.25, now + 0.12); gain2.gain.exponentialRampToValueAtTime(0.001, now + 0.55); osc2.connect(gain2); gain2.connect(ctx.destination); osc2.start(now + 0.12); osc2.stop(now + 0.55); } catch (e) { console.warn('Audio chime kon niet afspelen:', e); } }; // 4. In-App Toast Banner function showInAppToast(title, body, meetingUrl) { let container = document.getElementById('focizToastContainer'); if (!container) { container = document.createElement('div'); container.id = 'focizToastContainer'; container.className = 'fixed bottom-5 right-5 z-[9999] flex flex-col space-y-3 max-w-sm pointer-events-none'; document.body.appendChild(container); } const toast = document.createElement('div'); toast.className = 'pointer-events-auto bg-slate-900/95 border border-blue-500/40 text-white p-4 rounded-2xl shadow-2xl backdrop-blur-md flex items-start space-x-3 transform translate-y-4 opacity-0 transition duration-300 ease-out'; const iconDiv = document.createElement('div'); iconDiv.className = 'w-9 h-9 rounded-xl bg-blue-500/20 text-blue-400 flex items-center justify-center flex-shrink-0 mt-0.5 border border-blue-500/30'; iconDiv.innerHTML = ''; const contentDiv = document.createElement('div'); contentDiv.className = 'flex-1 min-w-0'; const titleEl = document.createElement('div'); titleEl.className = 'text-xs font-bold text-white truncate'; titleEl.textContent = title; const bodyEl = document.createElement('div'); bodyEl.className = 'text-[11px] text-slate-300 mt-0.5 leading-snug'; bodyEl.textContent = body; contentDiv.appendChild(titleEl); contentDiv.appendChild(bodyEl); if (meetingUrl) { const btn = document.createElement('a'); btn.href = meetingUrl; btn.target = '_blank'; btn.className = 'inline-flex items-center space-x-1 mt-2 px-2.5 py-1 rounded-lg bg-blue-600 hover:bg-blue-500 text-white text-[10px] font-bold transition'; btn.innerHTML = 'Direct Deelnemen →'; contentDiv.appendChild(btn); } const closeBtn = document.createElement('button'); closeBtn.className = 'text-slate-500 hover:text-white transition -mr-1 -mt-1 p-1'; closeBtn.innerHTML = ''; closeBtn.onclick = () => { toast.classList.add('opacity-0', 'translate-y-2'); setTimeout(() => toast.remove(), 300); }; toast.appendChild(iconDiv); toast.appendChild(contentDiv); toast.appendChild(closeBtn); container.appendChild(toast); // Animaties requestAnimationFrame(() => { toast.classList.remove('opacity-0', 'translate-y-4'); }); // Automatisch verdwijnen na 10 seconden setTimeout(() => { if (toast.parentElement) { toast.classList.add('opacity-0', 'translate-y-2'); setTimeout(() => toast.remove(), 300); } }, 10000); } // 5. Browser Notificatie Verzenden window.sendBrowserNotification = async function (title, body, meetingUrl, tag) { if (!window.focizNotifSettings.enabled) return; // Geluidssignaal if (window.focizNotifSettings.sound_enabled) { playNotificationChime(); } // In-app toast banner showInAppToast(title, body, meetingUrl); // HTML5 Browser Push Notificatie if ('Notification' in window && Notification.permission === 'granted') { try { if ('serviceWorker' in navigator && navigator.serviceWorker.controller) { const reg = await navigator.serviceWorker.ready; if (reg && reg.showNotification) { reg.showNotification(title, { body: body, icon: '/favicon.ico', badge: '/favicon.ico', tag: tag || 'fociz-' + Date.now(), data: { url: meetingUrl || '/planner.php' }, requireInteraction: false }); return; } } } catch (e) { // Fallback } try { const n = new Notification(title, { body: body, icon: '/favicon.ico', tag: tag || 'fociz-' + Date.now() }); n.onclick = function () { window.focus(); if (meetingUrl) { window.open(meetingUrl, '_blank'); } n.close(); }; } catch (err) { console.warn('Desktop notificatie kon niet worden getoond:', err); } } }; // 6. Evaluatie van agenda-afspraken function evaluateEvents() { if (!window.focizNotifSettings.enabled) return; if (!cachedEvents || cachedEvents.length === 0) return; const now = Date.now(); const leadMs = (parseInt(window.focizNotifSettings.lead_time_minutes) || 5) * 60 * 1000; const repeatCount = Math.max(1, parseInt(window.focizNotifSettings.repeat_count) || 2); const intervalMs = (parseInt(window.focizNotifSettings.repeat_interval_minutes) || 2) * 60 * 1000; cachedEvents.forEach(evt => { if (!evt.start_raw && !evt.start) return; // Bepaal starttijd in milliseconds let startMs = 0; if (evt.start_raw) { startMs = new Date(evt.start_raw).getTime(); } else if (evt.start) { const parts = evt.start.split(':'); const today = new Date(); today.setHours(parseInt(parts[0], 10), parseInt(parts[1], 10), 0, 0); startMs = today.getTime(); } if (!startMs || isNaN(startMs)) return; // Check herhalingen for (let i = 0; i < repeatCount; i++) { // Eerste notificatie: start - leadMs // Volgende notificaties: T_0 + i * intervalMs (tot maximaal starttijd) let triggerMs = startMs - leadMs + (i * intervalMs); if (triggerMs > startMs) { triggerMs = startMs; } // Venster: notificatie is nu actueel (tussen triggerMs en 4 minuten na de start) const isDue = (now >= triggerMs) && (now <= startMs + (4 * 60 * 1000)); if (isDue) { const storageKey = `fociz_notif_${evt.id}_rep_${i}`; const alreadySent = localStorage.getItem(storageKey); if (!alreadySent) { localStorage.setItem(storageKey, String(now)); const minutesLeft = Math.max(0, Math.round((startMs - now) / 60000)); let notifTitle = ''; let notifBody = ''; if (now >= startMs) { notifTitle = `🚀 Nu gestart: ${evt.subject}`; notifBody = `Afspraak is begonnen (${evt.start}) • ${evt.location || 'Teams'}`; } else if (minutesLeft === 0) { notifTitle = `âš¡ Begint zo: ${evt.subject}`; notifBody = `Start om ${evt.start} • ${evt.location || 'Teams'}`; } else { notifTitle = `📅 Over ${minutesLeft} min: ${evt.subject}`; notifBody = `Aanvang ${evt.start} - ${evt.end} • ${evt.location || 'Teams'}`; } sendBrowserNotification(notifTitle, notifBody, evt.meeting_url, `fociz-event-${evt.id}-${i}`); } } } }); } // 7. Data ophalen van backend async function refreshEvents() { if (isFetchingEvents) return; isFetchingEvents = true; try { const res = await fetch('/api/notifications.php?action=get_events', { cache: 'no-store' }); if (res.ok) { const data = await res.json(); if (data && data.success) { cachedEvents = data.events || []; if (data.settings) { window.focizNotifSettings = data.settings; updateDotStatus(); } evaluateEvents(); } } } catch (e) { // Netwerkfout stil opvangen } finally { isFetchingEvents = false; } } // 8. Dot indicator bijwerken op de bel in de header function updateDotStatus() { const dot = document.getElementById('focizNotificationDot'); if (!dot) return; if ('Notification' in window && Notification.permission === 'granted' && window.focizNotifSettings.enabled) { dot.classList.remove('hidden'); } else { dot.classList.add('hidden'); } } // 9. Modal helpers window.openNotificationModal = function () { const modal = document.getElementById('focizNotificationModal'); if (!modal) return; // Vul actuele waarden in formulier const s = window.focizNotifSettings; const enabledCheckbox = document.getElementById('notifEnabledInput'); const leadTimeSelect = document.getElementById('notifLeadTimeSelect'); const repeatCountSelect = document.getElementById('notifRepeatCountSelect'); const intervalSelect = document.getElementById('notifIntervalSelect'); const soundCheckbox = document.getElementById('notifSoundInput'); if (enabledCheckbox) enabledCheckbox.checked = !!parseInt(s.enabled); if (leadTimeSelect) leadTimeSelect.value = String(s.lead_time_minutes); if (repeatCountSelect) repeatCountSelect.value = String(s.repeat_count); if (intervalSelect) intervalSelect.value = String(s.repeat_interval_minutes); if (soundCheckbox) soundCheckbox.checked = !!parseInt(s.sound_enabled); updatePermissionBanner(); modal.classList.remove('hidden'); modal.classList.add('flex'); }; window.closeNotificationModal = function () { const modal = document.getElementById('focizNotificationModal'); if (!modal) return; modal.classList.add('hidden'); modal.classList.remove('flex'); }; function updatePermissionBanner() { const grantedDiv = document.getElementById('notifPermGranted'); const requestDiv = document.getElementById('notifPermRequest'); const deniedDiv = document.getElementById('notifPermDenied'); if (!('Notification' in window)) { if (grantedDiv) grantedDiv.classList.add('hidden'); if (requestDiv) requestDiv.classList.add('hidden'); if (deniedDiv) deniedDiv.classList.remove('hidden'); return; } const perm = Notification.permission; if (perm === 'granted') { if (grantedDiv) grantedDiv.classList.remove('hidden'); if (requestDiv) requestDiv.classList.add('hidden'); if (deniedDiv) deniedDiv.classList.add('hidden'); } else if (perm === 'denied') { if (grantedDiv) grantedDiv.classList.add('hidden'); if (requestDiv) requestDiv.classList.add('hidden'); if (deniedDiv) deniedDiv.classList.remove('hidden'); } else { if (grantedDiv) grantedDiv.classList.add('hidden'); if (requestDiv) requestDiv.classList.remove('hidden'); if (deniedDiv) deniedDiv.classList.add('hidden'); } } window.focizRequestPermission = async function () { if (!('Notification' in window)) { alert('Uw browser ondersteunt helaas geen HTML5 Web Notificaties.'); return; } try { const res = await Notification.requestPermission(); updatePermissionBanner(); updateDotStatus(); if (res === 'granted') { sendBrowserNotification('✅ Notificaties Ingeschakeld!', 'Fociz stuurt u nu pushberichten bij actuele afspraken.', null, 'fociz-test-perm'); } } catch (e) { console.error(e); } }; window.focizTestNotification = function () { sendBrowserNotification('🔔 Test Notificatie Fociz', 'Dit is hoe uw agenda-herinnering eruit ziet. Het geluid en de melding werken perfect!', '/planner.php', 'fociz-test-manual'); }; window.saveNotificationPreferences = async function (e) { if (e) e.preventDefault(); const btn = document.getElementById('saveNotifBtn'); if (btn) btn.disabled = true; const payload = { enabled: document.getElementById('notifEnabledInput').checked ? 1 : 0, lead_time_minutes: parseInt(document.getElementById('notifLeadTimeSelect').value, 10), repeat_count: parseInt(document.getElementById('notifRepeatCountSelect').value, 10), repeat_interval_minutes: parseInt(document.getElementById('notifIntervalSelect').value, 10), sound_enabled: document.getElementById('notifSoundInput').checked ? 1 : 0 }; try { const res = await fetch('/api/notifications.php?action=save_settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); if (res.ok) { const data = await res.json(); if (data && data.success) { window.focizNotifSettings = data.settings; updateDotStatus(); const statusMsg = document.getElementById('notifSaveStatus'); if (statusMsg) { statusMsg.textContent = 'Voorkeuren succesvol opgeslagen!'; statusMsg.classList.remove('hidden'); setTimeout(() => statusMsg.classList.add('hidden'), 3000); } setTimeout(() => closeNotificationModal(), 1000); } } } catch (err) { alert('Fout bij opslaan van voorkeuren: ' + err.message); } finally { if (btn) btn.disabled = false; } }; // 10. Initialisatie document.addEventListener('DOMContentLoaded', () => { refreshEvents(); // Polling interval elke 60 seconden setInterval(refreshEvents, 60000); // Evaluatie interval elke 15 seconden setInterval(evaluateEvents, 15000); updateDotStatus(); }); })();