feat(notifications): click a notification to jump to its related page
CI/CD / CI · API (dotnet build + test) (push) Successful in 42s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 28s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m9s
CI/CD / CI · Admin Web (tsc) (push) Successful in 37s
CI/CD / CI · Website (tsc) (push) Successful in 45s
CI/CD / CI · Koja (tsc) (push) Successful in 49s
CI/CD / Deploy · all services (push) Successful in 2m45s
CI/CD / CI · API (dotnet build + test) (push) Successful in 42s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 28s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m9s
CI/CD / CI · Admin Web (tsc) (push) Successful in 37s
CI/CD / CI · Website (tsc) (push) Successful in 45s
CI/CD / CI · Koja (tsc) (push) Successful in 49s
CI/CD / Deploy · all services (push) Successful in 2m45s
Every notification surface now deep-links to where the staff member needs to act: - bell dropdown: clicking an actionable notification navigates and closes the dropdown (platform broadcasts still expand inline to show their text) - notifications page: rows navigate to the right page - in-app toast: gains a "View" action button - desktop/Windows popup: clicking it focuses the tab and navigates Routing is now permission-aware via a single resolver (notification-routes.ts): a new-order alert sends a kitchen user to /kds, a cashier to /pos, and a floor user to /tables — never to a page their role can't open; a waiter call → /tables. This also fixes the old bug where table_call_waiter (which carries a referenceId) wrongly routed to /kds. Toast/desktop clicks navigate client-side through a small event bridge mounted in the dashboard shell. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,7 @@ 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";
|
import { useOrderAlerts } from "@/lib/realtime/use-order-alerts";
|
||||||
import { useTabBadge } from "@/lib/notifications/use-tab-badge";
|
import { useTabBadge } from "@/lib/notifications/use-tab-badge";
|
||||||
|
import { useNotificationNavBridge } from "@/lib/notifications/notification-routes";
|
||||||
|
|
||||||
export default function DashboardLayout({
|
export default function DashboardLayout({
|
||||||
children,
|
children,
|
||||||
@@ -24,6 +25,7 @@ export default function DashboardLayout({
|
|||||||
useOfflineSync(); // register online/offline listeners + load queue count
|
useOfflineSync(); // register online/offline listeners + load queue count
|
||||||
useOrderAlerts(); // global sound + toast + desktop popup for café notifications
|
useOrderAlerts(); // global sound + toast + desktop popup for café notifications
|
||||||
useTabBadge(); // unread count on the browser tab title + favicon
|
useTabBadge(); // unread count on the browser tab title + favicon
|
||||||
|
useNotificationNavBridge(); // toast/desktop notification clicks → navigate
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Wait for Zustand to finish reading localStorage before deciding to redirect.
|
// Wait for Zustand to finish reading localStorage before deciding to redirect.
|
||||||
|
|||||||
@@ -3,8 +3,12 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Bell } from "lucide-react";
|
import { Bell } from "lucide-react";
|
||||||
import { useLocale, useTranslations } from "next-intl";
|
import { useLocale, useTranslations } from "next-intl";
|
||||||
|
import { useRouter } from "@/i18n/routing";
|
||||||
import { useNotificationsFeed } from "@/lib/hooks/use-notifications-feed";
|
import { useNotificationsFeed } from "@/lib/hooks/use-notifications-feed";
|
||||||
import type { CafeNotification } from "@/lib/api/notifications";
|
import type { CafeNotification } from "@/lib/api/notifications";
|
||||||
|
import { resolveNotificationDestination } from "@/lib/notifications/notification-routes";
|
||||||
|
import { permissionsOf } from "@/lib/permissions";
|
||||||
|
import { useAuthStore } from "@/lib/stores/auth.store";
|
||||||
import { numberLocaleForUi } from "@/lib/format-datetime";
|
import { numberLocaleForUi } from "@/lib/format-datetime";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -18,6 +22,8 @@ import { cn } from "@/lib/utils";
|
|||||||
export function NotificationCenter() {
|
export function NotificationCenter() {
|
||||||
const t = useTranslations("notifications");
|
const t = useTranslations("notifications");
|
||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
|
const router = useRouter();
|
||||||
|
const permissions = permissionsOf(useAuthStore((s) => s.user));
|
||||||
const numberLocale = numberLocaleForUi(locale);
|
const numberLocale = numberLocaleForUi(locale);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [selected, setSelected] = useState<CafeNotification | null>(null);
|
const [selected, setSelected] = useState<CafeNotification | null>(null);
|
||||||
@@ -30,7 +36,15 @@ export function NotificationCenter() {
|
|||||||
|
|
||||||
const handleSelect = async (n: CafeNotification) => {
|
const handleSelect = async (n: CafeNotification) => {
|
||||||
await openNotification(n);
|
await openNotification(n);
|
||||||
setSelected({ ...n, isRead: true });
|
// Actionable notification → jump to its page and close the dropdown.
|
||||||
|
// Non-actionable (e.g. a platform broadcast) → show its full text inline.
|
||||||
|
const dest = resolveNotificationDestination(n, permissions);
|
||||||
|
if (dest) {
|
||||||
|
setOpen(false);
|
||||||
|
router.push(dest);
|
||||||
|
} else {
|
||||||
|
setSelected({ ...n, isRead: true });
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenChange = (next: boolean) => {
|
const handleOpenChange = (next: boolean) => {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
} from "@/lib/api/notifications";
|
} from "@/lib/api/notifications";
|
||||||
import { useAuthStore } from "@/lib/stores/auth.store";
|
import { useAuthStore } from "@/lib/stores/auth.store";
|
||||||
import { notify } from "@/lib/notify";
|
import { notify } from "@/lib/notify";
|
||||||
|
import { notificationDestinationFromStore } from "@/lib/notifications/notification-routes";
|
||||||
|
|
||||||
type UseNotificationsFeedOptions = {
|
type UseNotificationsFeedOptions = {
|
||||||
unreadOnly?: boolean;
|
unreadOnly?: boolean;
|
||||||
@@ -81,8 +82,8 @@ export function useNotificationsFeed(options: UseNotificationsFeedOptions = {})
|
|||||||
if (!n.isRead) await markNotificationsRead(cafeId, { ids: [n.id] });
|
if (!n.isRead) await markNotificationsRead(cafeId, { ids: [n.id] });
|
||||||
refresh();
|
refresh();
|
||||||
if (!opts.navigate) return;
|
if (!opts.navigate) return;
|
||||||
if (n.referenceId) router.push("/kds");
|
const dest = notificationDestinationFromStore(n);
|
||||||
else if (n.type.startsWith("guest_order")) router.push("/tables");
|
if (dest) router.push(dest);
|
||||||
},
|
},
|
||||||
[cafeId, refresh, router]
|
[cafeId, refresh, router]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useRouter } from "@/i18n/routing";
|
||||||
|
import type { CafeNotification } from "@/lib/api/notifications";
|
||||||
|
import { permissionsOf } from "@/lib/permissions";
|
||||||
|
import { useAuthStore } from "@/lib/stores/auth.store";
|
||||||
|
|
||||||
|
type RoutableNotification = Pick<CafeNotification, "type" | "referenceId">;
|
||||||
|
|
||||||
|
/** Fired when a toast/desktop notification is clicked; the bridge below navigates. */
|
||||||
|
export const OPEN_NOTIFICATION_EVENT = "meezi:open-notification";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The in-panel page a notification should open (locale-agnostic path), or null
|
||||||
|
* when there's nothing actionable to open (e.g. a platform broadcast — show its
|
||||||
|
* text instead). Permission-aware: a new-order alert sends a kitchen user to the
|
||||||
|
* KDS, a cashier to the POS, and a floor user to the tables board — never to a
|
||||||
|
* page their role can't open (which the route guard would just block).
|
||||||
|
*/
|
||||||
|
export function resolveNotificationDestination(
|
||||||
|
n: RoutableNotification,
|
||||||
|
permissions: Set<string> | null
|
||||||
|
): string | null {
|
||||||
|
const can = (p: string) => permissions === null || permissions.has(p);
|
||||||
|
|
||||||
|
// Best available "live orders" surface for this user.
|
||||||
|
const orderPage = (): string | null =>
|
||||||
|
can("ViewKitchen") ? "/kds"
|
||||||
|
: can("ProcessOrders") ? "/pos"
|
||||||
|
: can("ViewTables") ? "/tables"
|
||||||
|
: null;
|
||||||
|
|
||||||
|
switch (n.type) {
|
||||||
|
case "table_call_waiter":
|
||||||
|
return can("ViewTables") ? "/tables" : null;
|
||||||
|
case "guest_order_new":
|
||||||
|
case "guest_order_ready":
|
||||||
|
return orderPage();
|
||||||
|
case "platform_broadcast":
|
||||||
|
return null; // announcement — no page to open
|
||||||
|
default:
|
||||||
|
if (n.type.startsWith("guest_order")) return orderPage();
|
||||||
|
if (n.type.includes("table")) return can("ViewTables") ? "/tables" : null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve the destination using the current user's permissions from the store.
|
||||||
|
* Safe to call outside React render (e.g. inside a SignalR/toast callback). */
|
||||||
|
export function notificationDestinationFromStore(n: RoutableNotification): string | null {
|
||||||
|
return resolveNotificationDestination(n, permissionsOf(useAuthStore.getState().user));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Listens for {@link OPEN_NOTIFICATION_EVENT} (dispatched when a toast or native
|
||||||
|
* desktop notification is clicked) and navigates client-side. Mount once in the
|
||||||
|
* dashboard shell.
|
||||||
|
*/
|
||||||
|
export function useNotificationNavBridge() {
|
||||||
|
const router = useRouter();
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: Event) => {
|
||||||
|
const path = (e as CustomEvent<string>).detail;
|
||||||
|
if (path) router.push(path);
|
||||||
|
};
|
||||||
|
window.addEventListener(OPEN_NOTIFICATION_EVENT, handler);
|
||||||
|
return () => window.removeEventListener(OPEN_NOTIFICATION_EVENT, handler);
|
||||||
|
}, [router]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dispatch a navigation request from a non-React context (toast/desktop click). */
|
||||||
|
export function requestNotificationNav(path: string): void {
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
window.dispatchEvent(new CustomEvent(OPEN_NOTIFICATION_EVENT, { detail: path }));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { useNotifPrefs } from "@/lib/notifications/notification-prefs.store";
|
import { useNotifPrefs } from "@/lib/notifications/notification-prefs.store";
|
||||||
|
import { requestNotificationNav } from "@/lib/notifications/notification-routes";
|
||||||
|
|
||||||
export type PermissionState = NotificationPermission | "unsupported";
|
export type PermissionState = NotificationPermission | "unsupported";
|
||||||
|
|
||||||
@@ -34,6 +35,8 @@ export function showDesktopNotification(opts: {
|
|||||||
body?: string;
|
body?: string;
|
||||||
/** Collapses repeat popups for the same subject (e.g. the order id). */
|
/** Collapses repeat popups for the same subject (e.g. the order id). */
|
||||||
tag?: string;
|
tag?: string;
|
||||||
|
/** In-app path to open when the popup is clicked. */
|
||||||
|
path?: string | null;
|
||||||
}): void {
|
}): void {
|
||||||
if (!notificationsSupported()) return;
|
if (!notificationsSupported()) return;
|
||||||
if (!useNotifPrefs.getState().desktopEnabled) return;
|
if (!useNotifPrefs.getState().desktopEnabled) return;
|
||||||
@@ -53,6 +56,8 @@ export function showDesktopNotification(opts: {
|
|||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
|
// Bring the user straight to the related page (handled by the nav bridge).
|
||||||
|
if (opts.path) requestNotificationNav(opts.path);
|
||||||
n.close();
|
n.close();
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -4,12 +4,17 @@ import { ApiClientError } from "@/lib/api/client";
|
|||||||
export type NotifyOptions = {
|
export type NotifyOptions = {
|
||||||
description?: string;
|
description?: string;
|
||||||
duration?: number;
|
duration?: number;
|
||||||
|
/** Optional click-through button (e.g. "View" → navigate to the related page). */
|
||||||
|
action?: { label: string; onClick: () => void };
|
||||||
};
|
};
|
||||||
|
|
||||||
function baseOptions(opts?: NotifyOptions) {
|
function baseOptions(opts?: NotifyOptions) {
|
||||||
return {
|
return {
|
||||||
description: opts?.description,
|
description: opts?.description,
|
||||||
duration: opts?.duration ?? 4000,
|
duration: opts?.duration ?? 4000,
|
||||||
|
action: opts?.action
|
||||||
|
? { label: opts.action.label, onClick: opts.action.onClick }
|
||||||
|
: undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ import { notify } from "@/lib/notify";
|
|||||||
import { useNotifPrefs } from "@/lib/notifications/notification-prefs.store";
|
import { useNotifPrefs } from "@/lib/notifications/notification-prefs.store";
|
||||||
import { playSound } from "@/lib/notifications/sounds";
|
import { playSound } from "@/lib/notifications/sounds";
|
||||||
import { showDesktopNotification } from "@/lib/notifications/use-desktop-notifications";
|
import { showDesktopNotification } from "@/lib/notifications/use-desktop-notifications";
|
||||||
|
import {
|
||||||
|
notificationDestinationFromStore,
|
||||||
|
requestNotificationNav,
|
||||||
|
} from "@/lib/notifications/notification-routes";
|
||||||
|
|
||||||
type LiveOrder = {
|
type LiveOrder = {
|
||||||
displayNumber?: number;
|
displayNumber?: number;
|
||||||
@@ -60,22 +64,30 @@ export function useOrderAlerts() {
|
|||||||
if (type === "guest_order_ready") return notify.success;
|
if (type === "guest_order_ready") return notify.success;
|
||||||
return notify.info;
|
return notify.info;
|
||||||
};
|
};
|
||||||
|
const viewLabel = locale === "en" ? "View" : locale === "ar" ? "عرض" : "مشاهده";
|
||||||
|
|
||||||
// Persisted notifications are the canonical alert trigger (guest order placed /
|
// Persisted notifications are the canonical alert trigger (guest order placed /
|
||||||
// ready, waiter call, admin broadcast). Staff-created POS orders do NOT create
|
// ready, waiter call, admin broadcast). Staff-created POS orders do NOT create
|
||||||
// one, so they correctly stay silent.
|
// one, so they correctly stay silent. Each alert can deep-link to its page.
|
||||||
connection.on("NotificationReceived", (n: CafeNotification | null) => {
|
connection.on("NotificationReceived", (n: CafeNotification | null) => {
|
||||||
if (!n) return;
|
if (!n) return;
|
||||||
const prefs = useNotifPrefs.getState();
|
const prefs = useNotifPrefs.getState();
|
||||||
|
const dest = notificationDestinationFromStore(n);
|
||||||
|
|
||||||
if (prefs.soundEnabled) playSound(prefs.soundId, prefs.volume);
|
if (prefs.soundEnabled) playSound(prefs.soundId, prefs.volume);
|
||||||
if (prefs.toastEnabled) {
|
if (prefs.toastEnabled) {
|
||||||
severityFor(n.type)(n.title, { description: n.body ?? undefined });
|
severityFor(n.type)(n.title, {
|
||||||
|
description: n.body ?? undefined,
|
||||||
|
action: dest
|
||||||
|
? { label: viewLabel, onClick: () => requestNotificationNav(dest) }
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
showDesktopNotification({
|
showDesktopNotification({
|
||||||
title: n.title,
|
title: n.title,
|
||||||
body: n.body ?? undefined,
|
body: n.body ?? undefined,
|
||||||
tag: n.referenceId ?? n.id,
|
tag: n.referenceId ?? n.id,
|
||||||
|
path: dest,
|
||||||
});
|
});
|
||||||
|
|
||||||
void qc.invalidateQueries({ queryKey: ["notifications", cafeId] });
|
void qc.invalidateQueries({ queryKey: ["notifications", cafeId] });
|
||||||
|
|||||||
Reference in New Issue
Block a user