From dcdb0d57475263940de20bb2bfdba51ee0c90cdc Mon Sep 17 00:00:00 2001 From: "soroush.asadi" Date: Wed, 3 Jun 2026 02:42:29 +0330 Subject: [PATCH] feat(realtime): global guest-order alert on the dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Guest orders from the QR/digital menu already notified via SignalR, but only screens that were open (KDS/POS/tables) reacted — and silently (a data refresh, no alert). So staff on any other screen never knew a menu order arrived. - Add a global useOrderAlerts() mounted in the dashboard shell: connects to /hubs/kds, joins the café group, and on a new GUEST order plays a chime + shows a toast (localized fa/en/ar) + nudges order/KDS/POS lists to refresh — on every screen. - Filter to guest QR-menu orders only (not staff POS orders): LiveOrderDto now carries Source, set in MapLiveOrder (+ the delivery/snappfood mappers). 86 API tests pass; dashboard tsc + build clean. --- src/Meezi.API/Models/Orders/OrderDtos.cs | 3 +- .../Delivery/DeliveryOrderProcessor.cs | 3 +- src/Meezi.API/Services/OrderService.cs | 3 +- .../Services/SnappfoodWebhookService.cs | 3 +- .../src/app/[locale]/(dashboard)/layout.tsx | 2 + .../src/lib/realtime/use-order-alerts.ts | 102 ++++++++++++++++++ 6 files changed, 112 insertions(+), 4 deletions(-) create mode 100644 web/dashboard/src/lib/realtime/use-order-alerts.ts diff --git a/src/Meezi.API/Models/Orders/OrderDtos.cs b/src/Meezi.API/Models/Orders/OrderDtos.cs index 379a470..5459b97 100644 --- a/src/Meezi.API/Models/Orders/OrderDtos.cs +++ b/src/Meezi.API/Models/Orders/OrderDtos.cs @@ -80,4 +80,5 @@ public record LiveOrderDto( OrderType OrderType, decimal Total, DateTime CreatedAt, - IReadOnlyList Items); + IReadOnlyList Items, + OrderSource Source); diff --git a/src/Meezi.API/Services/Delivery/DeliveryOrderProcessor.cs b/src/Meezi.API/Services/Delivery/DeliveryOrderProcessor.cs index a4f0228..edd87d6 100644 --- a/src/Meezi.API/Services/Delivery/DeliveryOrderProcessor.cs +++ b/src/Meezi.API/Services/Delivery/DeliveryOrderProcessor.cs @@ -345,5 +345,6 @@ public class DeliveryOrderProcessor : IDeliveryOrderProcessor i.UnitPrice, i.Notes, i.IsVoided, - i.VoidedAt)).ToList()); + i.VoidedAt)).ToList(), + o.Source); } diff --git a/src/Meezi.API/Services/OrderService.cs b/src/Meezi.API/Services/OrderService.cs index 449a888..4f2c71c 100644 --- a/src/Meezi.API/Services/OrderService.cs +++ b/src/Meezi.API/Services/OrderService.cs @@ -1221,5 +1221,6 @@ public class OrderService : IOrderService i.UnitPrice, i.Notes, i.IsVoided, - i.VoidedAt)).ToList()); + i.VoidedAt)).ToList(), + o.Source); } diff --git a/src/Meezi.API/Services/SnappfoodWebhookService.cs b/src/Meezi.API/Services/SnappfoodWebhookService.cs index 297976f..052ef18 100644 --- a/src/Meezi.API/Services/SnappfoodWebhookService.cs +++ b/src/Meezi.API/Services/SnappfoodWebhookService.cs @@ -183,5 +183,6 @@ public class SnappfoodWebhookService : ISnappfoodWebhookService i.UnitPrice, i.Notes, i.IsVoided, - i.VoidedAt)).ToList()); + i.VoidedAt)).ToList(), + o.Source); } diff --git a/web/dashboard/src/app/[locale]/(dashboard)/layout.tsx b/web/dashboard/src/app/[locale]/(dashboard)/layout.tsx index 7356fb4..c327a3c 100644 --- a/web/dashboard/src/app/[locale]/(dashboard)/layout.tsx +++ b/web/dashboard/src/app/[locale]/(dashboard)/layout.tsx @@ -9,6 +9,7 @@ import { RouteGuard } from "@/components/auth/route-guard"; 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"; export default function DashboardLayout({ children, @@ -20,6 +21,7 @@ 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 useEffect(() => { // Wait for Zustand to finish reading localStorage before deciding to redirect. diff --git a/web/dashboard/src/lib/realtime/use-order-alerts.ts b/web/dashboard/src/lib/realtime/use-order-alerts.ts new file mode 100644 index 0000000..8b34b44 --- /dev/null +++ b/web/dashboard/src/lib/realtime/use-order-alerts.ts @@ -0,0 +1,102 @@ +"use client"; + +import { useEffect } from "react"; +import * as signalR from "@microsoft/signalr"; +import { useLocale } from "next-intl"; +import { useQueryClient } from "@tanstack/react-query"; +import { useAuthStore } from "@/lib/stores/auth.store"; +import { notify } from "@/lib/notify"; + +type LiveOrder = { + displayNumber?: number; + tableNumber?: string | null; + 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 + } +} + +/** + * 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. + */ +export function useOrderAlerts() { + const cafeId = useAuthStore((s) => s.user?.cafeId); + const locale = useLocale(); + const qc = useQueryClient(); + + useEffect(() => { + if (!cafeId) return; + + const baseUrl = process.env.NEXT_PUBLIC_API_URL ?? "https://localhost:7208"; + const connection = new signalR.HubConnectionBuilder() + .withUrl(`${baseUrl}/hubs/kds`, { + accessTokenFactory: () => + (typeof window !== "undefined" ? localStorage.getItem("meezi_access_token") : null) ?? "", + }) + .withAutomaticReconnect() + .build(); + + 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; + + playChime(); + const table = order?.tableNumber; + const msg = + locale === "en" + ? `New order${table ? ` — table ${table}` : ""}` + : locale === "ar" + ? `طلب جديد${table ? ` — طاولة ${table}` : ""}` + : `سفارش جدید${table ? ` — میز ${table}` : ""}`; + notify.success(msg); + + void qc.invalidateQueries({ queryKey: ["kds"] }); + void qc.invalidateQueries({ queryKey: ["orders"] }); + void qc.invalidateQueries({ queryKey: ["pos"] }); + }); + + void connection + .start() + .then(() => { + if (!stopped) return connection.invoke("JoinCafe", cafeId); + }) + .catch(() => { + // connection/auth failed — alerts simply won't fire; no UI breakage + }); + + return () => { + stopped = true; + void connection.stop(); + }; + }, [cafeId, locale, qc]); +}