Files
Fociz/assets/js/notifications.js
T

423 lines
17 KiB
JavaScript

/**
* 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> &rarr;';
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.status === 401) {
console.warn('Notificatiesessie niet geauthenticeerd.');
return;
}
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();
});
})();