feat(dashboard): configurable notification sound, desktop popups & tab unread badge
CI/CD / CI · API (dotnet build + test) (push) Has been cancelled
CI/CD / CI · Admin API (dotnet build) (push) Has been cancelled
CI/CD / CI · Dashboard (tsc) (push) Has been cancelled
CI/CD / CI · Admin Web (tsc) (push) Has been cancelled
CI/CD / CI · Website (tsc) (push) Has been cancelled
CI/CD / CI · Koja (tsc) (push) Has been cancelled
CI/CD / Deploy · all services (push) Has been cancelled

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 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-21 05:08:02 +03:30
parent aebfa825cd
commit 149a4d88cd
7 changed files with 440 additions and 45 deletions
@@ -10,6 +10,7 @@ import { CafeThemeProvider } from "@/components/theme/cafe-theme-provider";
import { useAuthStore } from "@/lib/stores/auth.store"; import { useAuthStore } from "@/lib/stores/auth.store";
import { useOfflineSync } from "@/lib/offline/use-offline-sync"; import { useOfflineSync } from "@/lib/offline/use-offline-sync";
import { useOrderAlerts } from "@/lib/realtime/use-order-alerts"; import { useOrderAlerts } from "@/lib/realtime/use-order-alerts";
import { useTabBadge } from "@/lib/notifications/use-tab-badge";
export default function DashboardLayout({ export default function DashboardLayout({
children, children,
@@ -21,7 +22,8 @@ export default function DashboardLayout({
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
const hasHydrated = useAuthStore((s) => s._hasHydrated); const hasHydrated = useAuthStore((s) => s._hasHydrated);
useOfflineSync(); // register online/offline listeners + load queue count 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(() => { useEffect(() => {
// Wait for Zustand to finish reading localStorage before deciding to redirect. // Wait for Zustand to finish reading localStorage before deciding to redirect.
@@ -22,8 +22,9 @@ export function NotificationCenter() {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [selected, setSelected] = useState<CafeNotification | null>(null); const [selected, setSelected] = useState<CafeNotification | null>(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({ const { items, unreadCount, openNotification, markAllRead } = useNotificationsFeed({
enableToasts: true,
limit: 20, limit: 20,
}); });
@@ -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<NotifPrefs>) => 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<NotifPrefsState>()(
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);
},
}
)
);
@@ -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<SoundId, Tone[]> = {
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
}
}
@@ -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<PermissionState> {
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<PermissionState>("unsupported");
useEffect(() => {
setPermission(getPermission());
}, []);
const request = useCallback(async () => {
const p = await requestNotificationPermission();
setPermission(p);
return p;
}, []);
return { permission, request, supported: notificationsSupported() };
}
@@ -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<HTMLLinkElement>('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<string> {
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 <title> 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]);
}
@@ -6,6 +6,9 @@ import { useLocale } from "next-intl";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { useAuthStore } from "@/lib/stores/auth.store"; import { useAuthStore } from "@/lib/stores/auth.store";
import { notify } from "@/lib/notify"; 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 = { type LiveOrder = {
displayNumber?: number; displayNumber?: number;
@@ -13,39 +16,25 @@ type LiveOrder = {
source?: string; source?: string;
}; };
/** Short two-tone chime via Web Audio (no asset). Silently no-ops if blocked. */ type CafeNotification = {
function playChime() { id: string;
try { type: string;
const Ctx = title: string;
window.AudioContext || body?: string | null;
(window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext; referenceId?: string | null;
if (!Ctx) return; tableNumber?: string | null;
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
}
}
/** /**
* Global alert for guest orders placed from the QR/digital menu. Mounted once in * Single source of truth for real-time alerts in the dashboard shell. Mounted once
* the dashboard shell so staff are notified on ANY screen (not just KDS): sound + * (dashboard layout) so staff are alerted on ANY screen. For every persisted
* toast + a refresh nudge to order lists. Staff-created POS orders are ignored. * 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() { export function useOrderAlerts() {
const cafeId = useAuthStore((s) => s.user?.cafeId); const cafeId = useAuthStore((s) => s.user?.cafeId);
@@ -66,20 +55,35 @@ export function useOrderAlerts() {
let stopped = false; let stopped = false;
connection.on("OrderCreated", (order: LiveOrder | null) => { const severityFor = (type: string) => {
// Only alert for guest QR-menu orders, not staff POS orders. if (type === "table_call_waiter") return notify.warning;
if (order?.source && order.source !== "GuestQr") return; if (type === "guest_order_ready") return notify.success;
return notify.info;
};
playChime(); // Persisted notifications are the canonical alert trigger (guest order placed /
const table = order?.tableNumber; // ready, waiter call, admin broadcast). Staff-created POS orders do NOT create
const msg = // one, so they correctly stay silent.
locale === "en" connection.on("NotificationReceived", (n: CafeNotification | null) => {
? `New order${table ? ` — table ${table}` : ""}` if (!n) return;
: locale === "ar" const prefs = useNotifPrefs.getState();
? `طلب جديد${table ? ` — طاولة ${table}` : ""}`
: `سفارش جدید${table ? ` — میز ${table}` : ""}`;
notify.success(msg);
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: ["kds"] });
void qc.invalidateQueries({ queryKey: ["orders"] }); void qc.invalidateQueries({ queryKey: ["orders"] });
void qc.invalidateQueries({ queryKey: ["pos"] }); void qc.invalidateQueries({ queryKey: ["pos"] });