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
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:
@@ -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"] });
|
||||||
|
|||||||
Reference in New Issue
Block a user