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,
|
OrderType OrderType,
|
||||||
decimal Total,
|
decimal Total,
|
||||||
DateTime CreatedAt,
|
DateTime CreatedAt,
|
||||||
IReadOnlyList<OrderItemDto> Items);
|
IReadOnlyList<OrderItemDto> Items,
|
||||||
|
OrderSource Source);
|
||||||
|
|||||||
@@ -345,5 +345,6 @@ public class DeliveryOrderProcessor : IDeliveryOrderProcessor
|
|||||||
i.UnitPrice,
|
i.UnitPrice,
|
||||||
i.Notes,
|
i.Notes,
|
||||||
i.IsVoided,
|
i.IsVoided,
|
||||||
i.VoidedAt)).ToList());
|
i.VoidedAt)).ToList(),
|
||||||
|
o.Source);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1221,5 +1221,6 @@ public class OrderService : IOrderService
|
|||||||
i.UnitPrice,
|
i.UnitPrice,
|
||||||
i.Notes,
|
i.Notes,
|
||||||
i.IsVoided,
|
i.IsVoided,
|
||||||
i.VoidedAt)).ToList());
|
i.VoidedAt)).ToList(),
|
||||||
|
o.Source);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -183,5 +183,6 @@ public class SnappfoodWebhookService : ISnappfoodWebhookService
|
|||||||
i.UnitPrice,
|
i.UnitPrice,
|
||||||
i.Notes,
|
i.Notes,
|
||||||
i.IsVoided,
|
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 { 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";
|
||||||
|
|
||||||
export default function DashboardLayout({
|
export default function DashboardLayout({
|
||||||
children,
|
children,
|
||||||
@@ -20,6 +21,7 @@ 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
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Wait for Zustand to finish reading localStorage before deciding to redirect.
|
// 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