From 149a4d88cdb15573417b6075dcc4bc2fccf012af Mon Sep 17 00:00:00 2001 From: "soroush.asadi" Date: Sun, 21 Jun 2026 05:08:02 +0330 Subject: [PATCH] feat(dashboard): configurable notification sound, desktop popups & tab unread badge Per-device notification preferences (localStorage) drive three new alert channels in the dashboard shell, all fed by the existing SignalR NotificationReceived events: - Sound: 6 selectable procedural Web Audio chimes + volume, no asset files. - Desktop/Windows popups via the Notification API, fired only when the tab is backgrounded (in-app toast covers the focused case). - Unread count on the browser tab: (N) title prefix + numbered favicon badge. useOrderAlerts is now the single orchestrator (sound + toast + desktop), each gated by prefs; topbar feed enableToasts disabled to avoid double toasts. Co-Authored-By: Claude Opus 4.8 --- .../src/app/[locale]/(dashboard)/layout.tsx | 4 +- .../notifications/notification-center.tsx | 3 +- .../notifications/notification-prefs.store.ts | 63 +++++++ web/dashboard/src/lib/notifications/sounds.ts | 90 ++++++++++ .../use-desktop-notifications.ts | 78 +++++++++ .../src/lib/notifications/use-tab-badge.ts | 157 ++++++++++++++++++ .../src/lib/realtime/use-order-alerts.ts | 90 +++++----- 7 files changed, 440 insertions(+), 45 deletions(-) create mode 100644 web/dashboard/src/lib/notifications/notification-prefs.store.ts create mode 100644 web/dashboard/src/lib/notifications/sounds.ts create mode 100644 web/dashboard/src/lib/notifications/use-desktop-notifications.ts create mode 100644 web/dashboard/src/lib/notifications/use-tab-badge.ts diff --git a/web/dashboard/src/app/[locale]/(dashboard)/layout.tsx b/web/dashboard/src/app/[locale]/(dashboard)/layout.tsx index c232527..2070d34 100644 --- a/web/dashboard/src/app/[locale]/(dashboard)/layout.tsx +++ b/web/dashboard/src/app/[locale]/(dashboard)/layout.tsx @@ -10,6 +10,7 @@ import { CafeThemeProvider } from "@/components/theme/cafe-theme-provider"; import { useAuthStore } from "@/lib/stores/auth.store"; import { useOfflineSync } from "@/lib/offline/use-offline-sync"; import { useOrderAlerts } from "@/lib/realtime/use-order-alerts"; +import { useTabBadge } from "@/lib/notifications/use-tab-badge"; export default function DashboardLayout({ children, @@ -21,7 +22,8 @@ export default function DashboardLayout({ const user = useAuthStore((s) => s.user); const hasHydrated = useAuthStore((s) => s._hasHydrated); useOfflineSync(); // register online/offline listeners + load queue count - useOrderAlerts(); // global sound+toast alert for guest QR-menu orders, any screen + useOrderAlerts(); // global sound + toast + desktop popup for café notifications + useTabBadge(); // unread count on the browser tab title + favicon useEffect(() => { // Wait for Zustand to finish reading localStorage before deciding to redirect. diff --git a/web/dashboard/src/components/notifications/notification-center.tsx b/web/dashboard/src/components/notifications/notification-center.tsx index 88db03c..2f65f81 100644 --- a/web/dashboard/src/components/notifications/notification-center.tsx +++ b/web/dashboard/src/components/notifications/notification-center.tsx @@ -22,8 +22,9 @@ export function NotificationCenter() { const [open, setOpen] = useState(false); const [selected, setSelected] = useState(null); + // Toasts/sounds/desktop popups are owned by useOrderAlerts (mounted once in the + // dashboard shell) so they fire exactly once; here we only need the bell feed. const { items, unreadCount, openNotification, markAllRead } = useNotificationsFeed({ - enableToasts: true, limit: 20, }); diff --git a/web/dashboard/src/lib/notifications/notification-prefs.store.ts b/web/dashboard/src/lib/notifications/notification-prefs.store.ts new file mode 100644 index 0000000..9a75c4f --- /dev/null +++ b/web/dashboard/src/lib/notifications/notification-prefs.store.ts @@ -0,0 +1,63 @@ +import { create } from "zustand"; +import { persist } from "zustand/middleware"; +import { DEFAULT_SOUND_ID, type SoundId } from "@/lib/notifications/sounds"; + +/** + * Per-device notification preferences. Stored in localStorage (NOT server-side): + * browser notification permission, sound choice, and volume are all bound to the + * specific device/browser, so they should never be shared across a café's devices. + */ +export interface NotifPrefs { + /** Play a sound when a new notification arrives. */ + soundEnabled: boolean; + soundId: SoundId; + /** 0..1 */ + volume: number; + /** Show a native OS/Windows popup when the dashboard tab is in the background. */ + desktopEnabled: boolean; + /** Show the unread count on the browser tab (title + favicon badge). */ + tabBadgeEnabled: boolean; + /** Show an in-app toast when a new notification arrives. */ + toastEnabled: boolean; +} + +interface NotifPrefsState extends NotifPrefs { + /** True once Zustand has finished rehydrating from localStorage. */ + _hasHydrated: boolean; + setPrefs: (partial: Partial) => void; + _setHasHydrated: (v: boolean) => void; +} + +export const DEFAULT_NOTIF_PREFS: NotifPrefs = { + soundEnabled: true, + soundId: DEFAULT_SOUND_ID, + volume: 0.7, + desktopEnabled: false, // opt-in: requires an explicit permission grant + tabBadgeEnabled: true, + toastEnabled: true, +}; + +export const useNotifPrefs = create()( + persist( + (set) => ({ + ...DEFAULT_NOTIF_PREFS, + _hasHydrated: false, + setPrefs: (partial) => set(partial), + _setHasHydrated: (v) => set({ _hasHydrated: v }), + }), + { + name: "meezi_notif_prefs", + partialize: (s): NotifPrefs => ({ + soundEnabled: s.soundEnabled, + soundId: s.soundId, + volume: s.volume, + desktopEnabled: s.desktopEnabled, + tabBadgeEnabled: s.tabBadgeEnabled, + toastEnabled: s.toastEnabled, + }), + onRehydrateStorage: () => (state) => { + state?._setHasHydrated(true); + }, + } + ) +); diff --git a/web/dashboard/src/lib/notifications/sounds.ts b/web/dashboard/src/lib/notifications/sounds.ts new file mode 100644 index 0000000..14833f5 --- /dev/null +++ b/web/dashboard/src/lib/notifications/sounds.ts @@ -0,0 +1,90 @@ +// Selectable notification sounds, generated procedurally with the Web Audio API +// (no asset files — same approach as the original guest-order chime, keeps the +// dashboard fully offline-capable). Each preset is a small list of tones. + +export type SoundId = "classic" | "ding" | "bell" | "chime" | "marimba" | "alert"; + +export const DEFAULT_SOUND_ID: SoundId = "classic"; + +/** Order shown in the settings sound picker. `labelKey` resolves under `notifPrefs`. */ +export const SOUND_PRESETS: { id: SoundId; labelKey: string }[] = [ + { id: "classic", labelKey: "soundClassic" }, + { id: "ding", labelKey: "soundDing" }, + { id: "bell", labelKey: "soundBell" }, + { id: "chime", labelKey: "soundChime" }, + { id: "marimba", labelKey: "soundMarimba" }, + { id: "alert", labelKey: "soundAlert" }, +]; + +type Tone = { + freq: number; + /** Start offset in seconds. */ + at: number; + /** Duration in seconds. */ + dur: number; + type?: OscillatorType; + /** Peak gain before the volume multiplier (0..1). */ + peak?: number; +}; + +const RECIPES: Record = { + classic: [ + { freq: 880, at: 0, dur: 0.25 }, + { freq: 1175, at: 0.18, dur: 0.27 }, + ], + ding: [{ freq: 1318, at: 0, dur: 0.4, peak: 0.3 }], + bell: [ + { freq: 1568, at: 0, dur: 0.6, peak: 0.28 }, + { freq: 2349, at: 0, dur: 0.4, peak: 0.12 }, + ], + chime: [ + { freq: 783, at: 0, dur: 0.3 }, + { freq: 987, at: 0.12, dur: 0.3 }, + { freq: 1318, at: 0.24, dur: 0.4 }, + ], + marimba: [ + { freq: 659, at: 0, dur: 0.18, type: "triangle", peak: 0.3 }, + { freq: 1318, at: 0, dur: 0.12, type: "sine", peak: 0.1 }, + ], + alert: [ + { freq: 988, at: 0, dur: 0.18, type: "square", peak: 0.18 }, + { freq: 988, at: 0.22, dur: 0.18, type: "square", peak: 0.18 }, + ], +}; + +/** + * Play a notification sound. `volume` is 0..1. Silently no-ops if the browser + * blocks the audio context (private mode, no user gesture yet, etc.). + */ +export function playSound(id: SoundId, volume = 0.7): void { + if (typeof window === "undefined") return; + const clamped = Math.max(0, Math.min(1, volume)); + if (clamped <= 0) return; + try { + const Ctx = + window.AudioContext || + (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext; + if (!Ctx) return; + const ctx = new Ctx(); + const tones = RECIPES[id] ?? RECIPES.classic; + let maxEnd = 0; + for (const { freq, at, dur, type = "sine", peak = 0.25 } of tones) { + const o = ctx.createOscillator(); + const g = ctx.createGain(); + o.connect(g); + g.connect(ctx.destination); + o.type = type; + o.frequency.value = freq; + const t0 = ctx.currentTime + at; + g.gain.setValueAtTime(0.0001, t0); + g.gain.exponentialRampToValueAtTime(Math.max(0.0001, peak * clamped), t0 + 0.02); + g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur); + o.start(t0); + o.stop(t0 + dur + 0.02); + maxEnd = Math.max(maxEnd, at + dur); + } + setTimeout(() => void ctx.close().catch(() => {}), (maxEnd + 0.3) * 1000); + } catch { + // audio blocked — callers fall back to toast / desktop notification + } +} diff --git a/web/dashboard/src/lib/notifications/use-desktop-notifications.ts b/web/dashboard/src/lib/notifications/use-desktop-notifications.ts new file mode 100644 index 0000000..6bd610b --- /dev/null +++ b/web/dashboard/src/lib/notifications/use-desktop-notifications.ts @@ -0,0 +1,78 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { useNotifPrefs } from "@/lib/notifications/notification-prefs.store"; + +export type PermissionState = NotificationPermission | "unsupported"; + +export function notificationsSupported(): boolean { + return typeof window !== "undefined" && "Notification" in window; +} + +export function getPermission(): PermissionState { + if (!notificationsSupported()) return "unsupported"; + return Notification.permission; +} + +export async function requestNotificationPermission(): Promise { + if (!notificationsSupported()) return "unsupported"; + try { + return await Notification.requestPermission(); + } catch { + return Notification.permission; + } +} + +/** + * Show a native OS/Windows desktop notification. Gated by the user's prefs and the + * browser permission, and — by design — only fires when the dashboard tab is NOT + * the focused/visible one. When the user is looking at the dashboard, the in-app + * toast + sound already covers it; the OS popup is for when they're in another app. + */ +export function showDesktopNotification(opts: { + title: string; + body?: string; + /** Collapses repeat popups for the same subject (e.g. the order id). */ + tag?: string; +}): void { + if (!notificationsSupported()) return; + if (!useNotifPrefs.getState().desktopEnabled) return; + if (Notification.permission !== "granted") return; + if (typeof document !== "undefined" && document.visibilityState === "visible") return; + try { + const n = new Notification(opts.title, { + body: opts.body, + tag: opts.tag, + icon: "/icons/icon-192.png", + badge: "/icons/icon-192.png", + lang: "fa", + }); + n.onclick = () => { + try { + window.focus(); + } catch { + // ignore + } + n.close(); + }; + } catch { + // notification construction can throw on some platforms — non-fatal + } +} + +/** Reactive permission state + request action, for the settings UI. */ +export function useNotificationPermission() { + const [permission, setPermission] = useState("unsupported"); + + useEffect(() => { + setPermission(getPermission()); + }, []); + + const request = useCallback(async () => { + const p = await requestNotificationPermission(); + setPermission(p); + return p; + }, []); + + return { permission, request, supported: notificationsSupported() }; +} diff --git a/web/dashboard/src/lib/notifications/use-tab-badge.ts b/web/dashboard/src/lib/notifications/use-tab-badge.ts new file mode 100644 index 0000000..0bdaae6 --- /dev/null +++ b/web/dashboard/src/lib/notifications/use-tab-badge.ts @@ -0,0 +1,157 @@ +"use client"; + +import { useEffect } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { fetchNotifications } from "@/lib/api/notifications"; +import { useAuthStore } from "@/lib/stores/auth.store"; +import { useNotifPrefs } from "@/lib/notifications/notification-prefs.store"; + +const ICON_REL = "icon"; +const PREFIX_RE = /^\(\d+\+?\)\s*/; + +function stripCount(title: string): string { + return title.replace(PREFIX_RE, ""); +} + +function ensureFaviconLink(): HTMLLinkElement | null { + if (typeof document === "undefined") return null; + let link = document.querySelector('link[rel~="icon"]'); + if (!link) { + link = document.createElement("link"); + link.rel = ICON_REL; + document.head.appendChild(link); + } + return link; +} + +/** Draw the base icon with a red count badge onto a 64px canvas; returns a data URL. */ +function buildBadgedFavicon(baseSrc: string, count: number): Promise { + return new Promise((resolve, reject) => { + const size = 64; + const canvas = document.createElement("canvas"); + canvas.width = size; + canvas.height = size; + const ctx = canvas.getContext("2d"); + if (!ctx) { + reject(new Error("no 2d context")); + return; + } + const draw = (drawBase?: () => void) => { + drawBase?.(); + // Badge bubble (top-end corner). + const r = 22; + const cx = size - r + 2; + const cy = r - 2; + ctx.beginPath(); + ctx.arc(cx, cy, r, 0, Math.PI * 2); + ctx.fillStyle = "#DC2626"; + ctx.fill(); + ctx.lineWidth = 4; + ctx.strokeStyle = "#ffffff"; + ctx.stroke(); + // Count text. + const label = count > 9 ? "9+" : String(count); + ctx.fillStyle = "#ffffff"; + ctx.font = "bold 30px Arial, sans-serif"; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText(label, cx, cy + 1); + resolve(canvas.toDataURL("image/png")); + }; + + const img = new Image(); + img.onload = () => draw(() => ctx.drawImage(img, 0, 0, size, size)); + img.onerror = () => + // Fall back to a Meezi-green rounded square if the base icon can't load. + draw(() => { + ctx.fillStyle = "#0F6E56"; + ctx.beginPath(); + const rr = 12; + ctx.moveTo(rr, 0); + ctx.arcTo(size, 0, size, size, rr); + ctx.arcTo(size, size, 0, size, rr); + ctx.arcTo(0, size, 0, 0, rr); + ctx.arcTo(0, 0, size, 0, rr); + ctx.fill(); + }); + img.src = baseSrc; + }); +} + +/** + * Reflects the unread-notification count on the browser tab: a `(N)` prefix on the + * title plus a numbered badge on the favicon. Driven by the shared notifications + * query (no extra SignalR connection — the topbar feed keeps that cache fresh). + * Mount once in the dashboard shell. + */ +export function useTabBadge() { + const cafeId = useAuthStore((s) => s.user?.cafeId); + const tabBadgeEnabled = useNotifPrefs((s) => s.tabBadgeEnabled); + + // Same query key as useNotificationsFeed → shares the cache, no duplicate fetch. + const { data } = useQuery({ + queryKey: ["notifications", cafeId, false], + queryFn: () => fetchNotifications(cafeId!, false, 50), + enabled: !!cafeId, + refetchInterval: 60_000, + }); + + const unread = data?.unreadCount ?? 0; + const count = tabBadgeEnabled ? unread : 0; + + // --- Title prefix (survives Next.js route-driven changes via observer) --- + useEffect(() => { + if (typeof document === "undefined") return; + const titleEl = document.querySelector("title"); + + const apply = () => { + const base = stripCount(document.title); + const next = count > 0 ? `(${count > 9 ? "9+" : count}) ${base}` : base; + if (document.title !== next) document.title = next; + }; + + apply(); + + // Re-apply when something else (router) rewrites the title. + let observer: MutationObserver | undefined; + if (titleEl) { + observer = new MutationObserver(() => apply()); + observer.observe(titleEl, { childList: true }); + } + + return () => { + observer?.disconnect(); + document.title = stripCount(document.title); + }; + }, [count]); + + // --- Favicon badge --- + useEffect(() => { + if (typeof document === "undefined") return; + const link = ensureFaviconLink(); + if (!link) return; + + // Remember the original href once so we can restore it. + const originalHref = link.dataset.originalHref ?? link.href ?? "/favicon.ico"; + if (!link.dataset.originalHref) link.dataset.originalHref = originalHref; + + let cancelled = false; + if (count > 0) { + void buildBadgedFavicon(originalHref || "/icons/icon-32.png", count) + .then((url) => { + if (!cancelled) link.href = url; + }) + .catch(() => { + // leave the favicon as-is on failure + }); + } else { + link.href = originalHref; + } + + return () => { + cancelled = true; + // Restore the plain favicon when the count changes or the shell unmounts. + link.href = originalHref; + }; + }, [count]); +} diff --git a/web/dashboard/src/lib/realtime/use-order-alerts.ts b/web/dashboard/src/lib/realtime/use-order-alerts.ts index 8b34b44..20107fb 100644 --- a/web/dashboard/src/lib/realtime/use-order-alerts.ts +++ b/web/dashboard/src/lib/realtime/use-order-alerts.ts @@ -6,6 +6,9 @@ import { useLocale } from "next-intl"; import { useQueryClient } from "@tanstack/react-query"; import { useAuthStore } from "@/lib/stores/auth.store"; import { notify } from "@/lib/notify"; +import { useNotifPrefs } from "@/lib/notifications/notification-prefs.store"; +import { playSound } from "@/lib/notifications/sounds"; +import { showDesktopNotification } from "@/lib/notifications/use-desktop-notifications"; type LiveOrder = { displayNumber?: number; @@ -13,39 +16,25 @@ type LiveOrder = { source?: string; }; -/** Short two-tone chime via Web Audio (no asset). Silently no-ops if blocked. */ -function playChime() { - try { - const Ctx = - window.AudioContext || - (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext; - if (!Ctx) return; - const ctx = new Ctx(); - const beep = (freq: number, at: number) => { - const o = ctx.createOscillator(); - const g = ctx.createGain(); - o.connect(g); - g.connect(ctx.destination); - o.type = "sine"; - o.frequency.value = freq; - g.gain.setValueAtTime(0.0001, ctx.currentTime + at); - g.gain.exponentialRampToValueAtTime(0.25, ctx.currentTime + at + 0.02); - g.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + at + 0.25); - o.start(ctx.currentTime + at); - o.stop(ctx.currentTime + at + 0.27); - }; - beep(880, 0); - beep(1175, 0.18); - setTimeout(() => void ctx.close().catch(() => {}), 800); - } catch { - // audio blocked by the browser — the toast still fires - } -} +type CafeNotification = { + id: string; + type: string; + title: string; + body?: string | null; + referenceId?: string | null; + tableNumber?: string | null; +}; /** - * Global alert for guest orders placed from the QR/digital menu. Mounted once in - * the dashboard shell so staff are notified on ANY screen (not just KDS): sound + - * toast + a refresh nudge to order lists. Staff-created POS orders are ignored. + * Single source of truth for real-time alerts in the dashboard shell. Mounted once + * (dashboard layout) so staff are alerted on ANY screen. For every persisted + * café notification (new guest order, order ready, waiter call, platform broadcast) + * it fires — each gated by the per-device preferences ([[notification-prefs.store]]): + * • a configurable sound, + * • an in-app toast, + * • a native OS/Windows popup when the tab is backgrounded. + * Preferences are read via getState() inside the handler so changing them never + * tears down and rebuilds the SignalR connection. */ export function useOrderAlerts() { const cafeId = useAuthStore((s) => s.user?.cafeId); @@ -66,20 +55,35 @@ export function useOrderAlerts() { let stopped = false; - connection.on("OrderCreated", (order: LiveOrder | null) => { - // Only alert for guest QR-menu orders, not staff POS orders. - if (order?.source && order.source !== "GuestQr") return; + const severityFor = (type: string) => { + if (type === "table_call_waiter") return notify.warning; + if (type === "guest_order_ready") return notify.success; + return notify.info; + }; - playChime(); - const table = order?.tableNumber; - const msg = - locale === "en" - ? `New order${table ? ` — table ${table}` : ""}` - : locale === "ar" - ? `طلب جديد${table ? ` — طاولة ${table}` : ""}` - : `سفارش جدید${table ? ` — میز ${table}` : ""}`; - notify.success(msg); + // Persisted notifications are the canonical alert trigger (guest order placed / + // ready, waiter call, admin broadcast). Staff-created POS orders do NOT create + // one, so they correctly stay silent. + connection.on("NotificationReceived", (n: CafeNotification | null) => { + if (!n) return; + const prefs = useNotifPrefs.getState(); + if (prefs.soundEnabled) playSound(prefs.soundId, prefs.volume); + if (prefs.toastEnabled) { + severityFor(n.type)(n.title, { description: n.body ?? undefined }); + } + showDesktopNotification({ + title: n.title, + body: n.body ?? undefined, + tag: n.referenceId ?? n.id, + }); + + void qc.invalidateQueries({ queryKey: ["notifications", cafeId] }); + }); + + // Real-time board refresh nudge (no alert here — guest orders are alerted via + // NotificationReceived above; this also covers silent staff POS orders). + connection.on("OrderCreated", (_order: LiveOrder | null) => { void qc.invalidateQueries({ queryKey: ["kds"] }); void qc.invalidateQueries({ queryKey: ["orders"] }); void qc.invalidateQueries({ queryKey: ["pos"] });