@open-mercato/ui 0.6.6-develop.6314.1.c7b8291aa2 → 0.6.6-develop.6330.1.a261878aa8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,33 @@
1
+ "use client";
2
+ import { Fragment, jsx } from "react/jsx-runtime";
3
+ import * as React from "react";
4
+ import {
5
+ subscribeOrganizationScopeChanged
6
+ } from "@open-mercato/shared/lib/frontend/organizationEvents";
7
+ function OrganizationScopeBoundary({ active, children }) {
8
+ const [scopeKey, setScopeKey] = React.useState(0);
9
+ const lastScopeRef = React.useRef(null);
10
+ const hasInitializedScopeRef = React.useRef(false);
11
+ React.useEffect(() => {
12
+ return subscribeOrganizationScopeChanged((detail) => {
13
+ const prev = lastScopeRef.current;
14
+ lastScopeRef.current = detail;
15
+ if (!hasInitializedScopeRef.current) {
16
+ hasInitializedScopeRef.current = true;
17
+ return;
18
+ }
19
+ if (prev && prev.organizationId === detail.organizationId && prev.tenantId === detail.tenantId) {
20
+ return;
21
+ }
22
+ setScopeKey((current) => current + 1);
23
+ });
24
+ }, []);
25
+ if (!active) {
26
+ return /* @__PURE__ */ jsx(Fragment, { children });
27
+ }
28
+ return /* @__PURE__ */ jsx(React.Fragment, { children }, scopeKey);
29
+ }
30
+ export {
31
+ OrganizationScopeBoundary
32
+ };
33
+ //# sourceMappingURL=OrganizationScopeBoundary.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../src/backend/OrganizationScopeBoundary.tsx"],
4
+ "sourcesContent": ["'use client'\nimport * as React from 'react'\nimport {\n subscribeOrganizationScopeChanged,\n type OrganizationScopeChangedDetail,\n} from '@open-mercato/shared/lib/frontend/organizationEvents'\n\nexport type OrganizationScopeBoundaryProps = {\n active: boolean\n children: React.ReactNode\n}\n\n/**\n * Remounts its children whenever the active organization/tenant scope changes.\n *\n * The org/tenant switcher already calls `router.refresh()` on a scope change,\n * which re-runs server components but does NOT remount client components \u2014 so\n * settings pages that fetch their data once in a mount `useEffect` keep showing\n * the previous scope's values until a manual reload. Wrapping those pages here\n * forces a fresh mount (and therefore a refetch) on a real scope change.\n *\n * Gated by `active` so only the settings surface remounts; list/CRUD pages keep\n * relying on the smoother `router.refresh()` path that preserves table state.\n */\nexport function OrganizationScopeBoundary({ active, children }: OrganizationScopeBoundaryProps) {\n const [scopeKey, setScopeKey] = React.useState(0)\n const lastScopeRef = React.useRef<OrganizationScopeChangedDetail | null>(null)\n const hasInitializedScopeRef = React.useRef(false)\n\n React.useEffect(() => {\n return subscribeOrganizationScopeChanged((detail) => {\n const prev = lastScopeRef.current\n lastScopeRef.current = detail\n if (!hasInitializedScopeRef.current) {\n hasInitializedScopeRef.current = true\n return\n }\n if (\n prev &&\n prev.organizationId === detail.organizationId &&\n prev.tenantId === detail.tenantId\n ) {\n return\n }\n setScopeKey((current) => current + 1)\n })\n }, [])\n\n if (!active) {\n return <>{children}</>\n }\n\n return <React.Fragment key={scopeKey}>{children}</React.Fragment>\n}\n"],
5
+ "mappings": ";AAiDW;AAhDX,YAAY,WAAW;AACvB;AAAA,EACE;AAAA,OAEK;AAmBA,SAAS,0BAA0B,EAAE,QAAQ,SAAS,GAAmC;AAC9F,QAAM,CAAC,UAAU,WAAW,IAAI,MAAM,SAAS,CAAC;AAChD,QAAM,eAAe,MAAM,OAA8C,IAAI;AAC7E,QAAM,yBAAyB,MAAM,OAAO,KAAK;AAEjD,QAAM,UAAU,MAAM;AACpB,WAAO,kCAAkC,CAAC,WAAW;AACnD,YAAM,OAAO,aAAa;AAC1B,mBAAa,UAAU;AACvB,UAAI,CAAC,uBAAuB,SAAS;AACnC,+BAAuB,UAAU;AACjC;AAAA,MACF;AACA,UACE,QACA,KAAK,mBAAmB,OAAO,kBAC/B,KAAK,aAAa,OAAO,UACzB;AACA;AAAA,MACF;AACA,kBAAY,CAAC,YAAY,UAAU,CAAC;AAAA,IACtC,CAAC;AAAA,EACH,GAAG,CAAC,CAAC;AAEL,MAAI,CAAC,QAAQ;AACX,WAAO,gCAAG,UAAS;AAAA,EACrB;AAEA,SAAO,oBAAC,MAAM,UAAN,EAA+B,YAAX,QAAoB;AAClD;",
6
+ "names": []
7
+ }
@@ -6,6 +6,7 @@ import { ArrowDown, ArrowUp, Bell, Loader2, RotateCcw, Settings2, X } from "luci
6
6
  import { Button } from "../../primitives/button.js";
7
7
  import { IconButton } from "../../primitives/icon-button.js";
8
8
  import { Sheet, SheetContent, SheetTitle } from "../../primitives/sheet.js";
9
+ import { Tabs, TabsList, TabsTrigger } from "../../primitives/tabs.js";
9
10
  import { NotificationItem } from "./NotificationItem.js";
