feat(dashboard): Notifications & sound settings panel (fa/en/ar)
CI/CD / CI · API (dotnet build + test) (push) Successful in 1m5s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 30s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m9s
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 3m46s

New Settings → "Notifications & sound" leaf to make the alert channels
changeable: toggle sound (+ picker with live preview + volume slider),
enable desktop notifications (permission flow + test button), toggle the
tab unread badge and in-app toasts. Strings added for fa/en/ar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-21 05:08:39 +03:30
parent 149a4d88cd
commit 170a9aa7ac
6 changed files with 337 additions and 0 deletions
+34
View File
@@ -1200,6 +1200,7 @@
"shop": "المقهى والمتجر",
"shopGeneral": "الملف والتكاملات",
"shopAppearance": "المظهر والألوان",
"shopNotifications": "الإشعارات والصوت",
"printer": "الطابعة",
"printerSettings": "إعدادات الطابعة",
"printTest": "صفحة اختبار الطباعة",
@@ -1207,6 +1208,39 @@
"team": "الفريق والموظفون",
"customRoles": "الأدوار المخصصة"
},
"notifPrefs": {
"soundSection": "الصوت",
"soundEnabled": "تشغيل صوت للإشعارات الجديدة",
"soundEnabledHint": "يصدر صوتًا عند وصول طلب جديد أو نداء نادل أو تنبيه.",
"soundChoice": "صوت الإشعار",
"preview": "معاينة",
"volume": "مستوى الصوت",
"soundClassic": "كلاسيكي",
"soundDing": "رنين",
"soundBell": "جرس",
"soundChime": "أجراس",
"soundMarimba": "ماريمبا",
"soundAlert": "تنبيه",
"desktopSection": "إشعارات سطح المكتب",
"desktopHint": "إظهار نافذة منبثقة على ويندوز/سطح المكتب حتى عندما تكون لوحة التحكم في تبويب آخر أو مصغّرة.",
"enableDesktop": "تفعيل إشعارات سطح المكتب",
"desktopEnabled": "نوافذ سطح المكتب",
"desktopEnabledHint": "تظهر فقط عندما لا يكون هذا التبويب نشطًا.",
"desktopGranted": "تم تفعيل إشعارات سطح المكتب",
"desktopDenied": "تم رفض الإذن من المتصفح",
"desktopBlocked": "الإشعارات محظورة لهذا الموقع. اسمح بها من إعدادات الموقع في المتصفح ثم أعد التحميل.",
"desktopUnsupported": "هذا المتصفح لا يدعم إشعارات سطح المكتب.",
"desktopFocusNote": "تظهر النافذة التجريبية فقط إذا انتقلت إلى نافذة أخرى أولًا.",
"sendTest": "إرسال إشعار تجريبي",
"testTitle": "ميزي",
"testBody": "هذا إشعار تجريبي.",
"testToast": "تم إرسال الإشعار التجريبي",
"inAppSection": "داخل التطبيق",
"tabBadge": "عدد غير المقروء على تبويب المتصفح",
"tabBadgeHint": "يعرض عدد الإشعارات غير المقروءة في عنوان التبويب والأيقونة المفضلة.",
"toast": "تنبيه داخل التطبيق",
"toastHint": "إظهار شريط صغير داخل لوحة التحكم للإشعارات الجديدة."
},
"customRoles": {
"title": "الأدوار المخصصة",
"subtitle": "حدّد أدواراً بصلاحيات مخصصة لموظفيك",
+34
View File
@@ -1272,6 +1272,7 @@
"shop": "Shop & café",
"shopGeneral": "Profile & integrations",
"shopAppearance": "Appearance & colors",
"shopNotifications": "Notifications & sound",
"printer": "Printer",
"printerSettings": "Printer settings",
"printTest": "Print test page",
@@ -1279,6 +1280,39 @@
"team": "Team & Staff",
"customRoles": "Custom Roles"
},
"notifPrefs": {
"soundSection": "Sound",
"soundEnabled": "Play a sound for new notifications",
"soundEnabledHint": "Chimes when a new order, waiter call, or alert arrives.",
"soundChoice": "Notification sound",
"preview": "Preview",
"volume": "Volume",
"soundClassic": "Classic",
"soundDing": "Ding",
"soundBell": "Bell",
"soundChime": "Chime",
"soundMarimba": "Marimba",
"soundAlert": "Alert",
"desktopSection": "Desktop notifications",
"desktopHint": "Show a Windows/desktop popup even when the dashboard is in another tab or minimized.",
"enableDesktop": "Enable desktop notifications",
"desktopEnabled": "Desktop popups",
"desktopEnabledHint": "Pop up only when this tab is not focused.",
"desktopGranted": "Desktop notifications enabled",
"desktopDenied": "Permission denied by the browser",
"desktopBlocked": "Notifications are blocked for this site. Allow them in your browser's site settings, then reload.",
"desktopUnsupported": "This browser does not support desktop notifications.",
"desktopFocusNote": "A test popup only appears if you switch to another window first.",
"sendTest": "Send a test notification",
"testTitle": "Meezi",
"testBody": "This is a test notification.",
"testToast": "Test sent",
"inAppSection": "In-app",
"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."
},
"customRoles": {
"title": "Custom Roles",
"subtitle": "Define roles with tailored permissions for your staff",
+34
View File
@@ -1273,6 +1273,7 @@
"shop": "کافه و فروشگاه",
"shopGeneral": "پروفایل و اتصال‌ها",
"shopAppearance": "ظاهر و رنگ‌بندی",
"shopNotifications": "اعلان‌ها و صدا",
"printer": "پرینتر",
"printerSettings": "تنظیمات پرینتر",
"printTest": "صفحه تست چاپ",
@@ -1280,6 +1281,39 @@
"team": "تیم و کارمندان",
"customRoles": "نقش‌های سفارشی"
},
"notifPrefs": {
"soundSection": "صدا",
"soundEnabled": "پخش صدا برای اعلان‌های جدید",
"soundEnabledHint": "هنگام رسیدن سفارش جدید، درخواست میز یا هشدار، صدا پخش می‌شود.",
"soundChoice": "صدای اعلان",
"preview": "پیش‌نمایش",
"volume": "بلندی صدا",
"soundClassic": "کلاسیک",
"soundDing": "دینگ",
"soundBell": "زنگ",
"soundChime": "ناقوس",
"soundMarimba": "ماریمبا",
"soundAlert": "هشدار",
"desktopSection": "اعلان‌های دسکتاپ",
"desktopHint": "نمایش پاپ‌آپ ویندوز/دسکتاپ حتی وقتی داشبورد در تب دیگری باز است یا کوچک شده.",
"enableDesktop": "فعال‌سازی اعلان‌های دسکتاپ",
"desktopEnabled": "پاپ‌آپ دسکتاپ",
"desktopEnabledHint": "فقط وقتی این تب فعال نیست نمایش داده می‌شود.",
"desktopGranted": "اعلان‌های دسکتاپ فعال شد",
"desktopDenied": "دسترسی توسط مرورگر رد شد",
"desktopBlocked": "اعلان‌ها برای این سایت مسدود شده‌اند. از تنظیمات سایت در مرورگر اجازه دهید و سپس صفحه را دوباره بارگذاری کنید.",
"desktopUnsupported": "این مرورگر از اعلان‌های دسکتاپ پشتیبانی نمی‌کند.",
"desktopFocusNote": "پاپ‌آپ آزمایشی فقط زمانی نمایش داده می‌شود که ابتدا به پنجره دیگری بروید.",
"sendTest": "ارسال اعلان آزمایشی",
"testTitle": "میزی",
"testBody": "این یک اعلان آزمایشی است.",
"testToast": "اعلان آزمایشی ارسال شد",
"inAppSection": "درون‌برنامه",
"tabBadge": "شمارش خوانده‌نشده روی تب مرورگر",
"tabBadgeHint": "تعداد اعلان‌های خوانده‌نشده را در عنوان تب و فاویکون نشان می‌دهد.",
"toast": "نوتیف درون‌برنامه",
"toastHint": "نمایش یک بنر کوچک داخل داشبورد برای اعلان‌های جدید."
},
"customRoles": {
"title": "نقش‌های سفارشی",
"subtitle": "نقش‌هایی با دسترسی دلخواه برای کارمندان تعریف کنید",
@@ -0,0 +1,227 @@
"use client";
import { Bell, Volume2 } from "lucide-react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { LabeledField } from "@/components/ui/labeled-field";
import { notify } from "@/lib/notify";
import { useNotifPrefs } from "@/lib/notifications/notification-prefs.store";
import { SOUND_PRESETS, playSound, type SoundId } from "@/lib/notifications/sounds";
import {
showDesktopNotification,
useNotificationPermission,
} from "@/lib/notifications/use-desktop-notifications";
function Toggle({
id,
checked,
onChange,
label,
hint,
}: {
id: string;
checked: boolean;
onChange: (v: boolean) => void;
label: string;
hint?: string;
}) {
return (
<label
htmlFor={id}
className="flex cursor-pointer items-start justify-between gap-4 rounded-lg border border-border/80 bg-card px-4 py-3"
>
<span className="min-w-0 space-y-0.5">
<span className="block text-sm font-medium">{label}</span>
{hint ? <span className="block text-xs text-muted-foreground">{hint}</span> : null}
</span>
<span className="relative mt-0.5 inline-flex shrink-0">
<input
id={id}
type="checkbox"
className="peer sr-only"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
/>
<span className="h-6 w-11 rounded-full bg-muted transition-colors peer-checked:bg-[#0F6E56]" />
<span className="absolute top-0.5 start-0.5 h-5 w-5 rounded-full bg-white shadow transition-transform peer-checked:translate-x-5 rtl:peer-checked:-translate-x-5" />
</span>
</label>
);
}
export function SettingsNotificationsPanel() {
const t = useTranslations("settings.notifPrefs");
const prefs = useNotifPrefs();
const hasHydrated = useNotifPrefs((s) => s._hasHydrated);
const setPrefs = useNotifPrefs((s) => s.setPrefs);
const { permission, request, supported } = useNotificationPermission();
if (!hasHydrated) {
return <p className="text-sm text-muted-foreground"></p>;
}
const enableDesktop = async () => {
const p = await request();
if (p === "granted") {
setPrefs({ desktopEnabled: true });
notify.success(t("desktopGranted"));
} else if (p === "denied") {
setPrefs({ desktopEnabled: false });
notify.error(t("desktopDenied"));
}
};
const desktopReady = supported && permission === "granted";
return (
<div className="space-y-4">
{/* Sound */}
<Card className="rounded-xl border border-border/80 shadow-sm">
<CardHeader className="px-6 pb-4 pt-6">
<CardTitle className="flex items-center gap-2 text-base font-medium">
<Volume2 className="size-4 text-[#0F6E56]" />
{t("soundSection")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-5 px-6 pb-6 pt-0">
<Toggle
id="notif-sound"
checked={prefs.soundEnabled}
onChange={(v) => setPrefs({ soundEnabled: v })}
label={t("soundEnabled")}
hint={t("soundEnabledHint")}
/>
<div className="grid gap-4 sm:grid-cols-2">
<LabeledField label={t("soundChoice")} htmlFor="notif-sound-choice">
<div className="flex gap-2">
<select
id="notif-sound-choice"
className="h-10 w-full rounded-md border border-input bg-background px-3 text-sm disabled:opacity-50"
value={prefs.soundId}
disabled={!prefs.soundEnabled}
onChange={(e) => setPrefs({ soundId: e.target.value as SoundId })}
>
{SOUND_PRESETS.map((s) => (
<option key={s.id} value={s.id}>
{t(s.labelKey)}
</option>
))}
</select>
<Button
type="button"
variant="outline"
size="sm"
disabled={!prefs.soundEnabled}
onClick={() => playSound(prefs.soundId, prefs.volume)}
>
{t("preview")}
</Button>
</div>
</LabeledField>
<LabeledField label={t("volume")} htmlFor="notif-volume">
<div className="flex h-10 items-center gap-3">
<input
id="notif-volume"
type="range"
min={0}
max={100}
step={5}
value={Math.round(prefs.volume * 100)}
disabled={!prefs.soundEnabled}
onChange={(e) => setPrefs({ volume: Number(e.target.value) / 100 })}
className="h-2 w-full cursor-pointer accent-[#0F6E56] disabled:opacity-50"
/>
<span className="w-10 text-end text-xs tabular-nums text-muted-foreground">
{Math.round(prefs.volume * 100)}%
</span>
</div>
</LabeledField>
</div>
</CardContent>
</Card>
{/* Desktop / Windows popups */}
<Card className="rounded-xl border border-border/80 shadow-sm">
<CardHeader className="px-6 pb-4 pt-6">
<CardTitle className="flex items-center gap-2 text-base font-medium">
<Bell className="size-4 text-[#0F6E56]" />
{t("desktopSection")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4 px-6 pb-6 pt-0">
<p className="text-xs leading-relaxed text-muted-foreground">{t("desktopHint")}</p>
{!supported ? (
<p className="rounded-lg border border-border/80 bg-muted/40 px-4 py-2.5 text-xs">
{t("desktopUnsupported")}
</p>
) : permission === "denied" ? (
<p className="rounded-lg border border-[#A32D2D]/30 bg-[#A32D2D]/5 px-4 py-2.5 text-xs text-[#A32D2D]">
{t("desktopBlocked")}
</p>
) : permission !== "granted" ? (
<Button
type="button"
className="bg-[#0F6E56] hover:bg-[#0c5e46]"
onClick={enableDesktop}
>
{t("enableDesktop")}
</Button>
) : (
<div className="space-y-4">
<Toggle
id="notif-desktop"
checked={prefs.desktopEnabled}
onChange={(v) => setPrefs({ desktopEnabled: v })}
label={t("desktopEnabled")}
hint={t("desktopEnabledHint")}
/>
<Button
type="button"
variant="outline"
size="sm"
disabled={!prefs.desktopEnabled}
onClick={() => {
if (prefs.soundEnabled) playSound(prefs.soundId, prefs.volume);
showDesktopNotification({ title: t("testTitle"), body: t("testBody") });
notify.info(t("testToast"));
}}
>
{t("sendTest")}
</Button>
{desktopReady ? (
<p className="text-[11px] text-muted-foreground">{t("desktopFocusNote")}</p>
) : null}
</div>
)}
</CardContent>
</Card>
{/* Tab badge + in-app toast */}
<Card className="rounded-xl border border-border/80 shadow-sm">
<CardHeader className="px-6 pb-4 pt-6">
<CardTitle className="text-base font-medium">{t("inAppSection")}</CardTitle>
</CardHeader>
<CardContent className="space-y-3 px-6 pb-6 pt-0">
<Toggle
id="notif-tab-badge"
checked={prefs.tabBadgeEnabled}
onChange={(v) => setPrefs({ tabBadgeEnabled: v })}
label={t("tabBadge")}
hint={t("tabBadgeHint")}
/>
<Toggle
id="notif-toast"
checked={prefs.toastEnabled}
onChange={(v) => setPrefs({ toastEnabled: v })}
label={t("toast")}
hint={t("toastHint")}
/>
</CardContent>
</Card>
</div>
);
}
@@ -6,6 +6,7 @@ import { useAuthStore } from "@/lib/stores/auth.store";
import { PageHeader } from "@/components/layout/page-header";
import { SettingsNav } from "@/components/settings/settings-nav";
import { SettingsAppearancePanel } from "@/components/settings/settings-appearance-panel";
import { SettingsNotificationsPanel } from "@/components/settings/settings-notifications-panel";
import { CafeDiscoverProfilePanel } from "@/components/discover/cafe-discover-profile-panel";
import { CafePublicProfilePanel } from "@/components/discover/cafe-public-profile-panel";
import { SettingsShopPanel } from "@/components/settings/settings-shop-panel";
@@ -23,6 +24,7 @@ import {
const LEAF_PAGE_TITLE: Record<SettingsLeafId, string> = {
"shop-general": "nav.shopGeneral",
"shop-appearance": "nav.shopAppearance",
"shop-notifications": "nav.shopNotifications",
"shop-discover": "nav.shopDiscover",
"printer-config": "nav.printerSettings",
"print-test": "nav.printTest",
@@ -83,6 +85,10 @@ export function SettingsScreen() {
<SettingsAppearancePanel cafeId={cafeId} />
) : null}
{activeLeaf === "shop-notifications" ? (
<SettingsNotificationsPanel />
) : null}
{activeLeaf === "shop-discover" ? (
<div className="space-y-6">
<CafeDiscoverProfilePanel cafeId={cafeId} mode="merchant" />
@@ -3,6 +3,7 @@ export type SettingsGroupId = "shop" | "printer" | "team";
export type SettingsLeafId =
| "shop-general"
| "shop-appearance"
| "shop-notifications"
| "shop-discover"
| "printer-config"
| "print-test"
@@ -21,6 +22,7 @@ export const SETTINGS_NAV: SettingsNavGroup[] = [
children: [
{ id: "shop-general", labelKey: "nav.shopGeneral" },
{ id: "shop-appearance", labelKey: "nav.shopAppearance" },
{ id: "shop-notifications", labelKey: "nav.shopNotifications" },
{ id: "shop-discover", labelKey: "nav.shopDiscover" },
],
},