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 { 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.
|
||||
|
||||
@@ -22,8 +22,9 @@ export function NotificationCenter() {
|
||||
const [open, setOpen] = useState(false);
|
||||
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({
|
||||
enableToasts: true,
|
||||
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 { 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);
|
||||
type CafeNotification = {
|
||||
id: string;
|
||||
type: string;
|
||||
title: string;
|
||||
body?: string | null;
|
||||
referenceId?: string | null;
|
||||
tableNumber?: string | null;
|
||||
};
|
||||
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
|
||||
* 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"] });
|
||||
|
||||
Reference in New Issue
Block a user