diff --git a/api/notifications.php b/api/notifications.php
new file mode 100644
index 0000000..58409d7
--- /dev/null
+++ b/api/notifications.php
@@ -0,0 +1,127 @@
+ true,
+ 'settings' => $settings,
+ ]);
+ exit;
+ }
+
+ // 2. Instellingen opslaan
+ if ($action === 'save_settings') {
+ $input = json_decode(file_get_contents('php://input'), true) ?: $_POST;
+ $saved = update_user_notification_settings((int)$user['id'], $input);
+ $settings = get_user_notification_settings((int)$user['id']);
+
+ echo json_encode([
+ 'success' => $saved,
+ 'settings' => $settings,
+ 'message' => $saved ? 'Notificatievoorkeuren succesvol opgeslagen.' : 'Fout bij opslaan.',
+ ]);
+ exit;
+ }
+
+ // 3. Actuele agenda-afspraken van vandaag ophalen voor notificatie-evaluatie
+ if ($action === 'get_events') {
+ $userId = (int)$user['id'];
+ $settings = get_user_notification_settings($userId);
+
+ // Controleer of Outlook gekoppeld is
+ $stmtInt = $db->prepare("SELECT * FROM integrations WHERE user_id = ? AND provider = 'OUTLOOK' AND is_enabled = 1");
+ $stmtInt->execute([$userId]);
+ $outlookInt = $stmtInt->fetch();
+
+ $isLiveOutlook = !empty($outlookInt['access_token']) && empty($outlookInt['is_demo_mode']);
+ $events = [];
+
+ if ($isLiveOutlook) {
+ $events = microsoft_fetch_today_events($userId, $today);
+ }
+
+ // Als er geen live Outlook events zijn, haal ook taken met tijdblokken op uit Fociz dagplanning
+ $stmtTasks = $db->prepare("
+ SELECT t.id, t.title, t.time_block_start, t.time_block_end, t.source, t.external_url
+ FROM tasks t
+ LEFT JOIN daily_plans dp ON t.daily_plan_id = dp.id
+ WHERE t.user_id = ? AND (dp.plan_date = ? OR t.time_block_start IS NOT NULL) AND t.is_completed = 0
+ ORDER BY t.time_block_start ASC
+ ");
+ $stmtTasks->execute([$userId, $today]);
+ $tasks = $stmtTasks->fetchAll();
+
+ foreach ($tasks as $t) {
+ if (!empty($t['time_block_start'])) {
+ $events[] = [
+ 'id' => 'task_' . $t['id'],
+ 'subject' => $t['title'],
+ 'start' => $t['time_block_start'],
+ 'end' => $t['time_block_end'] ?: date('H:i', strtotime($t['time_block_start'] . ' +30 minutes')),
+ 'start_raw' => "{$today}T{$t['time_block_start']}:00",
+ 'location' => 'Fociz Dagplanning',
+ 'meeting_url' => $t['external_url'] ?? '/planner.php',
+ 'is_all_day' => false,
+ 'source' => 'FOCIZ_TASK',
+ ];
+ }
+ }
+
+ // Als er nog steeds geen afspraken zijn (en demo mode actief), lever sandbox items zodat er altijd getest kan worden
+ if (empty($events) && (!empty($outlookInt['is_demo_mode']) || !$isLiveOutlook)) {
+ $currentHour = (int)date('H');
+ $nextHour = str_pad((string)(($currentHour + 1) % 24), 2, '0', STR_PAD_LEFT);
+ $events = [
+ [
+ 'id' => 'sandbox_meeting_1',
+ 'subject' => 'Focus Sync & Dagstart',
+ 'start' => "{$nextHour}:00",
+ 'end' => "{$nextHour}:30",
+ 'start_raw' => "{$today}T{$nextHour}:00:00",
+ 'location' => 'Microsoft Teams',
+ 'meeting_url' => 'https://teams.microsoft.com',
+ 'is_all_day' => false,
+ 'source' => 'SANDBOX',
+ ]
+ ];
+ }
+
+ echo json_encode([
+ 'success' => true,
+ 'server_time' => date('H:i:s'),
+ 'server_datetime' => date('Y-m-d H:i:s'),
+ 'server_timestamp'=> time(),
+ 'settings' => $settings,
+ 'events' => $events,
+ 'count' => count($events),
+ ]);
+ exit;
+ }
+
+ echo json_encode(['error' => 'Ongeldige actie']);
+ exit;
+
+} catch (Throwable $e) {
+ http_response_code(500);
+ echo json_encode([
+ 'success' => false,
+ 'error' => $e->getMessage(),
+ ]);
+ exit;
+}
diff --git a/assets/js/notifications.js b/assets/js/notifications.js
new file mode 100644
index 0000000..d3b1f19
--- /dev/null
+++ b/assets/js/notifications.js
@@ -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 = '';
+
+ 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();
+ });
+
+})();
diff --git a/config/schema.sql b/config/schema.sql
index d3b0d94..6bbb337 100644
--- a/config/schema.sql
+++ b/config/schema.sql
@@ -12,6 +12,7 @@ CREATE TABLE IF NOT EXISTS `users` (
`backup_codes` TEXT NULL,
`remember_token` VARCHAR(64) NULL,
`remember_token_expires_at` DATETIME NULL,
+ `notification_settings` TEXT NULL,
`avatar_url` VARCHAR(255) NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
diff --git a/includes/auth.php b/includes/auth.php
index f12cf44..dd05667 100644
--- a/includes/auth.php
+++ b/includes/auth.php
@@ -80,6 +80,24 @@ function ensure_remember_token_columns(PDO $db): void {
}
}
+/**
+ * Garandeert dat de kolom voor notificatie-instellingen aanwezig is in MariaDB
+ */
+function ensure_notification_columns(PDO $db): void {
+ static $notifColumnsChecked = false;
+ if ($notifColumnsChecked) return;
+ $notifColumnsChecked = true;
+
+ try {
+ $result = $db->query("SHOW COLUMNS FROM `users` LIKE 'notification_settings'");
+ if (!$result || $result->rowCount() === 0) {
+ $db->exec("ALTER TABLE `users` ADD COLUMN `notification_settings` TEXT NULL AFTER `remember_token_expires_at`");
+ }
+ } catch (Throwable $e) {
+ // Stil negeren indien al aanwezig
+ }
+}
+
/**
* Genereert en bewaart een persistente 'Onthoud mij' token in MariaDB en plaatst een veilige cookie
*/
@@ -324,4 +342,61 @@ function verify_oauth_state(string $state): ?int {
return (int)$payload['uid'];
}
+/**
+ * Haalt de persoonlijke notificatie-instellingen van een gebruiker op
+ */
+function get_user_notification_settings(int $userId): array {
+ $db = get_db_connection();
+ ensure_notification_columns($db);
+
+ $defaults = [
+ 'enabled' => 1,
+ 'lead_time_minutes' => 5, // 0, 2, 5, 10, 15, 30
+ 'repeat_count' => 2, // 1, 2, 3, 5
+ 'repeat_interval_minutes' => 2, // 1, 2, 3, 5
+ 'sound_enabled' => 1, // 0 of 1
+ ];
+
+ try {
+ $stmt = $db->prepare("SELECT notification_settings FROM users WHERE id = ?");
+ $stmt->execute([$userId]);
+ $json = $stmt->fetchColumn();
+ if (!empty($json)) {
+ $saved = json_decode($json, true);
+ if (is_array($saved)) {
+ return array_merge($defaults, $saved);
+ }
+ }
+ } catch (Throwable $e) {
+ error_log("get_user_notification_settings error: " . $e->getMessage());
+ }
+
+ return $defaults;
+}
+
+/**
+ * Slaat de persoonlijke notificatie-instellingen van een gebruiker op
+ */
+function update_user_notification_settings(int $userId, array $settings): bool {
+ $db = get_db_connection();
+ ensure_notification_columns($db);
+
+ $current = get_user_notification_settings($userId);
+ $merged = [
+ 'enabled' => !empty($settings['enabled']) ? 1 : 0,
+ 'lead_time_minutes' => max(0, min(60, (int)($settings['lead_time_minutes'] ?? $current['lead_time_minutes']))),
+ 'repeat_count' => max(1, min(10, (int)($settings['repeat_count'] ?? $current['repeat_count']))),
+ 'repeat_interval_minutes' => max(1, min(30, (int)($settings['repeat_interval_minutes'] ?? $current['repeat_interval_minutes']))),
+ 'sound_enabled' => !empty($settings['sound_enabled']) ? 1 : 0,
+ ];
+
+ try {
+ $stmt = $db->prepare("UPDATE users SET notification_settings = ? WHERE id = ?");
+ return $stmt->execute([json_encode($merged), $userId]);
+ } catch (Throwable $e) {
+ error_log("update_user_notification_settings error: " . $e->getMessage());
+ return false;
+ }
+}
+
diff --git a/includes/footer.php b/includes/footer.php
index d2afcbb..65cb76f 100644
--- a/includes/footer.php
+++ b/includes/footer.php
@@ -13,6 +13,9 @@
+
+
+