From b162335b484f62cf3c860124a7579614b21f7809 Mon Sep 17 00:00:00 2001 From: "soroush.asadi" Date: Thu, 25 Jun 2026 10:32:12 +0330 Subject: [PATCH] feat(notifications): proactively ask the browser for popup permission Previously desktop popups had to be enabled from Settings. Now a dismissible prompt card appears for signed-in users on a supporting browser that haven't decided yet ("Turn on notifications?" + Enable/Later). Tapping Enable triggers the browser permission request (user gesture, as browsers require), turns on desktop popups, and immediately fires one (force) so the user sees it works. Shown once per device (remembered in localStorage); mounted on both the dashboard and POS/queue layouts. fa/en/ar added. Co-Authored-By: Claude Opus 4.8 --- web/dashboard/messages/ar.json | 5 +- web/dashboard/messages/en.json | 5 +- web/dashboard/messages/fa.json | 5 +- .../src/app/[locale]/(dashboard)/layout.tsx | 2 + .../src/app/[locale]/(fullscreen)/layout.tsx | 2 + .../notification-permission-prompt.tsx | 114 ++++++++++++++++++ 6 files changed, 130 insertions(+), 3 deletions(-) create mode 100644 web/dashboard/src/components/notifications/notification-permission-prompt.tsx 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 ( +
+ + + +
+

{t("promptTitle")}

+

{t("promptBody")}

+
+ + +
+
+ +
+ ); +}