Task #389: Notification sounds + vibration with per-user customization
- DB: 7 new user prefs (sound per slot, per-type toggles, vibration, mute, volume). - API: PATCH /auth/me/notification-preferences with whitelisted partial update, integer guard for volume, and hydrated AuthUser response. AuthUser now exposes the 7 new fields. - Frontend: Web Audio synth library (8 sounds), settings popover with global mute/vibration/volume/per-type toggles, slot tabs, sound preview buttons, shift+click on bell for quick mute. Concurrency-safe optimistic updates with monotonic seq counter. RTL-correct toggle knob transforms. - Socket hook plays sound on notification_created (orders/meetings only) when tab is hidden, respecting global mute and per-type toggles. - Audio unlock hook mounted globally to satisfy autoplay restrictions. - AR/EN translations added. Pre-existing TS errors in executive-meetings.ts (font_settings) and pre-existing test failures in test workflow are unrelated to this task.
This commit is contained in:
@@ -4,6 +4,7 @@ import { Toaster } from "@/components/ui/toaster";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { AuthProvider } from "@/contexts/AuthContext";
|
||||
import { useNotificationsSocket } from "@/hooks/use-notifications-socket";
|
||||
import { useAudioUnlock } from "@/hooks/use-audio-unlock";
|
||||
import { UpcomingMeetingAlert } from "@/components/executive-meetings/upcoming-meeting-alert";
|
||||
import NotFound from "@/pages/not-found";
|
||||
import LoginPage from "@/pages/login";
|
||||
@@ -31,6 +32,7 @@ const queryClient = new QueryClient({
|
||||
|
||||
function NotificationsSocketBridge() {
|
||||
useNotificationsSocket();
|
||||
useAudioUnlock();
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
useUpdateNotificationPreferences,
|
||||
getGetMeQueryKey,
|
||||
type AuthUser,
|
||||
type NotificationSoundId,
|
||||
} from "@workspace/api-client-react";
|
||||
import { Bell, BellOff, Check, Volume2, Play } from "lucide-react";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { ALL_SOUND_IDS, notificationPlayer } from "@/lib/notification-sounds";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
|
||||
type PrefsPatch = {
|
||||
notificationSoundOrder?: NotificationSoundId;
|
||||
notificationSoundMeeting?: NotificationSoundId;
|
||||
notifyOrdersEnabled?: boolean;
|
||||
notifyMeetingsEnabled?: boolean;
|
||||
vibrationEnabled?: boolean;
|
||||
notificationsMuted?: boolean;
|
||||
notificationVolume?: number;
|
||||
};
|
||||
|
||||
function applyOptimistic(prev: AuthUser | undefined, patch: PrefsPatch): AuthUser | undefined {
|
||||
if (!prev) return prev;
|
||||
return { ...prev, ...patch };
|
||||
}
|
||||
|
||||
export function useUpdatePrefs() {
|
||||
const queryClient = useQueryClient();
|
||||
const mutation = useUpdateNotificationPreferences();
|
||||
// Monotonic counter so out-of-order responses from rapid clicks (or a
|
||||
// dragged slider) cannot overwrite newer optimistic state.
|
||||
const seqRef = useRef(0);
|
||||
const lastAppliedRef = useRef(0);
|
||||
return (patch: PrefsPatch) => {
|
||||
const seq = ++seqRef.current;
|
||||
queryClient.cancelQueries({ queryKey: getGetMeQueryKey() }).catch(() => {});
|
||||
const previous = queryClient.getQueryData<AuthUser>(getGetMeQueryKey());
|
||||
const next = applyOptimistic(previous, patch);
|
||||
if (next) queryClient.setQueryData<AuthUser>(getGetMeQueryKey(), next);
|
||||
mutation.mutate(
|
||||
{ data: patch },
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
// Only the latest in-flight mutation may publish server state,
|
||||
// otherwise a slow earlier response would clobber a newer one.
|
||||
if (seq < lastAppliedRef.current) return;
|
||||
lastAppliedRef.current = seq;
|
||||
if (seq === seqRef.current) {
|
||||
queryClient.setQueryData(getGetMeQueryKey(), data);
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
// Only roll back if no newer write has been initiated; otherwise
|
||||
// the newer optimistic state should stand.
|
||||
if (seq !== seqRef.current) return;
|
||||
if (previous) queryClient.setQueryData(getGetMeQueryKey(), previous);
|
||||
else queryClient.invalidateQueries({ queryKey: getGetMeQueryKey() });
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
type Slot = "order" | "meeting";
|
||||
|
||||
function SoundList({
|
||||
current,
|
||||
onPick,
|
||||
volume,
|
||||
}: {
|
||||
current: NotificationSoundId;
|
||||
onPick: (id: NotificationSoundId) => void;
|
||||
volume: number;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5 max-h-56 overflow-y-auto">
|
||||
{ALL_SOUND_IDS.map((id) => {
|
||||
const isActive = id === current;
|
||||
return (
|
||||
<div
|
||||
key={id}
|
||||
className={`flex items-center justify-between gap-2 px-2 py-1.5 rounded-lg ${
|
||||
isActive ? "bg-primary/10" : "hover:bg-foreground/5"
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onPick(id)}
|
||||
className="flex-1 flex items-center gap-2 text-start min-w-0"
|
||||
>
|
||||
{isActive ? (
|
||||
<Check size={14} className="text-primary shrink-0" />
|
||||
) : (
|
||||
<span className="w-3.5 shrink-0" />
|
||||
)}
|
||||
<span className="text-xs text-foreground font-medium truncate">
|
||||
{t(`notifSettings.sounds.${id}`)}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
notificationPlayer.unlock();
|
||||
notificationPlayer.play(id, { volume });
|
||||
}}
|
||||
aria-label={t("notifSettings.preview")}
|
||||
className="p-1 rounded hover:bg-foreground/10 text-muted-foreground"
|
||||
>
|
||||
<Play size={12} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Toggle({
|
||||
active,
|
||||
onClick,
|
||||
label,
|
||||
}: {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="w-full flex items-center justify-between gap-3 px-2 py-2 rounded-lg text-start hover:bg-foreground/5 transition-colors"
|
||||
>
|
||||
<span className="text-xs text-foreground font-medium truncate">{label}</span>
|
||||
<span
|
||||
className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors ${
|
||||
active ? "bg-primary" : "bg-foreground/15"
|
||||
}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${
|
||||
active
|
||||
? "translate-x-[18px] rtl:translate-x-0.5"
|
||||
: "translate-x-0.5 rtl:translate-x-[18px]"
|
||||
}`}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function NotificationSettingsPicker() {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useAuth();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [slot, setSlot] = useState<Slot>("order");
|
||||
const apply = useUpdatePrefs();
|
||||
|
||||
if (!user) return null;
|
||||
const muted = user.notificationsMuted;
|
||||
const volume = user.notificationVolume;
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t(muted ? "notifSettings.unmute" : "notifSettings.mute")}
|
||||
aria-pressed={muted}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors p-1.5 md:p-2.5 rounded-lg md:rounded-xl hover:bg-foreground/5 relative"
|
||||
onClick={(e) => {
|
||||
// Shift+click toggles mute quickly without opening the popover.
|
||||
if (e.shiftKey) {
|
||||
e.preventDefault();
|
||||
apply({ notificationsMuted: !muted });
|
||||
}
|
||||
}}
|
||||
>
|
||||
{muted ? (
|
||||
<BellOff className="w-[18px] h-[18px] md:w-6 md:h-6" />
|
||||
) : (
|
||||
<Bell className="w-[18px] h-[18px] md:w-6 md:h-6" />
|
||||
)}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80 p-2" align="end">
|
||||
<div className="px-2 py-1.5 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{t("notifSettings.title")}
|
||||
</div>
|
||||
|
||||
<Toggle
|
||||
active={!muted}
|
||||
onClick={() => apply({ notificationsMuted: !muted })}
|
||||
label={t("notifSettings.globalSound")}
|
||||
/>
|
||||
<Toggle
|
||||
active={user.vibrationEnabled}
|
||||
onClick={() => apply({ vibrationEnabled: !user.vibrationEnabled })}
|
||||
label={t("notifSettings.vibration")}
|
||||
/>
|
||||
|
||||
<div className="h-px bg-foreground/10 mx-1 my-1" />
|
||||
|
||||
<div className="flex items-center gap-2 px-2 py-1.5">
|
||||
<Volume2 size={14} className="text-muted-foreground" />
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
value={volume}
|
||||
onChange={(e) =>
|
||||
apply({ notificationVolume: Number(e.target.value) })
|
||||
}
|
||||
className="flex-1 accent-primary"
|
||||
aria-label={t("notifSettings.volume")}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground w-8 text-end tabular-nums">
|
||||
{volume}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-foreground/10 mx-1 my-1" />
|
||||
|
||||
<Toggle
|
||||
active={user.notifyOrdersEnabled}
|
||||
onClick={() =>
|
||||
apply({ notifyOrdersEnabled: !user.notifyOrdersEnabled })
|
||||
}
|
||||
label={t("notifSettings.ordersEnabled")}
|
||||
/>
|
||||
<Toggle
|
||||
active={user.notifyMeetingsEnabled}
|
||||
onClick={() =>
|
||||
apply({ notifyMeetingsEnabled: !user.notifyMeetingsEnabled })
|
||||
}
|
||||
label={t("notifSettings.meetingsEnabled")}
|
||||
/>
|
||||
|
||||
<div className="h-px bg-foreground/10 mx-1 my-1" />
|
||||
|
||||
<div
|
||||
className="grid grid-cols-2 gap-1 mx-1 mb-1 p-1 rounded-lg bg-foreground/5"
|
||||
role="group"
|
||||
>
|
||||
{(["order", "meeting"] as const).map((s) => {
|
||||
const isActive = s === slot;
|
||||
return (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
onClick={() => setSlot(s)}
|
||||
aria-pressed={isActive}
|
||||
className={`text-xs font-medium px-2 py-1.5 rounded-md transition-colors ${
|
||||
isActive
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{t(`notifSettings.slot.${s}`)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<SoundList
|
||||
current={
|
||||
slot === "order"
|
||||
? user.notificationSoundOrder
|
||||
: user.notificationSoundMeeting
|
||||
}
|
||||
onPick={(id) =>
|
||||
apply(
|
||||
slot === "order"
|
||||
? { notificationSoundOrder: id }
|
||||
: { notificationSoundMeeting: id },
|
||||
)
|
||||
}
|
||||
volume={volume}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useEffect } from "react";
|
||||
import { notificationPlayer } from "@/lib/notification-sounds";
|
||||
|
||||
/**
|
||||
* Browsers require a user gesture before audio can play. Listen for the
|
||||
* first interaction anywhere on the page and unlock the shared audio
|
||||
* context so subsequent socket-driven sounds work.
|
||||
*/
|
||||
export function useAudioUnlock(): void {
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
let done = false;
|
||||
const unlock = () => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
notificationPlayer.unlock();
|
||||
window.removeEventListener("pointerdown", unlock);
|
||||
window.removeEventListener("keydown", unlock);
|
||||
window.removeEventListener("touchstart", unlock);
|
||||
};
|
||||
window.addEventListener("pointerdown", unlock, { once: true });
|
||||
window.addEventListener("keydown", unlock, { once: true });
|
||||
window.addEventListener("touchstart", unlock, { once: true });
|
||||
return () => {
|
||||
window.removeEventListener("pointerdown", unlock);
|
||||
window.removeEventListener("keydown", unlock);
|
||||
window.removeEventListener("touchstart", unlock);
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
getGetRoleUsageQueryKey,
|
||||
} from "@workspace/api-client-react";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { notificationPlayer } from "@/lib/notification-sounds";
|
||||
|
||||
const BASE = import.meta.env.BASE_URL.replace(/\/$/, "");
|
||||
|
||||
@@ -40,6 +41,25 @@ export function useNotificationsSocket() {
|
||||
queryKey: getListIncomingServiceOrdersQueryKey(),
|
||||
});
|
||||
}
|
||||
|
||||
// Per-user sound + vibration. Stay silent when the tab is in the
|
||||
// foreground (the visible UI already conveys the change), when the
|
||||
// user has globally muted notifications, or when this event type
|
||||
// is opted-out.
|
||||
if (typeof document !== "undefined" && document.visibilityState === "visible") return;
|
||||
if (user.notificationsMuted) return;
|
||||
const isOrder = payload?.type === "order";
|
||||
const isMeeting = payload?.type === "executive_meeting";
|
||||
if (isOrder && !user.notifyOrdersEnabled) return;
|
||||
if (isMeeting && !user.notifyMeetingsEnabled) return;
|
||||
if (!isOrder && !isMeeting) return;
|
||||
const soundId = isOrder
|
||||
? user.notificationSoundOrder
|
||||
: user.notificationSoundMeeting;
|
||||
notificationPlayer.play(soundId, {
|
||||
volume: user.notificationVolume,
|
||||
vibrate: user.vibrationEnabled,
|
||||
});
|
||||
});
|
||||
|
||||
socket.on("order_updated", () => {
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import type { NotificationSoundId } from "@workspace/api-client-react";
|
||||
|
||||
type ToneStep = {
|
||||
freq: number;
|
||||
duration: number;
|
||||
delay?: number;
|
||||
type?: OscillatorType;
|
||||
gain?: number;
|
||||
};
|
||||
|
||||
type SoundDef = {
|
||||
id: NotificationSoundId;
|
||||
steps: ToneStep[];
|
||||
};
|
||||
|
||||
export const SOUND_LIBRARY: ReadonlyArray<SoundDef> = [
|
||||
{
|
||||
id: "ding",
|
||||
steps: [
|
||||
{ freq: 1318.5, duration: 0.35, type: "sine", gain: 0.5 },
|
||||
{ freq: 880, duration: 0.45, delay: 0.05, type: "sine", gain: 0.35 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "chime",
|
||||
steps: [
|
||||
{ freq: 523.25, duration: 0.3, type: "sine" },
|
||||
{ freq: 659.25, duration: 0.3, delay: 0.12, type: "sine" },
|
||||
{ freq: 783.99, duration: 0.5, delay: 0.24, type: "sine" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "bell",
|
||||
steps: [
|
||||
{ freq: 1760, duration: 0.6, type: "triangle", gain: 0.45 },
|
||||
{ freq: 880, duration: 0.6, delay: 0.0, type: "sine", gain: 0.25 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "knock",
|
||||
steps: [
|
||||
{ freq: 180, duration: 0.12, type: "square", gain: 0.55 },
|
||||
{ freq: 180, duration: 0.12, delay: 0.18, type: "square", gain: 0.55 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "pop",
|
||||
steps: [
|
||||
{ freq: 600, duration: 0.08, type: "sine", gain: 0.55 },
|
||||
{ freq: 1200, duration: 0.12, delay: 0.04, type: "sine", gain: 0.45 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "alert",
|
||||
steps: [
|
||||
{ freq: 988, duration: 0.18, type: "square", gain: 0.4 },
|
||||
{ freq: 988, duration: 0.18, delay: 0.22, type: "square", gain: 0.4 },
|
||||
{ freq: 988, duration: 0.18, delay: 0.44, type: "square", gain: 0.4 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "beep",
|
||||
steps: [{ freq: 1000, duration: 0.25, type: "sine", gain: 0.5 }],
|
||||
},
|
||||
{
|
||||
id: "soft",
|
||||
steps: [
|
||||
{ freq: 392, duration: 0.5, type: "sine", gain: 0.35 },
|
||||
{ freq: 587.33, duration: 0.5, delay: 0.1, type: "sine", gain: 0.3 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const SOUND_BY_ID = new Map(SOUND_LIBRARY.map((s) => [s.id, s]));
|
||||
|
||||
const ALL_SOUND_IDS: ReadonlyArray<NotificationSoundId> = SOUND_LIBRARY.map(
|
||||
(s) => s.id,
|
||||
);
|
||||
export { ALL_SOUND_IDS };
|
||||
|
||||
type AnyAudioContext = AudioContext;
|
||||
|
||||
class NotificationPlayer {
|
||||
private ctx: AnyAudioContext | null = null;
|
||||
private unlocked = false;
|
||||
private lastPlayAt = 0;
|
||||
|
||||
private getCtx(): AnyAudioContext | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
if (this.ctx) return this.ctx;
|
||||
const Ctor =
|
||||
window.AudioContext ||
|
||||
(window as unknown as { webkitAudioContext?: typeof AudioContext })
|
||||
.webkitAudioContext;
|
||||
if (!Ctor) return null;
|
||||
try {
|
||||
this.ctx = new Ctor();
|
||||
} catch {
|
||||
this.ctx = null;
|
||||
}
|
||||
return this.ctx;
|
||||
}
|
||||
|
||||
/**
|
||||
* Browsers require a user gesture before audio can play. Call this once
|
||||
* the user interacts with the app to "unlock" the AudioContext.
|
||||
*/
|
||||
unlock(): void {
|
||||
if (this.unlocked) return;
|
||||
const ctx = this.getCtx();
|
||||
if (!ctx) return;
|
||||
if (ctx.state === "suspended") {
|
||||
ctx.resume().catch(() => {});
|
||||
}
|
||||
// Play a tiny silent buffer to satisfy iOS/Safari unlock semantics.
|
||||
try {
|
||||
const buffer = ctx.createBuffer(1, 1, 22050);
|
||||
const src = ctx.createBufferSource();
|
||||
src.buffer = buffer;
|
||||
src.connect(ctx.destination);
|
||||
src.start(0);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
this.unlocked = true;
|
||||
}
|
||||
|
||||
play(
|
||||
soundId: NotificationSoundId,
|
||||
opts: { volume?: number; vibrate?: boolean } = {},
|
||||
): void {
|
||||
// Throttle: at most one sound every 600ms to avoid cacophony when
|
||||
// many events arrive at once.
|
||||
const now =
|
||||
typeof performance !== "undefined" ? performance.now() : Date.now();
|
||||
if (now - this.lastPlayAt < 600) return;
|
||||
this.lastPlayAt = now;
|
||||
|
||||
const def = SOUND_BY_ID.get(soundId) ?? SOUND_BY_ID.get("ding");
|
||||
if (!def) return;
|
||||
const ctx = this.getCtx();
|
||||
if (ctx) {
|
||||
if (ctx.state === "suspended") ctx.resume().catch(() => {});
|
||||
const baseGain = Math.max(0, Math.min(1, (opts.volume ?? 70) / 100));
|
||||
const t0 = ctx.currentTime + 0.01;
|
||||
for (const step of def.steps) {
|
||||
try {
|
||||
const osc = ctx.createOscillator();
|
||||
const g = ctx.createGain();
|
||||
osc.type = step.type ?? "sine";
|
||||
osc.frequency.value = step.freq;
|
||||
const start = t0 + (step.delay ?? 0);
|
||||
const peak = baseGain * (step.gain ?? 0.4);
|
||||
g.gain.setValueAtTime(0.0001, start);
|
||||
g.gain.exponentialRampToValueAtTime(
|
||||
Math.max(peak, 0.0002),
|
||||
start + 0.01,
|
||||
);
|
||||
g.gain.exponentialRampToValueAtTime(0.0001, start + step.duration);
|
||||
osc.connect(g).connect(ctx.destination);
|
||||
osc.start(start);
|
||||
osc.stop(start + step.duration + 0.05);
|
||||
} catch {
|
||||
/* ignore single-step failures */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (opts.vibrate && typeof navigator !== "undefined" && navigator.vibrate) {
|
||||
try {
|
||||
navigator.vibrate([60, 40, 60]);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const notificationPlayer = new NotificationPlayer();
|
||||
@@ -287,6 +287,31 @@
|
||||
"adminPromotedAfterLeave": "تمت ترقية {{target}} إلى مشرف بعد مغادرة المشرف السابق"
|
||||
}
|
||||
},
|
||||
"notifSettings": {
|
||||
"title": "أصوات الإشعارات",
|
||||
"mute": "كتم الإشعارات",
|
||||
"unmute": "إلغاء كتم الإشعارات",
|
||||
"globalSound": "تشغيل صوت عند التنبيه",
|
||||
"vibration": "اهتزاز عند التنبيه",
|
||||
"volume": "مستوى الصوت",
|
||||
"ordersEnabled": "صوت للطلبات الجديدة",
|
||||
"meetingsEnabled": "صوت لتذكيرات الاجتماعات",
|
||||
"preview": "تجربة",
|
||||
"slot": {
|
||||
"order": "الطلبات",
|
||||
"meeting": "الاجتماعات"
|
||||
},
|
||||
"sounds": {
|
||||
"ding": "رنين",
|
||||
"chime": "نغمة",
|
||||
"bell": "جرس",
|
||||
"knock": "طرق",
|
||||
"pop": "نقرة",
|
||||
"alert": "تنبيه",
|
||||
"beep": "صفير",
|
||||
"soft": "هادئ"
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"title": "الإشعارات",
|
||||
"markAllRead": "تحديد الكل كمقروء",
|
||||
|
||||
@@ -290,6 +290,31 @@
|
||||
"noNotifications": "No new notifications",
|
||||
"unread": "Unread"
|
||||
},
|
||||
"notifSettings": {
|
||||
"title": "Notification sounds",
|
||||
"mute": "Mute notifications",
|
||||
"unmute": "Unmute notifications",
|
||||
"globalSound": "Play sound on alerts",
|
||||
"vibration": "Vibrate on alerts",
|
||||
"volume": "Volume",
|
||||
"ordersEnabled": "Sound for new orders",
|
||||
"meetingsEnabled": "Sound for meeting reminders",
|
||||
"preview": "Preview",
|
||||
"slot": {
|
||||
"order": "Orders",
|
||||
"meeting": "Meetings"
|
||||
},
|
||||
"sounds": {
|
||||
"ding": "Ding",
|
||||
"chime": "Chime",
|
||||
"bell": "Bell",
|
||||
"knock": "Knock",
|
||||
"pop": "Pop",
|
||||
"alert": "Alert",
|
||||
"beep": "Beep",
|
||||
"soft": "Soft"
|
||||
}
|
||||
},
|
||||
"admin": {
|
||||
"title": "Admin Dashboard",
|
||||
"manageApps": "Manage Apps",
|
||||
|
||||
@@ -65,6 +65,7 @@ import {
|
||||
type HomeClockSize,
|
||||
} from "@/components/clock";
|
||||
import { ClockStylePicker } from "@/components/clock-style-picker";
|
||||
import { NotificationSettingsPicker } from "@/components/notification-settings";
|
||||
|
||||
type IconName = keyof typeof LucideIcons;
|
||||
function isIconName(x: string): x is IconName {
|
||||
@@ -495,6 +496,7 @@ export default function HomePage() {
|
||||
currentStyle={user?.clockStyle ?? null}
|
||||
currentHour12={user?.clockHour12 ?? null}
|
||||
/>
|
||||
<NotificationSettingsPicker />
|
||||
<button
|
||||
onClick={toggleLanguage}
|
||||
aria-label="Toggle language"
|
||||
|
||||
Reference in New Issue
Block a user