04f2ab7ea9
- 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.
541 lines
17 KiB
TypeScript
541 lines
17 KiB
TypeScript
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>
|
|
);
|
|
}
|