feat(notifications): add browser push notifications for calendar events with personal profile settings
This commit is contained in:
@@ -0,0 +1,417 @@
|
||||
/**
|
||||
* 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 = '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="18" rx="2" ry="2" stroke-width="2"/><line x1="16" y1="2" x2="16" y2="6" stroke-width="2"/><line x1="8" y1="2" x2="8" y2="6" stroke-width="2"/><line x1="3" y1="10" x2="21" y2="10" stroke-width="2"/></svg>';
|
||||
|
||||
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 = '<span>Direct Deelnemen</span> →';
|
||||
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 = '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>';
|
||||
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();
|
||||
});
|
||||
|
||||
})();
|
||||
Reference in New Issue
Block a user