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
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:
@@ -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": "حدّد أدواراً بصلاحيات مخصصة لموظفيك",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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" },
|
||||
],
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user