diff --git a/web/dashboard/messages/ar.json b/web/dashboard/messages/ar.json
index 7d25c7f..5a1efa0 100644
--- a/web/dashboard/messages/ar.json
+++ b/web/dashboard/messages/ar.json
@@ -1289,7 +1289,10 @@
"tabBadge": "عدد غير المقروء على تبويب المتصفح",
"tabBadgeHint": "يعرض عدد الإشعارات غير المقروءة في عنوان التبويب والأيقونة المفضلة.",
"toast": "تنبيه داخل التطبيق",
- "toastHint": "إظهار شريط صغير داخل لوحة التحكم للإشعارات الجديدة."
+ "toastHint": "إظهار شريط صغير داخل لوحة التحكم للإشعارات الجديدة.",
+ "promptTitle": "تفعيل الإشعارات؟",
+ "promptBody": "احصل على نافذة منبثقة وصوت للطلبات الجديدة ونداءات النادل — حتى عندما يكون هذا التبويب في الخلفية.",
+ "later": "لاحقًا"
},
"customRoles": {
"title": "الأدوار المخصصة",
diff --git a/web/dashboard/messages/en.json b/web/dashboard/messages/en.json
index 8db3b92..7a96238 100644
--- a/web/dashboard/messages/en.json
+++ b/web/dashboard/messages/en.json
@@ -1361,7 +1361,10 @@
"tabBadge": "Unread count on the browser tab",
"tabBadgeHint": "Shows the number of unread notifications in the tab title and favicon.",
"toast": "In-app toast",
- "toastHint": "Show a small banner inside the dashboard for new notifications."
+ "toastHint": "Show a small banner inside the dashboard for new notifications.",
+ "promptTitle": "Turn on notifications?",
+ "promptBody": "Get a popup + sound for new orders and waiter calls — even when this tab is in the background.",
+ "later": "Later"
},
"customRoles": {
"title": "Custom Roles",
diff --git a/web/dashboard/messages/fa.json b/web/dashboard/messages/fa.json
index e3477e3..9385c30 100644
--- a/web/dashboard/messages/fa.json
+++ b/web/dashboard/messages/fa.json
@@ -1362,7 +1362,10 @@
"tabBadge": "شمارش خواندهنشده روی تب مرورگر",
"tabBadgeHint": "تعداد اعلانهای خواندهنشده را در عنوان تب و فاویکون نشان میدهد.",
"toast": "نوتیف درونبرنامه",
- "toastHint": "نمایش یک بنر کوچک داخل داشبورد برای اعلانهای جدید."
+ "toastHint": "نمایش یک بنر کوچک داخل داشبورد برای اعلانهای جدید.",
+ "promptTitle": "اعلانها روشن شود؟",
+ "promptBody": "برای سفارشهای جدید و درخواست میز، پاپآپ و صدا دریافت کنید — حتی وقتی این تب در پسزمینه است.",
+ "later": "بعداً"
},
"customRoles": {
"title": "نقشهای سفارشی",
diff --git a/web/dashboard/src/app/[locale]/(dashboard)/layout.tsx b/web/dashboard/src/app/[locale]/(dashboard)/layout.tsx
index 6e5753b..1f5e65a 100644
--- a/web/dashboard/src/app/[locale]/(dashboard)/layout.tsx
+++ b/web/dashboard/src/app/[locale]/(dashboard)/layout.tsx
@@ -12,6 +12,7 @@ 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";
+import { NotificationPermissionPrompt } from "@/components/notifications/notification-permission-prompt";
export default function DashboardLayout({
children,
@@ -65,6 +66,7 @@ export default function DashboardLayout({
>
)}
+
);
}
diff --git a/web/dashboard/src/app/[locale]/(fullscreen)/layout.tsx b/web/dashboard/src/app/[locale]/(fullscreen)/layout.tsx
index a99ed03..94eefb0 100644
--- a/web/dashboard/src/app/[locale]/(fullscreen)/layout.tsx
+++ b/web/dashboard/src/app/[locale]/(fullscreen)/layout.tsx
@@ -7,6 +7,7 @@ import { useRouter } from "@/i18n/routing";
import { useAuthStore } from "@/lib/stores/auth.store";
import { RouteGuard } from "@/components/auth/route-guard";
import { useOrderAlerts } from "@/lib/realtime/use-order-alerts";
+import { NotificationPermissionPrompt } from "@/components/notifications/notification-permission-prompt";
/** Full-viewport routes (POS, queue TV display) — auth only, no dashboard chrome. */
export default function FullscreenLayout({ children }: { children: React.ReactNode }) {
@@ -41,6 +42,7 @@ export default function FullscreenLayout({ children }: { children: React.ReactNo
return (
{children}
+
);
}
diff --git a/web/dashboard/src/components/notifications/notification-permission-prompt.tsx b/web/dashboard/src/components/notifications/notification-permission-prompt.tsx
new file mode 100644
index 0000000..d53ebbb
--- /dev/null
+++ b/web/dashboard/src/components/notifications/notification-permission-prompt.tsx
@@ -0,0 +1,114 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { useTranslations } from "next-intl";
+import { Bell, X } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { notify } from "@/lib/notify";
+import { useAuthStore } from "@/lib/stores/auth.store";
+import { useNotifPrefs } from "@/lib/notifications/notification-prefs.store";
+import {
+ useNotificationPermission,
+ showDesktopNotification,
+} from "@/lib/notifications/use-desktop-notifications";
+
+const DISMISS_KEY = "meezi_notif_prompt_dismissed";
+
+/**
+ * Proactively asks the browser for notification permission. Browsers require a
+ * user gesture, so we show a small dismissible card with an Enable button rather
+ * than auto-calling requestPermission(). On grant we turn desktop popups on and
+ * immediately fire one (force) so the user sees it works. Shown once per device
+ * until the user enables or dismisses it.
+ */
+export function NotificationPermissionPrompt() {
+ const t = useTranslations("settings.notifPrefs");
+ const user = useAuthStore((s) => s.user);
+ const { permission, request, supported } = useNotificationPermission();
+ const setPrefs = useNotifPrefs((s) => s.setPrefs);
+ const hasHydrated = useNotifPrefs((s) => s._hasHydrated);
+ const [dismissed, setDismissed] = useState(true); // assume dismissed until we read storage
+ const [busy, setBusy] = useState(false);
+
+ useEffect(() => {
+ setDismissed(
+ typeof window !== "undefined" && localStorage.getItem(DISMISS_KEY) === "1"
+ );
+ }, []);
+
+ // Only prompt a signed-in user, on a supporting browser, that hasn't decided yet.
+ if (
+ !user?.accessToken ||
+ !hasHydrated ||
+ !supported ||
+ permission !== "default" ||
+ dismissed
+ ) {
+ return null;
+ }
+
+ const dismiss = () => {
+ setDismissed(true);
+ try {
+ localStorage.setItem(DISMISS_KEY, "1");
+ } catch {
+ // ignore
+ }
+ };
+
+ const enable = async () => {
+ setBusy(true);
+ try {
+ const p = await request();
+ if (p === "granted") {
+ setPrefs({ desktopEnabled: true });
+ // Show one immediately (force past the tab-visible guard) so they see it.
+ showDesktopNotification({
+ title: t("testTitle"),
+ body: t("desktopGranted"),
+ force: true,
+ });
+ notify.success(t("desktopGranted"));
+ dismiss();
+ } else if (p === "denied") {
+ notify.error(t("desktopDenied"));
+ dismiss();
+ }
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ return (
+