feat(realtime): global guest-order alert on the dashboard
CI/CD / CI · API (dotnet build + test) (push) Successful in 47s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 32s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m4s
CI/CD / CI · Admin Web (tsc) (push) Successful in 37s
CI/CD / CI · Website (tsc) (push) Successful in 44s
CI/CD / CI · Koja (tsc) (push) Successful in 49s
CI/CD / Deploy · all services (push) Successful in 3m9s

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.
This commit is contained in:
soroush.asadi
2026-06-03 02:42:29 +03:30
parent 9b2f15151d
commit dcdb0d5747
6 changed files with 112 additions and 4 deletions
+2 -1
View File
@@ -80,4 +80,5 @@ public record LiveOrderDto(
OrderType OrderType,
decimal Total,
DateTime CreatedAt,
IReadOnlyList<OrderItemDto> Items);
IReadOnlyList<OrderItemDto> Items,
OrderSource Source);
@@ -345,5 +345,6 @@ public class DeliveryOrderProcessor : IDeliveryOrderProcessor
i.UnitPrice,
i.Notes,
i.IsVoided,
i.VoidedAt)).ToList());
i.VoidedAt)).ToList(),
o.Source);
}
+2 -1
View File
@@ -1221,5 +1221,6 @@ public class OrderService : IOrderService
i.UnitPrice,
i.Notes,
i.IsVoided,
i.VoidedAt)).ToList());
i.VoidedAt)).ToList(),
o.Source);
}
@@ -183,5 +183,6 @@ public class SnappfoodWebhookService : ISnappfoodWebhookService
i.UnitPrice,
i.Notes,
i.IsVoided,
i.VoidedAt)).ToList());
i.VoidedAt)).ToList(),
o.Source);
}
@@ -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.
@@ -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]);
}