diff --git a/web/dashboard/src/app/[locale]/(dashboard)/layout.tsx b/web/dashboard/src/app/[locale]/(dashboard)/layout.tsx index 2070d34..6e5753b 100644 --- a/web/dashboard/src/app/[locale]/(dashboard)/layout.tsx +++ b/web/dashboard/src/app/[locale]/(dashboard)/layout.tsx @@ -11,6 +11,7 @@ import { useAuthStore } from "@/lib/stores/auth.store"; import { useOfflineSync } from "@/lib/offline/use-offline-sync"; import { useOrderAlerts } from "@/lib/realtime/use-order-alerts"; import { useTabBadge } from "@/lib/notifications/use-tab-badge"; +import { useNotificationNavBridge } from "@/lib/notifications/notification-routes"; export default function DashboardLayout({ children, @@ -24,6 +25,7 @@ export default function DashboardLayout({ useOfflineSync(); // register online/offline listeners + load queue count useOrderAlerts(); // global sound + toast + desktop popup for café notifications useTabBadge(); // unread count on the browser tab title + favicon + useNotificationNavBridge(); // toast/desktop notification clicks → navigate useEffect(() => { // Wait for Zustand to finish reading localStorage before deciding to redirect. diff --git a/web/dashboard/src/components/notifications/notification-center.tsx b/web/dashboard/src/components/notifications/notification-center.tsx index 2f65f81..442a2a0 100644 --- a/web/dashboard/src/components/notifications/notification-center.tsx +++ b/web/dashboard/src/components/notifications/notification-center.tsx @@ -3,8 +3,12 @@ import { useState } from "react"; import { Bell } from "lucide-react"; import { useLocale, useTranslations } from "next-intl"; +import { useRouter } from "@/i18n/routing"; import { useNotificationsFeed } from "@/lib/hooks/use-notifications-feed"; 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 { Button } from "@/components/ui/button"; import { @@ -18,6 +22,8 @@ import { cn } from "@/lib/utils"; export function NotificationCenter() { const t = useTranslations("notifications"); const locale = useLocale(); + const router = useRouter(); + const permissions = permissionsOf(useAuthStore((s) => s.user)); const numberLocale = numberLocaleForUi(locale); const [open, setOpen] = useState(false); const [selected, setSelected] = useState(null); @@ -30,7 +36,15 @@ export function NotificationCenter() { const handleSelect = async (n: CafeNotification) => { 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) => { diff --git a/web/dashboard/src/lib/hooks/use-notifications-feed.ts b/web/dashboard/src/lib/hooks/use-notifications-feed.ts index b4264af..d42ce18 100644 --- a/web/dashboard/src/lib/hooks/use-notifications-feed.ts +++ b/web/dashboard/src/lib/hooks/use-notifications-feed.ts @@ -11,6 +11,7 @@ import { } from "@/lib/api/notifications"; import { useAuthStore } from "@/lib/stores/auth.store"; import { notify } from "@/lib/notify"; +import { notificationDestinationFromStore } from "@/lib/notifications/notification-routes"; type UseNotificationsFeedOptions = { unreadOnly?: boolean; @@ -81,8 +82,8 @@ export function useNotificationsFeed(options: UseNotificationsFeedOptions = {}) if (!n.isRead) await markNotificationsRead(cafeId, { ids: [n.id] }); refresh(); if (!opts.navigate) return; - if (n.referenceId) router.push("/kds"); - else if (n.type.startsWith("guest_order")) router.push("/tables"); + const dest = notificationDestinationFromStore(n); + if (dest) router.push(dest); }, [cafeId, refresh, router] ); diff --git a/web/dashboard/src/lib/notifications/notification-routes.ts b/web/dashboard/src/lib/notifications/notification-routes.ts new file mode 100644 index 0000000..0cc8ccb --- /dev/null +++ b/web/dashboard/src/lib/notifications/notification-routes.ts @@ -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; + +/** 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 | 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).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 })); + } +} diff --git a/web/dashboard/src/lib/notifications/use-desktop-notifications.ts b/web/dashboard/src/lib/notifications/use-desktop-notifications.ts index 6bd610b..68709a0 100644 --- a/web/dashboard/src/lib/notifications/use-desktop-notifications.ts +++ b/web/dashboard/src/lib/notifications/use-desktop-notifications.ts @@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from "react"; import { useNotifPrefs } from "@/lib/notifications/notification-prefs.store"; +import { requestNotificationNav } from "@/lib/notifications/notification-routes"; export type PermissionState = NotificationPermission | "unsupported"; @@ -34,6 +35,8 @@ export function showDesktopNotification(opts: { body?: string; /** Collapses repeat popups for the same subject (e.g. the order id). */ tag?: string; + /** In-app path to open when the popup is clicked. */ + path?: string | null; }): void { if (!notificationsSupported()) return; if (!useNotifPrefs.getState().desktopEnabled) return; @@ -53,6 +56,8 @@ export function showDesktopNotification(opts: { } catch { // ignore } + // Bring the user straight to the related page (handled by the nav bridge). + if (opts.path) requestNotificationNav(opts.path); n.close(); }; } catch { diff --git a/web/dashboard/src/lib/notify.ts b/web/dashboard/src/lib/notify.ts index fd66baa..92d157f 100644 --- a/web/dashboard/src/lib/notify.ts +++ b/web/dashboard/src/lib/notify.ts @@ -4,12 +4,17 @@ import { ApiClientError } from "@/lib/api/client"; export type NotifyOptions = { description?: string; duration?: number; + /** Optional click-through button (e.g. "View" → navigate to the related page). */ + action?: { label: string; onClick: () => void }; }; function baseOptions(opts?: NotifyOptions) { return { description: opts?.description, duration: opts?.duration ?? 4000, + action: opts?.action + ? { label: opts.action.label, onClick: opts.action.onClick } + : undefined, }; } diff --git a/web/dashboard/src/lib/realtime/use-order-alerts.ts b/web/dashboard/src/lib/realtime/use-order-alerts.ts index 20107fb..8630052 100644 --- a/web/dashboard/src/lib/realtime/use-order-alerts.ts +++ b/web/dashboard/src/lib/realtime/use-order-alerts.ts @@ -9,6 +9,10 @@ import { notify } from "@/lib/notify"; import { useNotifPrefs } from "@/lib/notifications/notification-prefs.store"; import { playSound } from "@/lib/notifications/sounds"; import { showDesktopNotification } from "@/lib/notifications/use-desktop-notifications"; +import { + notificationDestinationFromStore, + requestNotificationNav, +} from "@/lib/notifications/notification-routes"; type LiveOrder = { displayNumber?: number; @@ -60,22 +64,30 @@ export function useOrderAlerts() { if (type === "guest_order_ready") return notify.success; return notify.info; }; + const viewLabel = locale === "en" ? "View" : locale === "ar" ? "عرض" : "مشاهده"; // Persisted notifications are the canonical alert trigger (guest order placed / // 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) => { if (!n) return; const prefs = useNotifPrefs.getState(); + const dest = notificationDestinationFromStore(n); if (prefs.soundEnabled) playSound(prefs.soundId, prefs.volume); 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({ title: n.title, body: n.body ?? undefined, tag: n.referenceId ?? n.id, + path: dest, }); void qc.invalidateQueries({ queryKey: ["notifications", cafeId] });