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")}

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