10
11
  function NotificationPanel({
11
12
  open,
@@ -43,6 +44,11 @@ function NotificationPanel({
43
44
  setMarkingAllRead(false);
44
45
  }
45
46
  };
47
+ const handleFilterChange = React.useCallback((next) => {
48
+ if (next === "all" || next === "unread" || next === "action") {
49
+ setFilter(next);
50
+ }
51
+ }, []);
46
52
  React.useEffect(() => {
47
53
  if (!open || typeof document === "undefined") return;
48
54
  const previous = document.body.style.overflow;
@@ -63,12 +69,14 @@ function NotificationPanel({
63
69
  /* @__PURE__ */ jsx(SheetTitle, { className: "text-base font-medium leading-6 tracking-tight text-foreground", children: t("notifications.title", "Notifications") }),
64
70
  /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-1", children: [
65
71
  unreadCount > 0 ? /* @__PURE__ */ jsxs(
66
- "button",
72
+ Button,
67
73
  {
68
74
  type: "button",
75
+ variant: "link",
76
+ size: "sm",
69
77
  onClick: handleMarkAllRead,
70
78
  disabled: markingAllRead,
71
- className: "inline-flex items-center gap-1 rounded-md px-1 text-sm font-medium leading-5 text-accent-indigo transition-colors hover:text-accent-indigo/80 disabled:opacity-50",
79
+ className: "gap-1 px-1 text-accent-indigo hover:text-accent-indigo/80 hover:no-underline",
72
80
  children: [
73
81
  markingAllRead ? /* @__PURE__ */ jsx(Loader2, { className: "size-3.5 animate-spin" }) : null,
74
82
  t("notifications.markAllRead", "Mark all read")
@@ -88,33 +96,11 @@ function NotificationPanel({
88
96
  )
89
97
  ] })
90
98
  ] }),
91
- /* @__PURE__ */ jsx("div", { role: "tablist", className: "flex items-center gap-5 border-b px-5 py-3.5", children: ["all", "unread", "action"].map((value) => {
92
- const isActive = filter === value;
99
+ /* @__PURE__ */ jsx(Tabs, { value: filter, onValueChange: handleFilterChange, variant: "underline", children: /* @__PURE__ */ jsx(TabsList, { className: "flex w-full px-5", children: ["all", "unread", "action"].map((value) => {
93
100
  const label = value === "all" ? t("notifications.filters.all", "All") : value === "unread" ? t("notifications.filters.unread", "Unread") : t("notifications.filters.actionRequired", "Action Required");
94
- return /* @__PURE__ */ jsxs(
95
- "button",
96
- {
97
- type: "button",
98
- role: "tab",
99
- "aria-selected": isActive,
100
- onClick: () => setFilter(value),
101
- className: "relative inline-flex items-center gap-1.5 pb-2 text-sm font-medium leading-5 tracking-tight transition-colors focus:outline-none focus-visible:text-foreground data-[active=true]:text-foreground data-[active=false]:text-muted-foreground hover:text-foreground",
102
- "data-active": isActive,
103
- children: [
104
- /* @__PURE__ */ jsx("span", { children: label }),
105
- value === "unread" && unreadCount > 0 ? /* @__PURE__ */ jsx("span", { className: "inline-flex min-w-4 items-center justify-center rounded-full bg-accent-indigo px-1 text-overline font-medium text-accent-indigo-foreground", children: unreadCount > 99 ? "99+" : unreadCount }) : null,
106
- isActive ? /* @__PURE__ */ jsx(
107
- "span",
108
- {
109
- className: "absolute bottom-[-14px] left-0 right-0 h-0.5 bg-foreground",
110
- "aria-hidden": "true"
111
- }
112
- ) : null
113
- ]
114
- },
115
- value
116
- );
117
- }) }),
101
+ const count = value === "unread" && unreadCount > 0 ? unreadCount > 99 ? "99+" : unreadCount : void 0;
102
+ return /* @__PURE__ */ jsx(TabsTrigger, { value, count, children: label }, value);
103
+ }) }) }),
118
104
  dismissUndo && onUndoDismiss && /* @__PURE__ */ jsx("div", { className: "border-b bg-muted/50 px-4 py-2 text-sm", children: /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between gap-3", children: [
119
105
  /* @__PURE__ */ jsx("span", { children: t("notifications.toast.dismissed", "Notification dismissed") }),
120
106
  /* @__PURE__ */ jsxs(Button, { variant: "ghost", size: "sm", onClick: () => onUndoDismiss(), children: [
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/backend/notifications/NotificationPanel.tsx"],
4
- "sourcesContent": ["\"use client\"\nimport * as React from 'react'\nimport Link from 'next/link'\nimport { ArrowDown, ArrowUp, Bell, Loader2, RotateCcw, Settings2, X } from 'lucide-react'\nimport { Button } from '../../primitives/button'\nimport { IconButton } from '../../primitives/icon-button'\nimport { Sheet, SheetContent, SheetTitle } from '../../primitives/sheet'\nimport { NotificationItem } from './NotificationItem'\nimport type { NotificationDto, NotificationRendererProps } from '@open-mercato/shared/modules/notifications/types'\nimport type { TranslateFn } from '@open-mercato/shared/lib/i18n/context'\nimport type { ComponentType } from 'react'\n\n/**\n * Map of notification type to custom renderer component.\n * Used to provide custom rendering for specific notification types.\n *\n * @example\n * ```tsx\n * const customRenderers = {\n * 'sales.order.created': SalesOrderCreatedRenderer,\n * 'sales.quote.created': SalesQuoteCreatedRenderer,\n * }\n * ```\n */\nexport type NotificationRenderers = Record<string, ComponentType<NotificationRendererProps>>\n\nexport type NotificationPanelProps = {\n open: boolean\n onOpenChange: (open: boolean) => void\n notifications: NotificationDto[]\n unreadCount: number\n onMarkAsRead: (id: string) => Promise<void>\n onExecuteAction: (id: string, actionId: string) => Promise<{ href?: string }>\n onDismiss: (id: string) => Promise<void>\n dismissUndo?: { notification: NotificationDto; previousStatus: 'read' | 'unread' } | null\n onUndoDismiss?: () => Promise<void>\n onMarkAllRead: () => Promise<void>\n t: TranslateFn\n /**\n * Optional map of notification type to custom renderer component.\n * When a notification's type matches a key in this map, the corresponding\n * renderer will be used instead of the default NotificationItem rendering.\n *\n * @example\n * ```tsx\n * import { salesNotificationTypes } from '@open-mercato/core/modules/sales/notifications.client'\n *\n * // Build renderers map from notification types\n * const renderers = Object.fromEntries(\n * salesNotificationTypes\n * .filter(t => t.Renderer)\n * .map(t => [t.type, t.Renderer!])\n * )\n *\n * <NotificationPanel customRenderers={renderers} ... />\n * ```\n */\n customRenderers?: NotificationRenderers\n}\n\nexport function NotificationPanel({\n open,\n onOpenChange,\n notifications,\n unreadCount,\n onMarkAsRead,\n onExecuteAction,\n onDismiss,\n dismissUndo,\n onUndoDismiss,\n onMarkAllRead,\n t,\n customRenderers,\n}: NotificationPanelProps) {\n const [filter, setFilter] = React.useState<'all' | 'unread' | 'action'>('all')\n const [markingAllRead, setMarkingAllRead] = React.useState(false)\n\n const filteredNotifications = React.useMemo(() => {\n switch (filter) {\n case 'unread':\n return notifications.filter((n) => n.status === 'unread')\n case 'action':\n return notifications.filter(\n (n) => n.actions && n.actions.length > 0 && n.status !== 'actioned'\n )\n default:\n return notifications\n }\n }, [notifications, filter])\n\n const handleMarkAllRead = async () => {\n setMarkingAllRead(true)\n try {\n await onMarkAllRead()\n } finally {\n setMarkingAllRead(false)\n }\n }\n\n // Preserve the body scroll lock contract that consumers (and integration\n // tests) rely on. Radix Dialog locks scroll via react-remove-scroll which\n // does not set `document.body.style.overflow` \u2014 we set it ourselves so the\n // contract is observable.\n React.useEffect(() => {\n if (!open || typeof document === 'undefined') return\n const previous = document.body.style.overflow\n document.body.style.overflow = 'hidden'\n return () => {\n document.body.style.overflow = previous\n }\n }, [open])\n\n return (\n <Sheet open={open} onOpenChange={onOpenChange}>\n <SheetContent\n side=\"right\"\n className=\"gap-0 p-0\"\n hideClose\n aria-label={t('notifications.title', 'Notifications')}\n >\n <div className=\"flex items-center justify-between gap-3 border-b px-5 py-4\">\n <SheetTitle className=\"text-base font-medium leading-6 tracking-tight text-foreground\">\n {t('notifications.title', 'Notifications')}\n </SheetTitle>\n <div className=\"flex items-center gap-1\">\n {unreadCount > 0 ? (\n <button\n type=\"button\"\n onClick={handleMarkAllRead}\n disabled={markingAllRead}\n className=\"inline-flex items-center gap-1 rounded-md px-1 text-sm font-medium leading-5 text-accent-indigo transition-colors hover:text-accent-indigo/80 disabled:opacity-50\"\n >\n {markingAllRead ? <Loader2 className=\"size-3.5 animate-spin\" /> : null}\n {t('notifications.markAllRead', 'Mark all read')}\n </button>\n ) : null}\n <IconButton\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n onClick={() => onOpenChange(false)}\n aria-label={t('notifications.close', 'Close notifications')}\n >\n <X className=\"size-4\" />\n </IconButton>\n </div>\n </div>\n\n <div role=\"tablist\" className=\"flex items-center gap-5 border-b px-5 py-3.5\">\n {(['all', 'unread', 'action'] as const).map((value) => {\n const isActive = filter === value\n const label =\n value === 'all'\n ? t('notifications.filters.all', 'All')\n : value === 'unread'\n ? t('notifications.filters.unread', 'Unread')\n : t('notifications.filters.actionRequired', 'Action Required')\n return (\n <button\n key={value}\n type=\"button\"\n role=\"tab\"\n aria-selected={isActive}\n onClick={() => setFilter(value)}\n className=\"relative inline-flex items-center gap-1.5 pb-2 text-sm font-medium leading-5 tracking-tight transition-colors focus:outline-none focus-visible:text-foreground data-[active=true]:text-foreground data-[active=false]:text-muted-foreground hover:text-foreground\"\n data-active={isActive}\n >\n <span>{label}</span>\n {value === 'unread' && unreadCount > 0 ? (\n <span className=\"inline-flex min-w-4 items-center justify-center rounded-full bg-accent-indigo px-1 text-overline font-medium text-accent-indigo-foreground\">\n {unreadCount > 99 ? '99+' : unreadCount}\n </span>\n ) : null}\n {isActive ? (\n <span\n className=\"absolute bottom-[-14px] left-0 right-0 h-0.5 bg-foreground\"\n aria-hidden=\"true\"\n />\n ) : null}\n </button>\n )\n })}\n </div>\n\n {dismissUndo && onUndoDismiss && (\n <div className=\"border-b bg-muted/50 px-4 py-2 text-sm\">\n <div className=\"flex items-center justify-between gap-3\">\n <span>\n {t('notifications.toast.dismissed', 'Notification dismissed')}\n </span>\n <Button variant=\"ghost\" size=\"sm\" onClick={() => onUndoDismiss()}>\n <RotateCcw className=\"mr-1 h-3 w-3\" />\n {t('notifications.actions.undo', 'Undo')}\n </Button>\n </div>\n </div>\n )}\n\n <div className=\"flex-1 overflow-y-auto overflow-x-hidden overscroll-contain\">\n {filteredNotifications.length === 0 ? (\n <div className=\"flex flex-col items-center justify-center gap-2 py-16 text-muted-foreground\">\n <div className=\"flex size-12 items-center justify-center rounded-full bg-muted/50\">\n <Bell className=\"size-5\" aria-hidden=\"true\" />\n </div>\n <p className=\"text-sm\">{t('notifications.empty', 'No notifications')}</p>\n </div>\n ) : (\n <div className=\"flex flex-col gap-1 p-2\">\n {filteredNotifications.map((notification, idx) => (\n <React.Fragment key={notification.id}>\n {idx > 0 ? <div className=\"my-px h-px bg-border\" aria-hidden=\"true\" /> : null}\n <NotificationItem\n notification={notification}\n onMarkAsRead={() => onMarkAsRead(notification.id)}\n onExecuteAction={(actionId) => onExecuteAction(notification.id, actionId)}\n onDismiss={() => onDismiss(notification.id)}\n t={t}\n customRenderer={customRenderers?.[notification.type]}\n />\n </React.Fragment>\n ))}\n </div>\n )}\n </div>\n\n <div className=\"flex items-center justify-between gap-3 border-t px-5 py-3.5 text-xs leading-4 text-muted-foreground\">\n <div className=\"flex items-center gap-2\">\n <span>{t('notifications.footer.useHint', 'Use')}</span>\n <span className=\"inline-flex items-center rounded border bg-muted/50 px-1 py-0.5\">\n <ArrowUp className=\"size-3\" aria-hidden=\"true\" />\n </span>\n <span className=\"inline-flex items-center rounded border bg-muted/50 px-1 py-0.5\">\n <ArrowDown className=\"size-3\" aria-hidden=\"true\" />\n </span>\n <span>{t('notifications.footer.toNavigate', 'to navigate')}</span>\n </div>\n <Link\n href=\"/backend/config/notifications\"\n onClick={() => onOpenChange(false)}\n className=\"inline-flex items-center gap-1.5 font-medium text-muted-foreground transition-colors hover:text-foreground\"\n >\n <Settings2 className=\"size-3.5\" aria-hidden=\"true\" />\n {t('notifications.footer.manage', 'Manage notifications')}\n </Link>\n </div>\n </SheetContent>\n </Sheet>\n )\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["\"use client\"\nimport * as React from 'react'\nimport Link from 'next/link'\nimport { ArrowDown, ArrowUp, Bell, Loader2, RotateCcw, Settings2, X } from 'lucide-react'\nimport { Button } from '../../primitives/button'\nimport { IconButton } from '../../primitives/icon-button'\nimport { Sheet, SheetContent, SheetTitle } from '../../primitives/sheet'\nimport { Tabs, TabsList, TabsTrigger } from '../../primitives/tabs'\nimport { NotificationItem } from './NotificationItem'\nimport type { NotificationDto, NotificationRendererProps } from '@open-mercato/shared/modules/notifications/types'\nimport type { TranslateFn } from '@open-mercato/shared/lib/i18n/context'\nimport type { ComponentType } from 'react'\n\n/**\n * Map of notification type to custom renderer component.\n * Used to provide custom rendering for specific notification types.\n *\n * @example\n * ```tsx\n * const customRenderers = {\n * 'sales.order.created': SalesOrderCreatedRenderer,\n * 'sales.quote.created': SalesQuoteCreatedRenderer,\n * }\n * ```\n */\nexport type NotificationRenderers = Record<string, ComponentType<NotificationRendererProps>>\n\nexport type NotificationPanelProps = {\n open: boolean\n onOpenChange: (open: boolean) => void\n notifications: NotificationDto[]\n unreadCount: number\n onMarkAsRead: (id: string) => Promise<void>\n onExecuteAction: (id: string, actionId: string) => Promise<{ href?: string }>\n onDismiss: (id: string) => Promise<void>\n dismissUndo?: { notification: NotificationDto; previousStatus: 'read' | 'unread' } | null\n onUndoDismiss?: () => Promise<void>\n onMarkAllRead: () => Promise<void>\n t: TranslateFn\n /**\n * Optional map of notification type to custom renderer component.\n * When a notification's type matches a key in this map, the corresponding\n * renderer will be used instead of the default NotificationItem rendering.\n *\n * @example\n * ```tsx\n * import { salesNotificationTypes } from '@open-mercato/core/modules/sales/notifications.client'\n *\n * // Build renderers map from notification types\n * const renderers = Object.fromEntries(\n * salesNotificationTypes\n * .filter(t => t.Renderer)\n * .map(t => [t.type, t.Renderer!])\n * )\n *\n * <NotificationPanel customRenderers={renderers} ... />\n * ```\n */\n customRenderers?: NotificationRenderers\n}\n\nexport function NotificationPanel({\n open,\n onOpenChange,\n notifications,\n unreadCount,\n onMarkAsRead,\n onExecuteAction,\n onDismiss,\n dismissUndo,\n onUndoDismiss,\n onMarkAllRead,\n t,\n customRenderers,\n}: NotificationPanelProps) {\n const [filter, setFilter] = React.useState<'all' | 'unread' | 'action'>('all')\n const [markingAllRead, setMarkingAllRead] = React.useState(false)\n\n const filteredNotifications = React.useMemo(() => {\n switch (filter) {\n case 'unread':\n return notifications.filter((n) => n.status === 'unread')\n case 'action':\n return notifications.filter(\n (n) => n.actions && n.actions.length > 0 && n.status !== 'actioned'\n )\n default:\n return notifications\n }\n }, [notifications, filter])\n\n const handleMarkAllRead = async () => {\n setMarkingAllRead(true)\n try {\n await onMarkAllRead()\n } finally {\n setMarkingAllRead(false)\n }\n }\n\n const handleFilterChange = React.useCallback((next: string) => {\n if (next === 'all' || next === 'unread' || next === 'action') {\n setFilter(next)\n }\n }, [])\n\n // Preserve the body scroll lock contract that consumers (and integration\n // tests) rely on. Radix Dialog locks scroll via react-remove-scroll which\n // does not set `document.body.style.overflow` \u2014 we set it ourselves so the\n // contract is observable.\n React.useEffect(() => {\n if (!open || typeof document === 'undefined') return\n const previous = document.body.style.overflow\n document.body.style.overflow = 'hidden'\n return () => {\n document.body.style.overflow = previous\n }\n }, [open])\n\n return (\n <Sheet open={open} onOpenChange={onOpenChange}>\n <SheetContent\n side=\"right\"\n className=\"gap-0 p-0\"\n hideClose\n aria-label={t('notifications.title', 'Notifications')}\n >\n <div className=\"flex items-center justify-between gap-3 border-b px-5 py-4\">\n <SheetTitle className=\"text-base font-medium leading-6 tracking-tight text-foreground\">\n {t('notifications.title', 'Notifications')}\n </SheetTitle>\n <div className=\"flex items-center gap-1\">\n {unreadCount > 0 ? (\n <Button\n type=\"button\"\n variant=\"link\"\n size=\"sm\"\n onClick={handleMarkAllRead}\n disabled={markingAllRead}\n className=\"gap-1 px-1 text-accent-indigo hover:text-accent-indigo/80 hover:no-underline\"\n >\n {markingAllRead ? <Loader2 className=\"size-3.5 animate-spin\" /> : null}\n {t('notifications.markAllRead', 'Mark all read')}\n </Button>\n ) : null}\n <IconButton\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n onClick={() => onOpenChange(false)}\n aria-label={t('notifications.close', 'Close notifications')}\n >\n <X className=\"size-4\" />\n </IconButton>\n </div>\n </div>\n\n <Tabs value={filter} onValueChange={handleFilterChange} variant=\"underline\">\n <TabsList className=\"flex w-full px-5\">\n {(['all', 'unread', 'action'] as const).map((value) => {\n const label =\n value === 'all'\n ? t('notifications.filters.all', 'All')\n : value === 'unread'\n ? t('notifications.filters.unread', 'Unread')\n : t('notifications.filters.actionRequired', 'Action Required')\n const count =\n value === 'unread' && unreadCount > 0\n ? unreadCount > 99\n ? '99+'\n : unreadCount\n : undefined\n return (\n <TabsTrigger key={value} value={value} count={count}>\n {label}\n </TabsTrigger>\n )\n })}\n </TabsList>\n </Tabs>\n\n {dismissUndo && onUndoDismiss && (\n <div className=\"border-b bg-muted/50 px-4 py-2 text-sm\">\n <div className=\"flex items-center justify-between gap-3\">\n <span>\n {t('notifications.toast.dismissed', 'Notification dismissed')}\n </span>\n <Button variant=\"ghost\" size=\"sm\" onClick={() => onUndoDismiss()}>\n <RotateCcw className=\"mr-1 h-3 w-3\" />\n {t('notifications.actions.undo', 'Undo')}\n </Button>\n </div>\n </div>\n )}\n\n <div className=\"flex-1 overflow-y-auto overflow-x-hidden overscroll-contain\">\n {filteredNotifications.length === 0 ? (\n <div className=\"flex flex-col items-center justify-center gap-2 py-16 text-muted-foreground\">\n <div className=\"flex size-12 items-center justify-center rounded-full bg-muted/50\">\n <Bell className=\"size-5\" aria-hidden=\"true\" />\n </div>\n <p className=\"text-sm\">{t('notifications.empty', 'No notifications')}</p>\n </div>\n ) : (\n <div className=\"flex flex-col gap-1 p-2\">\n {filteredNotifications.map((notification, idx) => (\n <React.Fragment key={notification.id}>\n {idx > 0 ? <div className=\"my-px h-px bg-border\" aria-hidden=\"true\" /> : null}\n <NotificationItem\n notification={notification}\n onMarkAsRead={() => onMarkAsRead(notification.id)}\n onExecuteAction={(actionId) => onExecuteAction(notification.id, actionId)}\n onDismiss={() => onDismiss(notification.id)}\n t={t}\n customRenderer={customRenderers?.[notification.type]}\n />\n </React.Fragment>\n ))}\n </div>\n )}\n </div>\n\n <div className=\"flex items-center justify-between gap-3 border-t px-5 py-3.5 text-xs leading-4 text-muted-foreground\">\n <div className=\"flex items-center gap-2\">\n <span>{t('notifications.footer.useHint', 'Use')}</span>\n <span className=\"inline-flex items-center rounded border bg-muted/50 px-1 py-0.5\">\n <ArrowUp className=\"size-3\" aria-hidden=\"true\" />\n </span>\n <span className=\"inline-flex items-center rounded border bg-muted/50 px-1 py-0.5\">\n <ArrowDown className=\"size-3\" aria-hidden=\"true\" />\n </span>\n <span>{t('notifications.footer.toNavigate', 'to navigate')}</span>\n </div>\n <Link\n href=\"/backend/config/notifications\"\n onClick={() => onOpenChange(false)}\n className=\"inline-flex items-center gap-1.5 font-medium text-muted-foreground transition-colors hover:text-foreground\"\n >\n <Settings2 className=\"size-3.5\" aria-hidden=\"true\" />\n {t('notifications.footer.manage', 'Manage notifications')}\n </Link>\n </div>\n </SheetContent>\n </Sheet>\n )\n}\n"],
5
+ "mappings": ";AAgIU,cAKI,YALJ;AA/HV,YAAY,WAAW;AACvB,OAAO,UAAU;AACjB,SAAS,WAAW,SAAS,MAAM,SAAS,WAAW,WAAW,SAAS;AAC3E,SAAS,cAAc;AACvB,SAAS,kBAAkB;AAC3B,SAAS,OAAO,cAAc,kBAAkB;AAChD,SAAS,MAAM,UAAU,mBAAmB;AAC5C,SAAS,wBAAwB;AAqD1B,SAAS,kBAAkB;AAAA,EAChC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,GAA2B;AACzB,QAAM,CAAC,QAAQ,SAAS,IAAI,MAAM,SAAsC,KAAK;AAC7E,QAAM,CAAC,gBAAgB,iBAAiB,IAAI,MAAM,SAAS,KAAK;AAEhE,QAAM,wBAAwB,MAAM,QAAQ,MAAM;AAChD,YAAQ,QAAQ;AAAA,MACd,KAAK;AACH,eAAO,cAAc,OAAO,CAAC,MAAM,EAAE,WAAW,QAAQ;AAAA,MAC1D,KAAK;AACH,eAAO,cAAc;AAAA,UACnB,CAAC,MAAM,EAAE,WAAW,EAAE,QAAQ,SAAS,KAAK,EAAE,WAAW;AAAA,QAC3D;AAAA,MACF;AACE,eAAO;AAAA,IACX;AAAA,EACF,GAAG,CAAC,eAAe,MAAM,CAAC;AAE1B,QAAM,oBAAoB,YAAY;AACpC,sBAAkB,IAAI;AACtB,QAAI;AACF,YAAM,cAAc;AAAA,IACtB,UAAE;AACA,wBAAkB,KAAK;AAAA,IACzB;AAAA,EACF;AAEA,QAAM,qBAAqB,MAAM,YAAY,CAAC,SAAiB;AAC7D,QAAI,SAAS,SAAS,SAAS,YAAY,SAAS,UAAU;AAC5D,gBAAU,IAAI;AAAA,IAChB;AAAA,EACF,GAAG,CAAC,CAAC;AAML,QAAM,UAAU,MAAM;AACpB,QAAI,CAAC,QAAQ,OAAO,aAAa,YAAa;AAC9C,UAAM,WAAW,SAAS,KAAK,MAAM;AACrC,aAAS,KAAK,MAAM,WAAW;AAC/B,WAAO,MAAM;AACX,eAAS,KAAK,MAAM,WAAW;AAAA,IACjC;AAAA,EACF,GAAG,CAAC,IAAI,CAAC;AAET,SACE,oBAAC,SAAM,MAAY,cACjB;AAAA,IAAC;AAAA;AAAA,MACC,MAAK;AAAA,MACL,WAAU;AAAA,MACV,WAAS;AAAA,MACT,cAAY,EAAE,uBAAuB,eAAe;AAAA,MAEpD;AAAA,6BAAC,SAAI,WAAU,8DACb;AAAA,8BAAC,cAAW,WAAU,kEACnB,YAAE,uBAAuB,eAAe,GAC3C;AAAA,UACA,qBAAC,SAAI,WAAU,2BACZ;AAAA,0BAAc,IACb;AAAA,cAAC;AAAA;AAAA,gBACC,MAAK;AAAA,gBACL,SAAQ;AAAA,gBACR,MAAK;AAAA,gBACL,SAAS;AAAA,gBACT,UAAU;AAAA,gBACV,WAAU;AAAA,gBAET;AAAA,mCAAiB,oBAAC,WAAQ,WAAU,yBAAwB,IAAK;AAAA,kBACjE,EAAE,6BAA6B,eAAe;AAAA;AAAA;AAAA,YACjD,IACE;AAAA,YACJ;AAAA,cAAC;AAAA;AAAA,gBACC,MAAK;AAAA,gBACL,SAAQ;AAAA,gBACR,MAAK;AAAA,gBACL,SAAS,MAAM,aAAa,KAAK;AAAA,gBACjC,cAAY,EAAE,uBAAuB,qBAAqB;AAAA,gBAE1D,8BAAC,KAAE,WAAU,UAAS;AAAA;AAAA,YACxB;AAAA,aACF;AAAA,WACF;AAAA,QAEA,oBAAC,QAAK,OAAO,QAAQ,eAAe,oBAAoB,SAAQ,aAC9D,8BAAC,YAAS,WAAU,oBAChB,WAAC,OAAO,UAAU,QAAQ,EAAY,IAAI,CAAC,UAAU;AACrD,gBAAM,QACJ,UAAU,QACN,EAAE,6BAA6B,KAAK,IACpC,UAAU,WACR,EAAE,gCAAgC,QAAQ,IAC1C,EAAE,wCAAwC,iBAAiB;AACnE,gBAAM,QACJ,UAAU,YAAY,cAAc,IAChC,cAAc,KACZ,QACA,cACF;AACN,iBACE,oBAAC,eAAwB,OAAc,OACpC,mBADe,KAElB;AAAA,QAEJ,CAAC,GACH,GACF;AAAA,QAEC,eAAe,iBACd,oBAAC,SAAI,WAAU,0CACb,+BAAC,SAAI,WAAU,2CACb;AAAA,8BAAC,UACE,YAAE,iCAAiC,wBAAwB,GAC9D;AAAA,UACA,qBAAC,UAAO,SAAQ,SAAQ,MAAK,MAAK,SAAS,MAAM,cAAc,GAC7D;AAAA,gCAAC,aAAU,WAAU,gBAAe;AAAA,YACnC,EAAE,8BAA8B,MAAM;AAAA,aACzC;AAAA,WACF,GACF;AAAA,QAGF,oBAAC,SAAI,WAAU,+DACZ,gCAAsB,WAAW,IAChC,qBAAC,SAAI,WAAU,+EACb;AAAA,8BAAC,SAAI,WAAU,qEACb,8BAAC,QAAK,WAAU,UAAS,eAAY,QAAO,GAC9C;AAAA,UACA,oBAAC,OAAE,WAAU,WAAW,YAAE,uBAAuB,kBAAkB,GAAE;AAAA,WACvE,IAEA,oBAAC,SAAI,WAAU,2BACZ,gCAAsB,IAAI,CAAC,cAAc,QACxC,qBAAC,MAAM,UAAN,EACE;AAAA,gBAAM,IAAI,oBAAC,SAAI,WAAU,wBAAuB,eAAY,QAAO,IAAK;AAAA,UACzE;AAAA,YAAC;AAAA;AAAA,cACC;AAAA,cACA,cAAc,MAAM,aAAa,aAAa,EAAE;AAAA,cAChD,iBAAiB,CAAC,aAAa,gBAAgB,aAAa,IAAI,QAAQ;AAAA,cACxE,WAAW,MAAM,UAAU,aAAa,EAAE;AAAA,cAC1C;AAAA,cACA,gBAAgB,kBAAkB,aAAa,IAAI;AAAA;AAAA,UACrD;AAAA,aATmB,aAAa,EAUlC,CACD,GACH,GAEJ;AAAA,QAEA,qBAAC,SAAI,WAAU,wGACb;AAAA,+BAAC,SAAI,WAAU,2BACb;AAAA,gCAAC,UAAM,YAAE,gCAAgC,KAAK,GAAE;AAAA,YAChD,oBAAC,UAAK,WAAU,mEACd,8BAAC,WAAQ,WAAU,UAAS,eAAY,QAAO,GACjD;AAAA,YACA,oBAAC,UAAK,WAAU,mEACd,8BAAC,aAAU,WAAU,UAAS,eAAY,QAAO,GACnD;AAAA,YACA,oBAAC,UAAM,YAAE,mCAAmC,aAAa,GAAE;AAAA,aAC7D;AAAA,UACA;AAAA,YAAC;AAAA;AAAA,cACC,MAAK;AAAA,cACL,SAAS,MAAM,aAAa,KAAK;AAAA,cACjC,WAAU;AAAA,cAEV;AAAA,oCAAC,aAAU,WAAU,YAAW,eAAY,QAAO;AAAA,gBAClD,EAAE,+BAA+B,sBAAsB;AAAA;AAAA;AAAA,UAC1D;AAAA,WACF;AAAA;AAAA;AAAA,EACF,GACF;AAEJ;",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/ui",
3
- "version": "0.6.6-develop.6314.1.c7b8291aa2",
3
+ "version": "0.6.6-develop.6330.1.a261878aa8",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -155,13 +155,13 @@
155
155
  "remark-gfm": "^4.0.1"
156
156
  },
157
157
  "peerDependencies": {
158
- "@open-mercato/shared": "0.6.6-develop.6314.1.c7b8291aa2",
158
+ "@open-mercato/shared": "0.6.6-develop.6330.1.a261878aa8",
159
159
  "react": ">=18.0.0",
160
160
  "react-dom": ">=18.0.0",
161
161
  "react-is": ">=18.0.0"
162
162
  },
163
163
  "devDependencies": {
164
- "@open-mercato/shared": "0.6.6-develop.6314.1.c7b8291aa2",
164
+ "@open-mercato/shared": "0.6.6-develop.6330.1.a261878aa8",
165
165
  "@testing-library/dom": "^10.4.1",
166
166
  "@testing-library/jest-dom": "^6.9.1",
167
167
  "@testing-library/react": "^16.3.1",
@@ -29,6 +29,7 @@ import { dismissRecordConflict } from './conflicts/store'
29
29
  import { ProgressTopBar } from './progress/ProgressTopBar'
30
30
  import { UpgradeActionBanner } from './upgrades/UpgradeActionBanner'
31
31
  import { PartialIndexBanner } from './indexes/PartialIndexBanner'
32
+ import { OrganizationScopeBoundary } from './OrganizationScopeBoundary'
32
33
  import { useLocale, useT } from '@open-mercato/shared/lib/i18n/context'
33
34
  import { slugifySidebarId } from '@open-mercato/shared/modules/navigation/sidebarPreferences'
34
35
  import { readVersionedPreference, writeVersionedPreference } from '@open-mercato/shared/lib/browser/versionedPreference'
@@ -1399,7 +1400,9 @@ function AppShellBody({ productName, logo, email, canManageUpgradeActions = fals
1399
1400
  context={injectionContext}
1400
1401
  />
1401
1402
  <div id="om-top-banners" className="mb-3 space-y-2" />
1402
- {children}
1403
+ <OrganizationScopeBoundary active={isOnSettingsPath}>
1404
+ {children}
1405
+ </OrganizationScopeBoundary>
1403
1406
  <InjectionSpot spotId={BACKEND_LAYOUT_FOOTER_INJECTION_SPOT_ID} context={injectionContext} />
1404
1407
  </main>
1405
1408
  <footer className="border-t bg-background/80 backdrop-blur supports-[backdrop-filter]:bg-background/80 px-4 py-3 flex flex-wrap items-center justify-end gap-4">
@@ -73,6 +73,7 @@ import {
73
73
  } from 'lucide-react'
74
74
  import { loadGeneratedFieldRegistrations } from './fields/registry'
75
75
  import type { CustomFieldDefDto, CustomFieldDefinitionsPayload, CustomFieldsetDto } from './utils/customFieldDefs'
76
+ import { isDefVisible } from './utils/customFieldDefs'
76
77
  import { buildFormFieldsFromCustomFields, buildFormFieldFromCustomFieldDef } from './utils/customFieldForms'
77
78
  import { useT } from '@open-mercato/shared/lib/i18n/context'
78
79
  import { TagsInput } from './inputs/TagsInput'
@@ -1553,7 +1554,9 @@ export function CrudForm<TValues extends Record<string, unknown>>({
1553
1554
  fieldsetGroupMap.set(group.code, { code: group.code, title: group.title, hint: group.hint })
1554
1555
  })
1555
1556
  }
1556
- const sortedDefs = [...defList].sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0))
1557
+ const sortedDefs = [...defList]
1558
+ .filter((definition) => isDefVisible(definition, 'form'))
1559
+ .sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0))
1557
1560
  const ensureBucket = (code: string | null, def: CustomFieldDefDto): CustomFieldGroupLayout => {
1558
1561
  const key = code ?? '__default__'
1559
1562
  let bucket = groupsMap.get(key)
@@ -0,0 +1,54 @@
1
+ 'use client'
2
+ import * as React from 'react'
3
+ import {
4
+ subscribeOrganizationScopeChanged,
5
+ type OrganizationScopeChangedDetail,
6
+ } from '@open-mercato/shared/lib/frontend/organizationEvents'
7
+
8
+ export type OrganizationScopeBoundaryProps = {
9
+ active: boolean
10
+ children: React.ReactNode
11
+ }
12
+
13
+ /**
14
+ * Remounts its children whenever the active organization/tenant scope changes.
15
+ *
16
+ * The org/tenant switcher already calls `router.refresh()` on a scope change,
17
+ * which re-runs server components but does NOT remount client components — so
18
+ * settings pages that fetch their data once in a mount `useEffect` keep showing
19
+ * the previous scope's values until a manual reload. Wrapping those pages here
20
+ * forces a fresh mount (and therefore a refetch) on a real scope change.
21
+ *
22
+ * Gated by `active` so only the settings surface remounts; list/CRUD pages keep
23
+ * relying on the smoother `router.refresh()` path that preserves table state.
24
+ */
25
+ export function OrganizationScopeBoundary({ active, children }: OrganizationScopeBoundaryProps) {
26
+ const [scopeKey, setScopeKey] = React.useState(0)
27
+ const lastScopeRef = React.useRef<OrganizationScopeChangedDetail | null>(null)
28
+ const hasInitializedScopeRef = React.useRef(false)
29
+
30
+ React.useEffect(() => {
31
+ return subscribeOrganizationScopeChanged((detail) => {
32
+ const prev = lastScopeRef.current
33
+ lastScopeRef.current = detail
34
+ if (!hasInitializedScopeRef.current) {
35
+ hasInitializedScopeRef.current = true
36
+ return
37
+ }
38
+ if (
39
+ prev &&
40
+ prev.organizationId === detail.organizationId &&
41
+ prev.tenantId === detail.tenantId
42
+ ) {
43
+ return
44
+ }
45
+ setScopeKey((current) => current + 1)
46
+ })
47
+ }, [])
48
+
49
+ if (!active) {
50
+ return <>{children}</>
51
+ }
52
+
53
+ return <React.Fragment key={scopeKey}>{children}</React.Fragment>
54
+ }
@@ -275,6 +275,65 @@ describe('CrudForm custom field loading', () => {
275
275
  expect(container.querySelector('[data-crud-field-id="cf_renewal_quarter"]')?.textContent).toContain('Q3')
276
276
  })
277
277
 
278
+ it('omits custom fields with formEditable:false from the generated custom-fields section', async () => {
279
+ buildFormFieldFromCustomFieldDefMock.mockImplementation((definition: any) => ({
280
+ id: `cf_${definition.key}`,
281
+ label: definition.label ?? definition.key,
282
+ type: 'text',
283
+ }))
284
+ fetchCustomFieldFormStructureMock.mockResolvedValue({
285
+ fields: [],
286
+ definitions: [
287
+ {
288
+ entityId: 'customers:customer_company_profile',
289
+ key: 'editable_note',
290
+ label: 'Editable note',
291
+ kind: 'text',
292
+ formEditable: true,
293
+ },
294
+ {
295
+ entityId: 'customers:customer_company_profile',
296
+ key: 'readonly_note',
297
+ label: 'Readonly note',
298
+ kind: 'text',
299
+ formEditable: false,
300
+ },
301
+ ],
302
+ metadata: {
303
+ items: [],
304
+ fieldsetsByEntity: {},
305
+ entitySettings: {},
306
+ },
307
+ })
308
+
309
+ const { container } = renderWithProviders(
310
+ <CrudForm
311
+ embedded
312
+ title="Form"
313
+ entityId="customers:customer_company_profile"
314
+ fields={fields}
315
+ groups={groups}
316
+ initialValues={{ name: 'Acme', editable_note: 'x', readonly_note: 'y' }}
317
+ onSubmit={() => {}}
318
+ />,
319
+ {
320
+ dict: {
321
+ 'ui.forms.actions.save': 'Save',
322
+ 'entities.customFields.manageFieldset': 'Manage fields',
323
+ },
324
+ },
325
+ )
326
+
327
+ await waitFor(() => {
328
+ expect(container.querySelector('[data-crud-field-id="cf_editable_note"]')).not.toBeNull()
329
+ })
330
+ expect(container.querySelector('[data-crud-field-id="cf_readonly_note"]')).toBeNull()
331
+ expect(buildFormFieldFromCustomFieldDefMock).not.toHaveBeenCalledWith(
332
+ expect.objectContaining({ key: 'readonly_note' }),
333
+ expect.anything(),
334
+ )
335
+ })
336
+
278
337
  it('submits custom entity values without loaded record metadata', async () => {
279
338
  const handleSubmit = jest.fn().mockResolvedValue(undefined)
280
339
 
@@ -0,0 +1,74 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+
5
+ import * as React from 'react'
6
+ import { act, render } from '@testing-library/react'
7
+ import { emitOrganizationScopeChanged } from '@open-mercato/shared/lib/frontend/organizationEvents'
8
+ import { OrganizationScopeBoundary } from '../OrganizationScopeBoundary'
9
+
10
+ let mountCount = 0
11
+
12
+ function MountCounter() {
13
+ React.useEffect(() => {
14
+ mountCount += 1
15
+ }, [])
16
+ return <span data-testid="child">child</span>
17
+ }
18
+
19
+ describe('<OrganizationScopeBoundary>', () => {
20
+ beforeEach(() => {
21
+ mountCount = 0
22
+ })
23
+
24
+ it('remounts children on a real scope change when active', () => {
25
+ render(
26
+ <OrganizationScopeBoundary active>
27
+ <MountCounter />
28
+ </OrganizationScopeBoundary>,
29
+ )
30
+ expect(mountCount).toBe(1)
31
+
32
+ // First scope event after mount only establishes the baseline.
33
+ act(() => {
34
+ emitOrganizationScopeChanged({ organizationId: 'org-a', tenantId: 'tenant-a' })
35
+ })
36
+ expect(mountCount).toBe(1)
37
+
38
+ // A genuine change remounts the subtree, re-running mount effects.
39
+ act(() => {
40
+ emitOrganizationScopeChanged({ organizationId: 'org-b', tenantId: 'tenant-a' })
41
+ })
42
+ expect(mountCount).toBe(2)
43
+ })
44
+
45
+ it('does not remount children when the scope is unchanged', () => {
46
+ render(
47
+ <OrganizationScopeBoundary active>
48
+ <MountCounter />
49
+ </OrganizationScopeBoundary>,
50
+ )
51
+ act(() => {
52
+ emitOrganizationScopeChanged({ organizationId: 'org-c', tenantId: 'tenant-c' })
53
+ })
54
+ act(() => {
55
+ emitOrganizationScopeChanged({ organizationId: 'org-c', tenantId: 'tenant-c' })
56
+ })
57
+ expect(mountCount).toBe(1)
58
+ })
59
+
60
+ it('does not remount children on a scope change when inactive', () => {
61
+ render(
62
+ <OrganizationScopeBoundary active={false}>
63
+ <MountCounter />
64
+ </OrganizationScopeBoundary>,
65
+ )
66
+ act(() => {
67
+ emitOrganizationScopeChanged({ organizationId: 'org-d', tenantId: 'tenant-d' })
68
+ })
69
+ act(() => {
70
+ emitOrganizationScopeChanged({ organizationId: 'org-e', tenantId: 'tenant-e' })
71
+ })
72
+ expect(mountCount).toBe(1)
73
+ })
74
+ })
@@ -5,6 +5,7 @@ import { ArrowDown, ArrowUp, Bell, Loader2, RotateCcw, Settings2, X } from 'luci
5
5
  import { Button } from '../../primitives/button'
6
6
  import { IconButton } from '../../primitives/icon-button'
7
7
  import { Sheet, SheetContent, SheetTitle } from '../../primitives/sheet'
8
+ import { Tabs, TabsList, TabsTrigger } from '../../primitives/tabs'
8
9
  import { NotificationItem } from './NotificationItem'
9
10
  import type { NotificationDto, NotificationRendererProps } from '@open-mercato/shared/modules/notifications/types'
10
11
  import type { TranslateFn } from '@open-mercato/shared/lib/i18n/context'
@@ -97,6 +98,12 @@ export function NotificationPanel({
97
98
  }
98
99
  }
99
100
 
101
+ const handleFilterChange = React.useCallback((next: string) => {
102
+ if (next === 'all' || next === 'unread' || next === 'action') {
103
+ setFilter(next)
104
+ }
105
+ }, [])
106
+
100
107
  // Preserve the body scroll lock contract that consumers (and integration
101
108
  // tests) rely on. Radix Dialog locks scroll via react-remove-scroll which
102
109
  // does not set `document.body.style.overflow` — we set it ourselves so the
@@ -124,15 +131,17 @@ export function NotificationPanel({
124
131
  </SheetTitle>
125
132
  <div className="flex items-center gap-1">
126
133
  {unreadCount > 0 ? (
127
- <button
134
+ <Button
128
135
  type="button"
136
+ variant="link"
137
+ size="sm"
129
138
  onClick={handleMarkAllRead}
130
139
  disabled={markingAllRead}
131
- className="inline-flex items-center gap-1 rounded-md px-1 text-sm font-medium leading-5 text-accent-indigo transition-colors hover:text-accent-indigo/80 disabled:opacity-50"
140
+ className="gap-1 px-1 text-accent-indigo hover:text-accent-indigo/80 hover:no-underline"
132
141
  >
133
142
  {markingAllRead ? <Loader2 className="size-3.5 animate-spin" /> : null}
134
143
  {t('notifications.markAllRead', 'Mark all read')}
135
- </button>
144
+ </Button>
136
145
  ) : null}
137
146
  <IconButton
138
147
  type="button"
@@ -146,41 +155,29 @@ export function NotificationPanel({
146
155
  </div>
147
156
  </div>
148
157
 
149
- <div role="tablist" className="flex items-center gap-5 border-b px-5 py-3.5">
150
- {(['all', 'unread', 'action'] as const).map((value) => {
151
- const isActive = filter === value
152
- const label =
153
- value === 'all'
154
- ? t('notifications.filters.all', 'All')
155
- : value === 'unread'
156
- ? t('notifications.filters.unread', 'Unread')
157
- : t('notifications.filters.actionRequired', 'Action Required')
158
- return (
159
- <button
160
- key={value}
161
- type="button"
162
- role="tab"
163
- aria-selected={isActive}
164
- onClick={() => setFilter(value)}
165
- className="relative inline-flex items-center gap-1.5 pb-2 text-sm font-medium leading-5 tracking-tight transition-colors focus:outline-none focus-visible:text-foreground data-[active=true]:text-foreground data-[active=false]:text-muted-foreground hover:text-foreground"
166
- data-active={isActive}
167
- >
168
- <span>{label}</span>
169
- {value === 'unread' && unreadCount > 0 ? (
170
- <span className="inline-flex min-w-4 items-center justify-center rounded-full bg-accent-indigo px-1 text-overline font-medium text-accent-indigo-foreground">
171
- {unreadCount > 99 ? '99+' : unreadCount}
172
- </span>
173
- ) : null}
174
- {isActive ? (
175
- <span
176
- className="absolute bottom-[-14px] left-0 right-0 h-0.5 bg-foreground"
177
- aria-hidden="true"
178
- />
179
- ) : null}
180
- </button>
181
- )
182
- })}
183
- </div>
158
+ <Tabs value={filter} onValueChange={handleFilterChange} variant="underline">
159
+ <TabsList className="flex w-full px-5">
160
+ {(['all', 'unread', 'action'] as const).map((value) => {
161
+ const label =
162
+ value === 'all'
163
+ ? t('notifications.filters.all', 'All')
164
+ : value === 'unread'
165
+ ? t('notifications.filters.unread', 'Unread')
166
+ : t('notifications.filters.actionRequired', 'Action Required')
167
+ const count =
168
+ value === 'unread' && unreadCount > 0
169
+ ? unreadCount > 99
170
+ ? '99+'
171
+ : unreadCount
172
+ : undefined
173
+ return (
174
+ <TabsTrigger key={value} value={value} count={count}>
175
+ {label}
176
+ </TabsTrigger>
177
+ )
178
+ })}
179
+ </TabsList>
180
+ </Tabs>
184
181
 
185
182
  {dismissUndo && onUndoDismiss && (
186
183
  <div className="border-b bg-muted/50 px-4 py-2 text-sm">