diff --git a/artifacts/tx-os/src/components/settings-panel.tsx b/artifacts/tx-os/src/components/settings-panel.tsx new file mode 100644 index 00000000..7b7586c7 --- /dev/null +++ b/artifacts/tx-os/src/components/settings-panel.tsx @@ -0,0 +1,540 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useQueryClient } from "@tanstack/react-query"; +import { + useUpdateLanguage, + useUpdateClockStyle, + useUpdateClockHour12, + getGetMeQueryKey, + type AuthUser, + type ClockStyle as ClockStyleType, + type NotificationSoundId, +} from "@workspace/api-client-react"; +import { + Settings as SettingsIcon, + BellOff, + Volume2, + VolumeX, + Check, + Eye, + EyeOff, + Play, +} from "lucide-react"; +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, + SheetTrigger, +} from "@/components/ui/sheet"; +import { + Clock, + CLOCK_STYLES, + resolveClockStyle, + resolveClockHour12, + useNow, + useHomeClockVisibility, + useTopbarClockVisibility, +} from "@/components/clock"; +import { + ALL_SOUND_IDS, + notificationPlayer, +} from "@/lib/notification-sounds"; +import { useAuth } from "@/contexts/AuthContext"; +import { useUpdatePrefs } from "@/components/notification-settings"; +import i18n from "@/i18n"; + +type Slot = "order" | "meeting" | "note"; + +const SLOT_KEYS: Record< + Slot, + { + enabledKey: keyof Pick< + AuthUser, + "notifyOrdersEnabled" | "notifyMeetingsEnabled" | "notifyNotesEnabled" + >; + soundKey: keyof Pick< + AuthUser, + "notificationSoundOrder" | "notificationSoundMeeting" | "notificationSoundNote" + >; + vibrateKey: keyof Pick< + AuthUser, + "vibrationEnabledOrder" | "vibrationEnabledMeeting" | "vibrationEnabledNote" + >; + enabledLabel: string; + vibrateLabel: string; + } +> = { + order: { + enabledKey: "notifyOrdersEnabled", + soundKey: "notificationSoundOrder", + vibrateKey: "vibrationEnabledOrder", + enabledLabel: "notifSettings.ordersEnabled", + vibrateLabel: "notifSettings.vibrationOrder", + }, + meeting: { + enabledKey: "notifyMeetingsEnabled", + soundKey: "notificationSoundMeeting", + vibrateKey: "vibrationEnabledMeeting", + enabledLabel: "notifSettings.meetingsEnabled", + vibrateLabel: "notifSettings.vibrationMeeting", + }, + note: { + enabledKey: "notifyNotesEnabled", + soundKey: "notificationSoundNote", + vibrateKey: "vibrationEnabledNote", + enabledLabel: "notifSettings.notesEnabled", + vibrateLabel: "notifSettings.vibrationNote", + }, +}; + +function ToggleRow({ + active, + onClick, + label, + leftIcon, +}: { + active: boolean; + onClick: () => void; + label: string; + leftIcon?: React.ReactNode; +}) { + return ( + + ); +} + +function SectionTitle({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +function GroupCard({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +function LanguageGroup() { + const { t, i18n: i18nInstance } = useTranslation(); + const queryClient = useQueryClient(); + const updateLanguage = useUpdateLanguage(); + const current = i18nInstance.language === "ar" ? "ar" : "en"; + + const choose = (next: "ar" | "en") => { + if (next === current) return; + i18n.changeLanguage(next); + updateLanguage.mutate( + { data: { language: next } }, + { + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: getGetMeQueryKey() }); + }, + }, + ); + }; + + const options: Array<{ value: "ar" | "en"; label: string }> = [ + { value: "ar", label: t("settingsPanel.lang.ar") }, + { value: "en", label: t("settingsPanel.lang.en") }, + ]; + + return ( + + {t("settingsPanel.section.language")} +
+ {options.map((opt) => { + const isActive = opt.value === current; + return ( + + ); + })} +
+
+ ); +} + +function SoundList({ + current, + onPick, +}: { + current: NotificationSoundId; + onPick: (id: NotificationSoundId) => void; +}) { + const { t } = useTranslation(); + return ( +
+ {ALL_SOUND_IDS.map((id) => { + const isActive = id === current; + return ( +
+ + +
+ ); + })} +
+ ); +} + +function NotificationsGroup() { + const { t } = useTranslation(); + const { user } = useAuth(); + const apply = useUpdatePrefs(); + const [slot, setSlot] = useState("order"); + if (!user) return null; + const cfg = SLOT_KEYS[slot]; + return ( + + {t("settingsPanel.section.notifications")} +
+ {(["order", "meeting", "note"] as const).map((s) => { + const isActive = s === slot; + return ( + + ); + })} +
+ + apply({ [cfg.enabledKey]: !user[cfg.enabledKey] } as Record) + } + label={t(cfg.enabledLabel)} + /> + + apply({ [cfg.vibrateKey]: !user[cfg.vibrateKey] } as Record) + } + label={t(cfg.vibrateLabel)} + /> +
+ apply({ [cfg.soundKey]: id } as Record)} + /> + + ); +} + +function SoundGroup() { + const { t } = useTranslation(); + const { user } = useAuth(); + const apply = useUpdatePrefs(); + if (!user) return null; + const muted = user.notificationsMuted; + return ( + + {t("settingsPanel.section.sound")} + apply({ notificationsMuted: !muted })} + label={t("settingsPanel.sound.master")} + leftIcon={ + muted ? ( + + ) : ( + + ) + } + /> + + ); +} + +function ClockGroup() { + const { t, i18n: i18nInstance } = useTranslation(); + const lang = i18nInstance.language; + const queryClient = useQueryClient(); + const now = useNow(); + const { user } = useAuth(); + const updateClockStyle = useUpdateClockStyle(); + const updateClockHour12 = useUpdateClockHour12(); + const [homeClockVisible, setHomeClockVisible] = useHomeClockVisibility(); + const [topbarClockVisible, setTopbarClockVisible] = useTopbarClockVisibility(); + + if (!user) return null; + const active = resolveClockStyle(user.clockStyle); + const activeHour12 = resolveClockHour12(user.clockHour12); + + const chooseStyle = (style: ClockStyleType) => { + const previous = queryClient.getQueryData(getGetMeQueryKey()); + if (previous) { + queryClient.setQueryData(getGetMeQueryKey(), { ...previous, clockStyle: style }); + } + updateClockStyle.mutate( + { data: { clockStyle: style } }, + { + onSuccess: (data) => queryClient.setQueryData(getGetMeQueryKey(), data), + onError: () => { + if (previous) queryClient.setQueryData(getGetMeQueryKey(), previous); + else queryClient.invalidateQueries({ queryKey: getGetMeQueryKey() }); + }, + }, + ); + }; + + const chooseHour12 = (next: boolean) => { + if (next === activeHour12) return; + const previous = queryClient.getQueryData(getGetMeQueryKey()); + if (previous) { + queryClient.setQueryData(getGetMeQueryKey(), { ...previous, clockHour12: next }); + } + updateClockHour12.mutate( + { data: { clockHour12: next } }, + { + onSuccess: (data) => queryClient.setQueryData(getGetMeQueryKey(), data), + onError: () => { + if (previous) queryClient.setQueryData(getGetMeQueryKey(), previous); + else queryClient.invalidateQueries({ queryKey: getGetMeQueryKey() }); + }, + }, + ); + }; + + return ( + + {t("settingsPanel.section.clock")} + setHomeClockVisible(!homeClockVisible)} + label={t("home.clockStyle.showWidget")} + leftIcon={ + homeClockVisible ? ( + + ) : ( + + ) + } + /> + setTopbarClockVisible(!topbarClockVisible)} + label={t("home.clockStyle.showTopbar")} + leftIcon={ + topbarClockVisible ? ( + + ) : ( + + ) + } + /> +
+
+ {t("home.clockStyle.hourFormat.label")} +
+
+ {([false, true] as const).map((value) => { + const isActive = value === activeHour12; + const labelKey = value ? "h12" : "h24"; + return ( + + ); + })} +
+
+
+ {t("home.clockStyle.label")} +
+
+ {CLOCK_STYLES.map((style) => { + const isActive = style === active; + return ( + + ); + })} +
+ + ); +} + +/** + * Unified per-user Settings panel (Task #527). Consolidates the four + * preferences that previously each had their own topbar button: + * - Language (was: globe button) + * - Notifications: per-category sound + vibration (was: bell #2 popover) + * - Sound: master mute toggle (was: volume button) + * - Clock: visibility, 12/24h, style (was: clock-style picker button) + * + * Renders as a right-side Sheet on every viewport — full-width on + * phones, capped width on desktop. Keeps the same persistence paths + * as the original controls (DB columns via the existing hooks + + * localStorage for clock visibility), so no migration is required. + */ +export function SettingsPanel() { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const { user } = useAuth(); + if (!user) return null; + const muted = user.notificationsMuted; + return ( + + + + + + + + + {t("settingsPanel.title")} + + +
+ + + + +
+
+
+ ); +} diff --git a/artifacts/tx-os/src/locales/ar.json b/artifacts/tx-os/src/locales/ar.json index 24fb93a0..7dfa508e 100644 --- a/artifacts/tx-os/src/locales/ar.json +++ b/artifacts/tx-os/src/locales/ar.json @@ -204,6 +204,22 @@ "deleteFailed": "تعذّر حذف الطلبات", "deletePartial": "تم حذف {{deleted}} من {{total}}، تعذّر حذف الباقي" }, + "settingsPanel": { + "title": "الإعدادات", + "section": { + "language": "اللغة", + "notifications": "الإشعارات", + "sound": "الصوت", + "clock": "الساعة" + }, + "lang": { + "ar": "العربية", + "en": "English" + }, + "sound": { + "master": "تشغيل أصوات الإشعارات" + } + }, "notifSettings": { "title": "أصوات الإشعارات", "mute": "كتم الإشعارات", diff --git a/artifacts/tx-os/src/locales/en.json b/artifacts/tx-os/src/locales/en.json index 4e6ed271..339d4465 100644 --- a/artifacts/tx-os/src/locales/en.json +++ b/artifacts/tx-os/src/locales/en.json @@ -210,6 +210,22 @@ "noNotifications": "No new notifications", "unread": "Unread" }, + "settingsPanel": { + "title": "Settings", + "section": { + "language": "Language", + "notifications": "Notifications", + "sound": "Sound", + "clock": "Clock" + }, + "lang": { + "ar": "العربية", + "en": "English" + }, + "sound": { + "master": "Play notification sounds" + } + }, "notifSettings": { "title": "Notification sounds", "mute": "Mute notifications", diff --git a/artifacts/tx-os/src/pages/home.tsx b/artifacts/tx-os/src/pages/home.tsx index 0ab28b3e..771664b0 100644 --- a/artifacts/tx-os/src/pages/home.tsx +++ b/artifacts/tx-os/src/pages/home.tsx @@ -11,7 +11,6 @@ import { useListIncomingServiceOrders, getListIncomingServiceOrdersQueryKey, useLogout, - useUpdateLanguage, useUpdateMyAppOrder, logAppOpen, getGetMeQueryKey, @@ -25,7 +24,6 @@ import { Bell, Inbox, LogOut, - Globe, Grid2X2, Coffee, BellRing, @@ -33,7 +31,6 @@ import { } from "lucide-react"; import * as LucideIcons from "lucide-react"; import type { LucideIcon } from "lucide-react"; -import i18n from "@/i18n"; import { DndContext, PointerSensor, @@ -66,11 +63,7 @@ import { useHomeClockPosition, type HomeClockSize, } from "@/components/clock"; -import { ClockStylePicker } from "@/components/clock-style-picker"; -import { - NotificationSettingsPicker, - QuickMuteButton, -} from "@/components/notification-settings"; +import { SettingsPanel } from "@/components/settings-panel"; type IconName = keyof typeof LucideIcons; function isIconName(x: string): x is IconName { @@ -479,7 +472,6 @@ export default function HomePage() { const notesPulse = notePopupQueueLength > 0; const logout = useLogout(); - const updateLanguage = useUpdateLanguage(); const openApp = (app: App) => { // Fire-and-forget: keepalive ensures the request survives any @@ -519,15 +511,6 @@ export default function HomePage() { }); }; - const toggleLanguage = () => { - const newLang = lang === "ar" ? "en" : "ar"; - i18n.changeLanguage(newLang); - updateLanguage.mutate( - { data: { language: newLang } }, - { onSuccess: () => queryClient.invalidateQueries({ queryKey: getGetMeQueryKey() }) }, - ); - }; - const dockApps = (orderedApps ?? apps)?.filter((a) => ["services", "notifications", "admin"].includes(a.slug)) ?? []; const initials = (displayName || "?").trim().slice(0, 1).toUpperCase(); @@ -582,20 +565,17 @@ export default function HomePage() { numeric `size` prop and use Tailwind classes so the icon itself can scale at the `md:` breakpoint. */} + {/* + Task #527: the topbar's right-side cluster used to host five + icon buttons (clock-style, quick-mute, notification-settings, + language toggle, plus the inbox/notifications/logout below). + Per-user UI preferences (language, notification sound + + vibration, master sound, clock style/visibility/12-24h) now + live behind a single Settings (gear) entry that opens the + unified panel. The notification-center bell stays as a + navigation link — it's the only bell on the page now. + */}
- - - - {canReceiveOrders && ( +