Task #527: Unify topbar prefs into per-user Settings panel

- New `settings-panel.tsx` consolidates four duplicate topbar buttons
  (clock-style picker, quick-mute, notification-settings popover,
  language-toggle globe) behind a single gear icon that opens a
  right-side Sheet (full-width on mobile, capped on desktop).
- Panel groups: Language (ar/en), Notifications (per-slot
  enabled/sound/vibration), Sound (master mute), Clock (visibility,
  12/24h, style).
- Reuses existing hooks (useUpdateLanguage, useUpdate{Clock*},
  useUpdatePrefs, useHome/TopbarClockVisibility) so all persistence
  paths (DB columns + localStorage) stay identical — no migration.
- home.tsx topbar now renders only inbox link, single notification
  bell (kept as nav link), Settings gear, and logout.
- New i18n keys under `settingsPanel.*` in ar.json + en.json.
- Old ClockStylePicker / NotificationSettingsPicker / QuickMuteButton
  exports remain in their original files (unused) — kept to minimize
  diff scope; can be removed in a follow-up.
This commit is contained in:
riyadhafraa
2026-05-13 14:46:50 +00:00
parent 4dfde8f04a
commit 04f2ab7ea9
4 changed files with 584 additions and 31 deletions
@@ -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 (
<button
type="button"
role="switch"
aria-checked={active}
aria-label={label}
onClick={onClick}
className="w-full flex items-center justify-between gap-3 px-3 py-2.5 rounded-lg text-start hover:bg-foreground/5 transition-colors"
>
<div className="flex items-center gap-2 min-w-0">
{leftIcon}
<span className="text-sm text-foreground font-medium truncate">{label}</span>
</div>
<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-[18px]"
: "translate-x-0.5 rtl:-translate-x-0.5"
}`}
/>
</span>
</button>
);
}
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<div className="px-2 pb-1.5 pt-1 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{children}
</div>
);
}
function GroupCard({ children }: { children: React.ReactNode }) {
return (
<div className="rounded-xl border border-foreground/10 bg-background/40 p-2">
{children}
</div>
);
}
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 (
<GroupCard>
<SectionTitle>{t("settingsPanel.section.language")}</SectionTitle>
<div
className="grid grid-cols-2 gap-1 mx-1 mb-1 p-1 rounded-lg bg-foreground/5"
role="radiogroup"
aria-label={t("settingsPanel.section.language")}
>
{options.map((opt) => {
const isActive = opt.value === current;
return (
<button
key={opt.value}
type="button"
role="radio"
aria-checked={isActive}
onClick={() => choose(opt.value)}
data-testid={`settings-lang-${opt.value}`}
className={`text-sm font-medium px-2 py-1.5 rounded-md transition-colors ${
isActive
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
>
{opt.label}
</button>
);
})}
</div>
</GroupCard>
);
}
function SoundList({
current,
onPick,
}: {
current: NotificationSoundId;
onPick: (id: NotificationSoundId) => void;
}) {
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-sm text-foreground font-medium truncate">
{t(`notifSettings.sounds.${id}`)}
</span>
</button>
<button
type="button"
onClick={() => notificationPlayer.testPlay(id)}
aria-label={t("notifSettings.preview")}
className="p-1 rounded hover:bg-foreground/10 text-muted-foreground"
>
<Play size={12} />
</button>
</div>
);
})}
</div>
);
}
function NotificationsGroup() {
const { t } = useTranslation();
const { user } = useAuth();
const apply = useUpdatePrefs();
const [slot, setSlot] = useState<Slot>("order");
if (!user) return null;
const cfg = SLOT_KEYS[slot];
return (
<GroupCard>
<SectionTitle>{t("settingsPanel.section.notifications")}</SectionTitle>
<div
className="grid grid-cols-3 gap-1 mx-1 mb-1 p-1 rounded-lg bg-foreground/5"
role="group"
>
{(["order", "meeting", "note"] 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>
<ToggleRow
active={user[cfg.enabledKey]}
onClick={() =>
apply({ [cfg.enabledKey]: !user[cfg.enabledKey] } as Record<string, boolean>)
}
label={t(cfg.enabledLabel)}
/>
<ToggleRow
active={user[cfg.vibrateKey]}
onClick={() =>
apply({ [cfg.vibrateKey]: !user[cfg.vibrateKey] } as Record<string, boolean>)
}
label={t(cfg.vibrateLabel)}
/>
<div className="h-px bg-foreground/10 mx-1 my-1" />
<SoundList
current={user[cfg.soundKey]}
onPick={(id) => apply({ [cfg.soundKey]: id } as Record<string, NotificationSoundId>)}
/>
</GroupCard>
);
}
function SoundGroup() {
const { t } = useTranslation();
const { user } = useAuth();
const apply = useUpdatePrefs();
if (!user) return null;
const muted = user.notificationsMuted;
return (
<GroupCard>
<SectionTitle>{t("settingsPanel.section.sound")}</SectionTitle>
<ToggleRow
active={!muted}
onClick={() => apply({ notificationsMuted: !muted })}
label={t("settingsPanel.sound.master")}
leftIcon={
muted ? (
<VolumeX size={16} className="text-muted-foreground shrink-0" />
) : (
<Volume2 size={16} className="text-primary shrink-0" />
)
}
/>
</GroupCard>
);
}
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<AuthUser>(getGetMeQueryKey());
if (previous) {
queryClient.setQueryData<AuthUser>(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<AuthUser>(getGetMeQueryKey());
if (previous) {
queryClient.setQueryData<AuthUser>(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 (
<GroupCard>
<SectionTitle>{t("settingsPanel.section.clock")}</SectionTitle>
<ToggleRow
active={homeClockVisible}
onClick={() => setHomeClockVisible(!homeClockVisible)}
label={t("home.clockStyle.showWidget")}
leftIcon={
homeClockVisible ? (
<Eye size={16} className="text-primary shrink-0" />
) : (
<EyeOff size={16} className="text-muted-foreground shrink-0" />
)
}
/>
<ToggleRow
active={topbarClockVisible}
onClick={() => setTopbarClockVisible(!topbarClockVisible)}
label={t("home.clockStyle.showTopbar")}
leftIcon={
topbarClockVisible ? (
<Eye size={16} className="text-primary shrink-0" />
) : (
<EyeOff size={16} className="text-muted-foreground shrink-0" />
)
}
/>
<div className="h-px bg-foreground/10 mx-1 my-1" />
<div className="px-2 py-1 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{t("home.clockStyle.hourFormat.label")}
</div>
<div
className="grid grid-cols-2 gap-1 mx-1 mb-1 p-1 rounded-lg bg-foreground/5"
role="group"
aria-label={t("home.clockStyle.hourFormat.label")}
>
{([false, true] as const).map((value) => {
const isActive = value === activeHour12;
const labelKey = value ? "h12" : "h24";
return (
<button
key={labelKey}
type="button"
onClick={() => chooseHour12(value)}
disabled={updateClockHour12.isPending}
aria-pressed={isActive}
className={`text-sm 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(`home.clockStyle.hourFormat.${labelKey}`)}
</button>
);
})}
</div>
<div className="h-px bg-foreground/10 mx-1 my-1" />
<div className="px-2 py-1 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{t("home.clockStyle.label")}
</div>
<div className="flex flex-col gap-1 max-h-56 overflow-y-auto">
{CLOCK_STYLES.map((style) => {
const isActive = style === active;
return (
<button
key={style}
type="button"
onClick={() => chooseStyle(style)}
disabled={updateClockStyle.isPending}
className={`flex items-center justify-between gap-3 px-2 py-2 rounded-lg text-start hover:bg-foreground/5 transition-colors ${
isActive ? "bg-primary/10" : ""
}`}
>
<div className="flex items-center gap-3 min-w-0 flex-1">
<div className="shrink-0">
<Clock
style={style}
time={now}
lang={lang}
hour12={activeHour12}
size="sm"
/>
</div>
<div className="text-sm text-foreground font-medium truncate">
{t(`home.clockStyle.options.${style}`)}
</div>
</div>
{isActive && <Check size={14} className="text-primary shrink-0" />}
</button>
);
})}
</div>
</GroupCard>
);
}
/**
* 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 (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<button
type="button"
aria-label={t("settingsPanel.title")}
aria-haspopup="dialog"
data-testid="topbar-settings-button"
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"
>
<SettingsIcon className="w-[18px] h-[18px] md:w-6 md:h-6" />
{muted && (
<BellOff
aria-hidden="true"
className="absolute -top-0.5 -right-0.5 w-3 h-3 text-destructive bg-background rounded-full p-px"
/>
)}
</button>
</SheetTrigger>
<SheetContent
side="right"
className="w-full sm:max-w-md p-0 flex flex-col"
data-testid="settings-panel"
>
<SheetHeader className="px-4 pt-4 pb-2 shrink-0 border-b border-foreground/10">
<SheetTitle className="flex items-center gap-2 text-base">
<SettingsIcon size={18} />
{t("settingsPanel.title")}
</SheetTitle>
</SheetHeader>
<div className="flex-1 overflow-y-auto p-3 space-y-3">
<LanguageGroup />
<NotificationsGroup />
<SoundGroup />
<ClockGroup />
</div>
</SheetContent>
</Sheet>
);
}
+16
View File
@@ -204,6 +204,22 @@
"deleteFailed": "تعذّر حذف الطلبات",
"deletePartial": "تم حذف {{deleted}} من {{total}}، تعذّر حذف الباقي"
},
"settingsPanel": {
"title": "الإعدادات",
"section": {
"language": "اللغة",
"notifications": "الإشعارات",
"sound": "الصوت",
"clock": "الساعة"
},
"lang": {
"ar": "العربية",
"en": "English"
},
"sound": {
"master": "تشغيل أصوات الإشعارات"
}
},
"notifSettings": {
"title": "أصوات الإشعارات",
"mute": "كتم الإشعارات",
+16
View File
@@ -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",
+12 -31
View File
@@ -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.
*/}
<div className="flex items-center gap-1 md:gap-2 shrink basis-0 flex-1 justify-end">
<ClockStylePicker
currentStyle={user?.clockStyle ?? null}
currentHour12={user?.clockHour12 ?? null}
/>
<QuickMuteButton />
<NotificationSettingsPicker />
<button
onClick={toggleLanguage}
aria-label="Toggle language"
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"
>
<Globe className="w-[18px] h-[18px] md:w-6 md:h-6" />
</button>
{canReceiveOrders && (
<button
onClick={() => setLocation("/orders/incoming")}
@@ -621,6 +601,7 @@ export default function HomePage() {
</span>
)}
</button>
<SettingsPanel />
<button
onClick={handleLogout}
className="p-1.5 md:p-2.5 rounded-lg md:rounded-xl hover:bg-foreground/5 text-muted-foreground hover:text-foreground transition-colors"