feat(notifications): proactively ask the browser for popup permission
CI/CD / CI · API (dotnet build + test) (push) Successful in 1m1s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 30s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m11s
CI/CD / CI · Admin Web (tsc) (push) Successful in 39s
CI/CD / CI · Website (tsc) (push) Successful in 46s
CI/CD / CI · Koja (tsc) (push) Successful in 50s
CI/CD / Deploy · all services (push) Successful in 2m52s

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 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-25 10:32:12 +03:30
parent 27b3ac60c7
commit b162335b48
6 changed files with 130 additions and 3 deletions
+4 -1
View File
@@ -1289,7 +1289,10 @@
"tabBadge": "عدد غير المقروء على تبويب المتصفح", "tabBadge": "عدد غير المقروء على تبويب المتصفح",
"tabBadgeHint": "يعرض عدد الإشعارات غير المقروءة في عنوان التبويب والأيقونة المفضلة.", "tabBadgeHint": "يعرض عدد الإشعارات غير المقروءة في عنوان التبويب والأيقونة المفضلة.",
"toast": "تنبيه داخل التطبيق", "toast": "تنبيه داخل التطبيق",
"toastHint": "إظهار شريط صغير داخل لوحة التحكم للإشعارات الجديدة." "toastHint": "إظهار شريط صغير داخل لوحة التحكم للإشعارات الجديدة.",
"promptTitle": "تفعيل الإشعارات؟",
"promptBody": "احصل على نافذة منبثقة وصوت للطلبات الجديدة ونداءات النادل — حتى عندما يكون هذا التبويب في الخلفية.",
"later": "لاحقًا"
}, },
"customRoles": { "customRoles": {
"title": "الأدوار المخصصة", "title": "الأدوار المخصصة",
+4 -1
View File
@@ -1361,7 +1361,10 @@
"tabBadge": "Unread count on the browser tab", "tabBadge": "Unread count on the browser tab",
"tabBadgeHint": "Shows the number of unread notifications in the tab title and favicon.", "tabBadgeHint": "Shows the number of unread notifications in the tab title and favicon.",
"toast": "In-app toast", "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": { "customRoles": {
"title": "Custom Roles", "title": "Custom Roles",
+4 -1
View File
@@ -1362,7 +1362,10 @@
"tabBadge": "شمارش خوانده‌نشده روی تب مرورگر", "tabBadge": "شمارش خوانده‌نشده روی تب مرورگر",
"tabBadgeHint": "تعداد اعلان‌های خوانده‌نشده را در عنوان تب و فاویکون نشان می‌دهد.", "tabBadgeHint": "تعداد اعلان‌های خوانده‌نشده را در عنوان تب و فاویکون نشان می‌دهد.",
"toast": "نوتیف درون‌برنامه", "toast": "نوتیف درون‌برنامه",
"toastHint": "نمایش یک بنر کوچک داخل داشبورد برای اعلان‌های جدید." "toastHint": "نمایش یک بنر کوچک داخل داشبورد برای اعلان‌های جدید.",
"promptTitle": "اعلان‌ها روشن شود؟",
"promptBody": "برای سفارش‌های جدید و درخواست میز، پاپ‌آپ و صدا دریافت کنید — حتی وقتی این تب در پس‌زمینه است.",
"later": "بعداً"
}, },
"customRoles": { "customRoles": {
"title": "نقش‌های سفارشی", "title": "نقش‌های سفارشی",
@@ -12,6 +12,7 @@ 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"; import { useNotificationNavBridge } from "@/lib/notifications/notification-routes";
import { NotificationPermissionPrompt } from "@/components/notifications/notification-permission-prompt";
export default function DashboardLayout({ export default function DashboardLayout({
children, children,
@@ -65,6 +66,7 @@ export default function DashboardLayout({
</> </>
)} )}
</div> </div>
<NotificationPermissionPrompt />
</CafeThemeProvider> </CafeThemeProvider>
); );
} }
@@ -7,6 +7,7 @@ import { useRouter } from "@/i18n/routing";
import { useAuthStore } from "@/lib/stores/auth.store"; import { useAuthStore } from "@/lib/stores/auth.store";
import { RouteGuard } from "@/components/auth/route-guard"; import { RouteGuard } from "@/components/auth/route-guard";
import { useOrderAlerts } from "@/lib/realtime/use-order-alerts"; 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. */ /** Full-viewport routes (POS, queue TV display) — auth only, no dashboard chrome. */
export default function FullscreenLayout({ children }: { children: React.ReactNode }) { export default function FullscreenLayout({ children }: { children: React.ReactNode }) {
@@ -41,6 +42,7 @@ export default function FullscreenLayout({ children }: { children: React.ReactNo
return ( return (
<div className="min-h-svh" dir={dir}> <div className="min-h-svh" dir={dir}>
<RouteGuard>{children}</RouteGuard> <RouteGuard>{children}</RouteGuard>
<NotificationPermissionPrompt />
</div> </div>
); );
} }
@@ -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 (
<div className="fixed inset-x-4 bottom-4 z-[60] mx-auto flex max-w-md items-start gap-3 rounded-xl border border-border bg-card p-4 shadow-lg">
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-[#E1F5EE] text-[#0F6E56]">
<Bell className="size-4" />
</span>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">{t("promptTitle")}</p>
<p className="mt-0.5 text-xs text-muted-foreground">{t("promptBody")}</p>
<div className="mt-3 flex gap-2">
<Button
size="sm"
className="bg-[#0F6E56] hover:bg-[#0c5e46]"
disabled={busy}
onClick={enable}
>
{t("enableDesktop")}
</Button>
<Button size="sm" variant="ghost" onClick={dismiss} disabled={busy}>
{t("later")}
</Button>
</div>
</div>
<button
type="button"
onClick={dismiss}
aria-label={t("later")}
className="rounded-md p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
>
<X className="size-4" />
</button>
</div>
);
}