Task #527: unify topbar into per-user Settings panel
Topbar right cluster collapses from 5–7 icons to exactly Bell -> Inbox
(conditional on canReceiveOrders) -> Settings (gear). Logout moved
inside the Settings panel; the second bell, globe, volume and clock
buttons are gone from the topbar.
New SettingsPanel renders as a Popover on md+ and a bottom Sheet on
<md (via new useMediaQuery hook). It composes existing controls
rather than re-implementing them:
- Language radio uses useUpdateLanguage + i18n.changeLanguage
- Notifications group renders extracted NotificationSettingsContent
- Sound group toggles notificationsMuted (same flag QuickMute used)
- Clock group renders extracted ClockStyleContent
All DB + localStorage persistence is preserved unchanged.
Refactors:
- notification-settings.tsx: extracted NotificationSettingsContent;
NotificationSettingsPicker now wraps it (kept for any future caller).
- clock-style-picker.tsx: extracted ClockStyleContent; ClockStylePicker
now wraps it; removed dead setOpen reference inside choose().
- home.tsx: removed Globe / QuickMute / NotificationSettingsPicker /
ClockStylePicker / standalone LogOut from the cluster, dropped
related imports + useUpdateLanguage, reordered to Bell -> Inbox ->
Settings, and passes onLogout to SettingsPanel.
i18n: added settingsPanel.{title, section.*, lang.*, sound.master}
keys to ar.json and en.json.
Code review: PASS.
This commit is contained in:
@@ -29,12 +29,18 @@ type Props = {
|
||||
currentHour12: boolean | null | undefined;
|
||||
};
|
||||
|
||||
export function ClockStylePicker({ currentStyle, currentHour12 }: Props) {
|
||||
/**
|
||||
* Inner content for the clock-style picker, extracted so the unified
|
||||
* Settings panel (Task #527) can render the same controls without
|
||||
* re-implementing the visibility toggles, hour-format switch, and
|
||||
* style list. `ClockStylePicker` below remains as a standalone
|
||||
* popover wrapper for any caller that wants a single icon-button.
|
||||
*/
|
||||
export function ClockStyleContent({ currentStyle, currentHour12 }: Props) {
|
||||
const { t, i18n: i18nInstance } = useTranslation();
|
||||
const lang = i18nInstance.language;
|
||||
const queryClient = useQueryClient();
|
||||
const now = useNow();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const updateClockStyle = useUpdateClockStyle();
|
||||
const updateClockHour12 = useUpdateClockHour12();
|
||||
@@ -66,7 +72,6 @@ export function ClockStylePicker({ currentStyle, currentHour12 }: Props) {
|
||||
},
|
||||
},
|
||||
);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const chooseHour12 = (next: boolean) => {
|
||||
@@ -96,18 +101,8 @@ export function ClockStylePicker({ currentStyle, currentHour12 }: Props) {
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("home.clockStyle.label")}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors p-1.5 rounded-lg hover:bg-foreground/5"
|
||||
>
|
||||
<ClockIcon size={18} />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-72 p-2" align="end">
|
||||
<button
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setHomeClockVisible(!homeClockVisible)}
|
||||
className="w-full flex items-center justify-between gap-3 px-2 py-2 rounded-lg text-start hover:bg-foreground/5 transition-colors mb-1"
|
||||
@@ -233,6 +228,26 @@ export function ClockStylePicker({ currentStyle, currentHour12 }: Props) {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ClockStylePicker(props: Props) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("home.clockStyle.label")}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors p-1.5 rounded-lg hover:bg-foreground/5"
|
||||
>
|
||||
<ClockIcon size={18} />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-72 p-2" align="end">
|
||||
<ClockStyleContent {...props} />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
|
||||
@@ -242,10 +242,17 @@ export function QuickMuteButton() {
|
||||
);
|
||||
}
|
||||
|
||||
export function NotificationSettingsPicker() {
|
||||
/**
|
||||
* Inner content of the notification settings popover, extracted so the
|
||||
* Settings panel (Task #527) can render the same form without
|
||||
* re-implementing the toggles, slot tabs, and sound picker. The
|
||||
* standalone `NotificationSettingsPicker` below wraps it in a popover
|
||||
* trigger + content; the unified Settings panel mounts this content
|
||||
* directly inside its own surface.
|
||||
*/
|
||||
export function NotificationSettingsContent() {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useAuth();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [slot, setSlot] = useState<Slot>("order");
|
||||
const apply = useUpdatePrefs();
|
||||
|
||||
@@ -253,6 +260,79 @@ export function NotificationSettingsPicker() {
|
||||
const muted = user.notificationsMuted;
|
||||
const cfg = SLOT_KEYS[slot];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<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")}
|
||||
/>
|
||||
|
||||
<div className="h-px bg-foreground/10 mx-1 my-1" />
|
||||
|
||||
<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>
|
||||
|
||||
<Toggle
|
||||
active={user[cfg.enabledKey]}
|
||||
onClick={() =>
|
||||
apply({ [cfg.enabledKey]: !user[cfg.enabledKey] } as PrefsPatch)
|
||||
}
|
||||
label={t(cfg.enabledLabel)}
|
||||
/>
|
||||
<Toggle
|
||||
active={user[cfg.vibrateKey]}
|
||||
onClick={() =>
|
||||
apply({ [cfg.vibrateKey]: !user[cfg.vibrateKey] } as PrefsPatch)
|
||||
}
|
||||
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 PrefsPatch)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function NotificationSettingsPicker() {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useAuth();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
if (!user) return null;
|
||||
const muted = user.notificationsMuted;
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
@@ -270,65 +350,7 @@ export function NotificationSettingsPicker() {
|
||||
</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")}
|
||||
/>
|
||||
|
||||
<div className="h-px bg-foreground/10 mx-1 my-1" />
|
||||
|
||||
<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>
|
||||
|
||||
<Toggle
|
||||
active={user[cfg.enabledKey]}
|
||||
onClick={() =>
|
||||
apply({ [cfg.enabledKey]: !user[cfg.enabledKey] } as PrefsPatch)
|
||||
}
|
||||
label={t(cfg.enabledLabel)}
|
||||
/>
|
||||
<Toggle
|
||||
active={user[cfg.vibrateKey]}
|
||||
onClick={() =>
|
||||
apply({ [cfg.vibrateKey]: !user[cfg.vibrateKey] } as PrefsPatch)
|
||||
}
|
||||
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 PrefsPatch)
|
||||
}
|
||||
/>
|
||||
<NotificationSettingsContent />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
|
||||
@@ -3,91 +3,35 @@ 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,
|
||||
LogOut,
|
||||
} 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";
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { useUpdatePrefs } from "@/components/notification-settings";
|
||||
import {
|
||||
NotificationSettingsContent,
|
||||
useUpdatePrefs,
|
||||
} from "@/components/notification-settings";
|
||||
import { ClockStyleContent } from "@/components/clock-style-picker";
|
||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||
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,
|
||||
@@ -203,110 +147,6 @@ function LanguageGroup() {
|
||||
);
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -332,194 +172,134 @@ function SoundGroup() {
|
||||
);
|
||||
}
|
||||
|
||||
function ClockGroup() {
|
||||
const { t, i18n: i18nInstance } = useTranslation();
|
||||
const lang = i18nInstance.language;
|
||||
const queryClient = useQueryClient();
|
||||
const now = useNow();
|
||||
/**
|
||||
* Body of the unified per-user Settings panel (Task #527). Composes
|
||||
* the existing form components from `notification-settings.tsx` and
|
||||
* `clock-style-picker.tsx` rather than re-implementing them, so all
|
||||
* persistence + behaviour stays identical to the topbar buttons that
|
||||
* used to expose them.
|
||||
*/
|
||||
function SettingsPanelBody({
|
||||
onLogout,
|
||||
}: {
|
||||
onLogout: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
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")}
|
||||
<div className="space-y-3">
|
||||
<LanguageGroup />
|
||||
<GroupCard>
|
||||
<SectionTitle>{t("settingsPanel.section.notifications")}</SectionTitle>
|
||||
<NotificationSettingsContent />
|
||||
</GroupCard>
|
||||
<SoundGroup />
|
||||
<GroupCard>
|
||||
<SectionTitle>{t("settingsPanel.section.clock")}</SectionTitle>
|
||||
<ClockStyleContent
|
||||
currentStyle={user.clockStyle ?? null}
|
||||
currentHour12={user.clockHour12 ?? null}
|
||||
/>
|
||||
</GroupCard>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onLogout}
|
||||
data-testid="settings-logout"
|
||||
className="w-full flex items-center justify-center gap-2 px-3 py-2.5 rounded-xl border border-destructive/30 text-destructive hover:bg-destructive/10 transition-colors text-sm font-medium"
|
||||
>
|
||||
{([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>
|
||||
<LogOut size={16} />
|
||||
{t("nav.logout")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
* Unified per-user Settings panel (Task #527).
|
||||
*
|
||||
* 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.
|
||||
* - Trigger: a single gear icon in the topbar that replaces the four
|
||||
* per-user preference buttons (language globe, quick-mute volume,
|
||||
* notification-settings bell, clock-style picker).
|
||||
* - Surface: an anchored Popover on `md:` (≥768px) viewports;
|
||||
* a bottom Sheet on smaller screens. Both render the same body so
|
||||
* behaviour and accessibility stay consistent.
|
||||
* - Body composes the existing form components rather than
|
||||
* re-implementing them, preserving DB + localStorage persistence.
|
||||
* - Logout lives inside the panel (kept off the topbar so the
|
||||
* right-side cluster shows exactly Bell, Inbox, Settings).
|
||||
*/
|
||||
export function SettingsPanel() {
|
||||
export function SettingsPanel({ onLogout }: { onLogout: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const { user } = useAuth();
|
||||
const isDesktop = useMediaQuery("(min-width: 768px)");
|
||||
if (!user) return null;
|
||||
const muted = user.notificationsMuted;
|
||||
|
||||
const trigger = (
|
||||
<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>
|
||||
);
|
||||
|
||||
const handleLogout = () => {
|
||||
setOpen(false);
|
||||
onLogout();
|
||||
};
|
||||
|
||||
if (isDesktop) {
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="end"
|
||||
className="w-96 p-3 max-h-[80vh] overflow-y-auto"
|
||||
data-testid="settings-panel"
|
||||
>
|
||||
<div className="px-1 pb-2 flex items-center gap-2 text-sm font-semibold text-foreground">
|
||||
<SettingsIcon size={16} />
|
||||
{t("settingsPanel.title")}
|
||||
</div>
|
||||
<SettingsPanelBody onLogout={handleLogout} />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
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 rounded-lg hover:bg-foreground/5 relative"
|
||||
>
|
||||
<SettingsIcon className="w-[18px] h-[18px]" />
|
||||
{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>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="w-full sm:max-w-md p-0 flex flex-col"
|
||||
side="bottom"
|
||||
className="h-[85vh] p-0 flex flex-col rounded-t-2xl"
|
||||
data-testid="settings-panel"
|
||||
>
|
||||
<SheetHeader className="px-4 pt-4 pb-2 shrink-0 border-b border-foreground/10">
|
||||
@@ -528,11 +308,8 @@ export function SettingsPanel() {
|
||||
{t("settingsPanel.title")}
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="flex-1 overflow-y-auto p-3 space-y-3">
|
||||
<LanguageGroup />
|
||||
<NotificationsGroup />
|
||||
<SoundGroup />
|
||||
<ClockGroup />
|
||||
<div className="flex-1 overflow-y-auto p-3">
|
||||
<SettingsPanelBody onLogout={handleLogout} />
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
/**
|
||||
* Subscribes to a CSS media query and returns whether it currently matches.
|
||||
* SSR-safe: returns `false` until mounted in the browser.
|
||||
*/
|
||||
export function useMediaQuery(query: string): boolean {
|
||||
const [matches, setMatches] = useState<boolean>(() => {
|
||||
if (typeof window === "undefined") return false;
|
||||
return window.matchMedia(query).matches;
|
||||
});
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const mql = window.matchMedia(query);
|
||||
const onChange = () => setMatches(mql.matches);
|
||||
onChange();
|
||||
mql.addEventListener("change", onChange);
|
||||
return () => mql.removeEventListener("change", onChange);
|
||||
}, [query]);
|
||||
return matches;
|
||||
}
|
||||
@@ -23,7 +23,6 @@ import { useIncomingNotePopup } from "@/contexts/IncomingNotePopupContext";
|
||||
import {
|
||||
Bell,
|
||||
Inbox,
|
||||
LogOut,
|
||||
Grid2X2,
|
||||
Coffee,
|
||||
BellRing,
|
||||
@@ -566,16 +565,29 @@ export default function HomePage() {
|
||||
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.
|
||||
Task #527: the topbar's right-side cluster previously hosted
|
||||
up to seven icon buttons (clock-style, quick-mute,
|
||||
notification-settings, language toggle, inbox, notifications
|
||||
bell, logout). Per-user UI preferences now live behind a
|
||||
single Settings (gear) entry that opens the unified panel,
|
||||
and logout has moved inside that panel. The cluster now
|
||||
shows exactly Bell → Inbox → Settings (Inbox is conditional
|
||||
on the user being able to receive incoming orders). The
|
||||
notification-center bell is the only bell on the page.
|
||||
*/}
|
||||
<div className="flex items-center gap-1 md:gap-2 shrink basis-0 flex-1 justify-end">
|
||||
<button
|
||||
onClick={() => setLocation("/notifications")}
|
||||
aria-label={t("notifications.title")}
|
||||
className="relative p-1.5 md:p-2.5 rounded-lg md:rounded-xl hover:bg-foreground/5 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<Bell className="w-[18px] h-[18px] md:w-6 md:h-6" />
|
||||
{unreadCount > 0 && (
|
||||
<span aria-hidden="true" className="absolute -top-0.5 -right-0.5 bg-destructive text-destructive-foreground text-[10px] md:text-xs rounded-full min-w-[16px] md:min-w-[20px] h-4 md:h-5 px-1 flex items-center justify-center font-bold tabular-nums">
|
||||
{unreadCount > 9 ? "9+" : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{canReceiveOrders && (
|
||||
<button
|
||||
onClick={() => setLocation("/orders/incoming")}
|
||||
@@ -590,24 +602,7 @@ export default function HomePage() {
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setLocation("/notifications")}
|
||||
className="relative p-1.5 md:p-2.5 rounded-lg md:rounded-xl hover:bg-foreground/5 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<Bell className="w-[18px] h-[18px] md:w-6 md:h-6" />
|
||||
{unreadCount > 0 && (
|
||||
<span aria-hidden="true" className="absolute -top-0.5 -right-0.5 bg-destructive text-destructive-foreground text-[10px] md:text-xs rounded-full min-w-[16px] md:min-w-[20px] h-4 md:h-5 px-1 flex items-center justify-center font-bold tabular-nums">
|
||||
{unreadCount > 9 ? "9+" : unreadCount}
|
||||
</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"
|
||||
>
|
||||
<LogOut className="w-[18px] h-[18px] md:w-6 md:h-6" />
|
||||
</button>
|
||||
<SettingsPanel onLogout={handleLogout} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user