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