@open-mercato/ui 0.6.5-develop.5337.1.534b781eac → 0.6.5-develop.5382.1.f542de69af
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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/ai/AiChat.js.map +1 -1
- package/dist/ai/records/ActivityCard.js +1 -6
- package/dist/ai/records/ActivityCard.js.map +2 -2
- package/dist/ai/records/DealCard.js +2 -23
- package/dist/ai/records/DealCard.js.map +2 -2
- package/dist/ai/records/ProductCard.js +2 -17
- package/dist/ai/records/ProductCard.js.map +2 -2
- package/dist/ai/useAiChat.js.map +1 -1
- package/dist/backend/CrudForm.js.map +1 -1
- package/dist/backend/DataTable.js.map +1 -1
- package/dist/backend/ProfileDropdown.js +62 -33
- package/dist/backend/ProfileDropdown.js.map +2 -2
- package/dist/backend/injection/useGuardedMutation.js.map +1 -1
- package/dist/backend/utils/optimisticLock.js.map +1 -1
- package/dist/utils/format.js +27 -0
- package/dist/utils/format.js.map +7 -0
- package/package.json +7 -3
- package/src/ai/AiChat.tsx +1 -1
- package/src/ai/__tests__/AiChat.task-plan.test.tsx +1 -1
- package/src/ai/records/ActivityCard.tsx +1 -7
- package/src/ai/records/DealCard.tsx +2 -26
- package/src/ai/records/ProductCard.tsx +2 -19
- package/src/ai/useAiChat.ts +1 -1
- package/src/backend/CrudForm.tsx +1 -1
- package/src/backend/DataTable.tsx +2 -2
- package/src/backend/ProfileDropdown.tsx +35 -3
- package/src/backend/__tests__/CrudForm.optimisticLock.test.tsx +52 -0
- package/src/backend/__tests__/ProfileDropdown.test.tsx +59 -0
- package/src/backend/__tests__/lazy-heavy-libraries.test.ts +8 -0
- package/src/backend/injection/useGuardedMutation.ts +1 -1
- package/src/backend/utils/optimisticLock.ts +1 -1
- package/src/utils/__tests__/format.test.ts +58 -0
- package/src/utils/format.ts +40 -0
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
3
3
|
import * as React from "react";
|
|
4
|
+
import { createPortal } from "react-dom";
|
|
4
5
|
import Link from "next/link";
|
|
5
6
|
import { User, LogOut, Bell, Moon, Sun, Globe, Key, Check, ChevronRight } from "lucide-react";
|
|
6
7
|
import { useT, useLocale } from "@open-mercato/shared/lib/i18n/context";
|
|
@@ -14,6 +15,7 @@ import { mergeMenuItems } from "./injection/mergeMenuItems.js";
|
|
|
14
15
|
import { resolveInjectedIcon } from "./injection/resolveInjectedIcon.js";
|
|
15
16
|
import { InjectionSpot } from "./injection/InjectionSpot.js";
|
|
16
17
|
import { BACKEND_TOPBAR_PROFILE_MENU_INJECTION_SPOT_ID } from "./injection/spotIds.js";
|
|
18
|
+
const useIsomorphicLayoutEffect = typeof window !== "undefined" ? React.useLayoutEffect : React.useEffect;
|
|
17
19
|
const localeLabels = {
|
|
18
20
|
en: "English",
|
|
19
21
|
de: "Deutsch",
|
|
@@ -34,10 +36,33 @@ function ProfileDropdown({
|
|
|
34
36
|
const [mounted, setMounted] = React.useState(false);
|
|
35
37
|
const buttonRef = React.useRef(null);
|
|
36
38
|
const menuRef = React.useRef(null);
|
|
39
|
+
const [menuPosition, setMenuPosition] = React.useState(null);
|
|
37
40
|
const { items: injectedItems } = useInjectedMenuItems("menu:topbar:profile-dropdown");
|
|
38
41
|
React.useEffect(() => {
|
|
39
42
|
setMounted(true);
|
|
40
43
|
}, []);
|
|
44
|
+
useIsomorphicLayoutEffect(() => {
|
|
45
|
+
if (!open) {
|
|
46
|
+
setMenuPosition(null);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const updatePosition = () => {
|
|
50
|
+
const trigger = buttonRef.current;
|
|
51
|
+
if (!trigger) return;
|
|
52
|
+
const rect = trigger.getBoundingClientRect();
|
|
53
|
+
setMenuPosition({
|
|
54
|
+
top: rect.bottom + 8,
|
|
55
|
+
right: Math.max(0, window.innerWidth - rect.right)
|
|
56
|
+
});
|
|
57
|
+
};
|
|
58
|
+
updatePosition();
|
|
59
|
+
window.addEventListener("resize", updatePosition);
|
|
60
|
+
window.addEventListener("scroll", updatePosition, true);
|
|
61
|
+
return () => {
|
|
62
|
+
window.removeEventListener("resize", updatePosition);
|
|
63
|
+
window.removeEventListener("scroll", updatePosition, true);
|
|
64
|
+
};
|
|
65
|
+
}, [open]);
|
|
41
66
|
const isDark = resolvedTheme === "dark";
|
|
42
67
|
React.useEffect(() => {
|
|
43
68
|
if (!open) return;
|
|
@@ -312,40 +337,44 @@ function ProfileDropdown({
|
|
|
312
337
|
children: /* @__PURE__ */ jsx(User, { className: "size-4" })
|
|
313
338
|
}
|
|
314
339
|
),
|
|
315
|
-
open &&
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
/* @__PURE__ */ jsxs("div", { className: "flex
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
"truncate text-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
340
|
+
open && menuPosition && typeof document !== "undefined" && createPortal(
|
|
341
|
+
/* @__PURE__ */ jsxs(
|
|
342
|
+
"div",
|
|
343
|
+
{
|
|
344
|
+
ref: menuRef,
|
|
345
|
+
className: "fixed z-popover w-64 overflow-hidden rounded-lg border bg-popover p-0 shadow-lg",
|
|
346
|
+
style: { top: menuPosition.top, right: menuPosition.right },
|
|
347
|
+
role: "menu",
|
|
348
|
+
"data-testid": "profile-dropdown",
|
|
349
|
+
children: [
|
|
350
|
+
(displayName || email) && /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-3 border-b bg-muted/30 px-3 py-3", children: [
|
|
351
|
+
/* @__PURE__ */ jsx("div", { className: "flex size-9 shrink-0 items-center justify-center rounded-full bg-accent-indigo/10 text-accent-indigo", children: /* @__PURE__ */ jsx(User, { className: "size-4", "aria-hidden": "true" }) }),
|
|
352
|
+
/* @__PURE__ */ jsxs("div", { className: "flex min-w-0 flex-1 flex-col", children: [
|
|
353
|
+
displayName ? /* @__PURE__ */ jsx("span", { className: "truncate text-sm font-medium leading-5 text-foreground", children: displayName }) : null,
|
|
354
|
+
email ? /* @__PURE__ */ jsx("span", { className: cn(
|
|
355
|
+
"truncate text-xs leading-4",
|
|
356
|
+
displayName ? "text-muted-foreground" : "text-foreground font-medium"
|
|
357
|
+
), children: email }) : null,
|
|
358
|
+
!displayName && email ? /* @__PURE__ */ jsx("span", { className: "truncate text-overline uppercase tracking-wider text-muted-foreground/80", children: t("ui.userMenu.loggedInAs", "Logged in") }) : null
|
|
359
|
+
] })
|
|
360
|
+
] }),
|
|
361
|
+
/* @__PURE__ */ jsxs("div", { className: "flex flex-col p-1.5", children: [
|
|
362
|
+
mergedMenuItems.map((item) => /* @__PURE__ */ jsxs(React.Fragment, { children: [
|
|
363
|
+
item.separator ? /* @__PURE__ */ jsx("div", { className: "my-1 h-px bg-border", "aria-hidden": "true" }) : null,
|
|
364
|
+
item.source === "injected" ? item.href || item.onClick || item.label || item.labelKey ? renderInjectedItem(item) : null : renderBuiltInItem(item.id)
|
|
365
|
+
] }, item.id)),
|
|
366
|
+
/* @__PURE__ */ jsx(
|
|
367
|
+
InjectionSpot,
|
|
368
|
+
{
|
|
369
|
+
spotId: BACKEND_TOPBAR_PROFILE_MENU_INJECTION_SPOT_ID,
|
|
370
|
+
context: injectionContext
|
|
371
|
+
}
|
|
372
|
+
)
|
|
332
373
|
] })
|
|
333
|
-
]
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
item.source === "injected" ? item.href || item.onClick || item.label || item.labelKey ? renderInjectedItem(item) : null : renderBuiltInItem(item.id)
|
|
338
|
-
] }, item.id)),
|
|
339
|
-
/* @__PURE__ */ jsx(
|
|
340
|
-
InjectionSpot,
|
|
341
|
-
{
|
|
342
|
-
spotId: BACKEND_TOPBAR_PROFILE_MENU_INJECTION_SPOT_ID,
|
|
343
|
-
context: injectionContext
|
|
344
|
-
}
|
|
345
|
-
)
|
|
346
|
-
] })
|
|
347
|
-
]
|
|
348
|
-
}
|
|
374
|
+
]
|
|
375
|
+
}
|
|
376
|
+
),
|
|
377
|
+
document.body
|
|
349
378
|
)
|
|
350
379
|
] });
|
|
351
380
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/backend/ProfileDropdown.tsx"],
|
|
4
|
-
"sourcesContent": ["'use client'\nimport * as React from 'react'\nimport Link from 'next/link'\nimport { User, LogOut, Bell, Moon, Sun, Globe, Key, Check, ChevronRight } from 'lucide-react'\nimport { useT, useLocale } from '@open-mercato/shared/lib/i18n/context'\nimport { locales, type Locale } from '@open-mercato/shared/lib/i18n/config'\nimport { useTheme } from '@open-mercato/ui/theme'\nimport { cn } from '@open-mercato/shared/lib/utils'\nimport { IconButton } from '../primitives/icon-button'\nimport { Switch } from '../primitives/switch'\nimport { useInjectedMenuItems } from './injection/useInjectedMenuItems'\nimport { mergeMenuItems, type MergedMenuItem } from './injection/mergeMenuItems'\nimport { resolveInjectedIcon } from './injection/resolveInjectedIcon'\nimport { InjectionSpot } from './injection/InjectionSpot'\nimport { BACKEND_TOPBAR_PROFILE_MENU_INJECTION_SPOT_ID } from './injection/spotIds'\n\nexport type ProfileDropdownProps = {\n email?: string\n displayName?: string\n changePasswordHref?: string\n notificationsHref?: string\n}\n\nconst localeLabels: Record<Locale, string> = {\n en: 'English',\n de: 'Deutsch',\n es: 'Espa\u00F1ol',\n pl: 'Polski',\n}\n\nexport function ProfileDropdown({\n email,\n displayName,\n changePasswordHref = '/backend/profile/change-password',\n notificationsHref,\n}: ProfileDropdownProps) {\n const t = useT()\n const currentLocale = useLocale()\n const { resolvedTheme, setTheme } = useTheme()\n const [open, setOpen] = React.useState(false)\n const [languageOpen, setLanguageOpen] = React.useState(false)\n const [mounted, setMounted] = React.useState(false)\n const buttonRef = React.useRef<HTMLButtonElement>(null)\n const menuRef = React.useRef<HTMLDivElement>(null)\n const { items: injectedItems } = useInjectedMenuItems('menu:topbar:profile-dropdown')\n\n React.useEffect(() => {\n setMounted(true)\n }, [])\n\n const isDark = resolvedTheme === 'dark'\n\n // Close on click outside\n React.useEffect(() => {\n if (!open) return\n function handleClick(event: MouseEvent) {\n if (\n menuRef.current &&\n !menuRef.current.contains(event.target as Node) &&\n buttonRef.current &&\n !buttonRef.current.contains(event.target as Node)\n ) {\n setOpen(false)\n setLanguageOpen(false)\n }\n }\n document.addEventListener('mousedown', handleClick)\n return () => document.removeEventListener('mousedown', handleClick)\n }, [open])\n\n // Close on Escape\n React.useEffect(() => {\n if (!open) return\n function handleKeyDown(event: KeyboardEvent) {\n if (event.key === 'Escape') {\n if (languageOpen) {\n setLanguageOpen(false)\n } else {\n setOpen(false)\n buttonRef.current?.focus()\n }\n }\n }\n document.addEventListener('keydown', handleKeyDown)\n return () => document.removeEventListener('keydown', handleKeyDown)\n }, [open, languageOpen])\n\n const handleThemeToggle = () => {\n setTheme(isDark ? 'light' : 'dark')\n }\n\n const handleLocaleChange = async (locale: Locale) => {\n try {\n await fetch('/api/auth/locale', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ locale }),\n })\n window.location.reload()\n } catch {}\n }\n\n // Unified row class \u2014 every menu item uses this for perfectly aligned layout.\n // h-9 keeps all rows the same height regardless of whether they have a trailing element.\n const menuItemClass = cn(\n 'group flex h-9 w-full items-center gap-3 rounded-md px-2.5 text-sm text-foreground',\n 'transition-colors hover:bg-muted/60 focus:outline-none focus-visible:bg-muted/60',\n 'cursor-pointer',\n )\n const menuIconClass = 'size-4 shrink-0 text-muted-foreground group-hover:text-foreground'\n\n const resolveMenuLabel = React.useCallback(\n (item: Pick<MergedMenuItem, 'id' | 'label' | 'labelKey'>): string => {\n if (item.labelKey && item.label) return t(item.labelKey, item.label)\n if (item.labelKey) return t(item.labelKey, item.id)\n if (item.label && item.label.includes('.')) return t(item.label, item.id)\n return item.label ?? item.id\n },\n [t],\n )\n\n const builtInMenuItems = React.useMemo(\n () => {\n const items: Array<{ id: string; separator?: boolean }> = [{ id: 'change-password' }]\n if (notificationsHref) items.push({ id: 'notifications' })\n items.push({ id: 'theme-toggle', separator: true }, { id: 'language' }, { id: 'sign-out', separator: true })\n return items\n },\n [notificationsHref],\n )\n\n const mergedMenuItems = React.useMemo(\n () => mergeMenuItems(builtInMenuItems, injectedItems),\n [builtInMenuItems, injectedItems],\n )\n const injectionContext = React.useMemo(\n () => ({\n email,\n displayName,\n locale: currentLocale,\n }),\n [currentLocale, displayName, email],\n )\n\n const renderInjectedItem = React.useCallback(\n (item: MergedMenuItem) => {\n const label = resolveMenuLabel(item)\n const icon = resolveInjectedIcon(item.icon)\n const inner = (\n <>\n <span className={menuIconClass}>{icon}</span>\n <span className=\"flex-1 truncate\">{label}</span>\n </>\n )\n if (item.href) {\n return (\n <Link\n key={item.id}\n href={item.href}\n className={menuItemClass}\n role=\"menuitem\"\n data-menu-item-id={item.id}\n onClick={() => setOpen(false)}\n >\n {inner}\n </Link>\n )\n }\n return (\n <button\n key={item.id}\n type=\"button\"\n className={menuItemClass}\n role=\"menuitem\"\n data-menu-item-id={item.id}\n onClick={() => {\n item.onClick?.()\n setOpen(false)\n }}\n >\n {inner}\n </button>\n )\n },\n [menuItemClass, menuIconClass, resolveMenuLabel],\n )\n\n const renderBuiltInItem = React.useCallback(\n (id: string) => {\n if (id === 'change-password') {\n return (\n <Link\n key={id}\n href={changePasswordHref}\n className={menuItemClass}\n role=\"menuitem\"\n onClick={() => setOpen(false)}\n >\n <Key className={menuIconClass} />\n <span className=\"flex-1 truncate\">{t('ui.profileMenu.changePassword', 'Change Password')}</span>\n </Link>\n )\n }\n\n if (id === 'notifications' && notificationsHref) {\n return (\n <Link\n key={id}\n href={notificationsHref}\n className={menuItemClass}\n role=\"menuitem\"\n onClick={() => setOpen(false)}\n >\n <Bell className={menuIconClass} />\n <span className=\"flex-1 truncate\">{t('ui.profileMenu.notifications', 'Notification Preferences')}</span>\n </Link>\n )\n }\n\n if (id === 'theme-toggle') {\n if (!mounted) return null\n return (\n <div\n key={id}\n className={cn(menuItemClass, 'justify-between')}\n role=\"menuitem\"\n onClick={handleThemeToggle}\n onKeyDown={(e) => {\n if (e.key === 'Enter' || e.key === ' ') {\n e.preventDefault()\n handleThemeToggle()\n }\n }}\n tabIndex={0}\n >\n <span className=\"flex flex-1 items-center gap-3\">\n {isDark ? <Moon className={menuIconClass} /> : <Sun className={menuIconClass} />}\n <span className=\"flex-1 truncate\">{t('ui.profileMenu.theme', 'Theme')}</span>\n </span>\n <Switch\n checked={isDark}\n onCheckedChange={handleThemeToggle}\n aria-label={t('ui.profileMenu.theme', 'Theme')}\n onClick={(e) => e.stopPropagation()}\n />\n </div>\n )\n }\n\n if (id === 'language') {\n return (\n <div key={id} className=\"contents\">\n <button\n type=\"button\"\n className={menuItemClass}\n role=\"menuitem\"\n onClick={() => setLanguageOpen(!languageOpen)}\n aria-expanded={languageOpen}\n >\n <Globe className={menuIconClass} />\n <span className=\"truncate\">{t('ui.profileMenu.language', 'Language')}</span>\n <span className=\"text-xs text-muted-foreground\">{localeLabels[currentLocale]}</span>\n <ChevronRight\n className={cn(\n 'ml-auto size-3.5 shrink-0 text-muted-foreground transition-transform',\n languageOpen && 'rotate-90',\n )}\n aria-hidden=\"true\"\n />\n </button>\n {languageOpen && (\n <div className=\"ml-7 mr-1 flex flex-col gap-0.5 border-l pl-2 py-1\">\n {locales.map((locale) => (\n <button\n key={locale}\n type=\"button\"\n className={cn(\n 'flex h-8 w-full items-center justify-between gap-2 rounded-md px-2 text-sm transition-colors hover:bg-muted/60 focus:outline-none focus-visible:bg-muted/60',\n locale === currentLocale ? 'font-medium text-foreground' : 'text-muted-foreground',\n )}\n onClick={() => handleLocaleChange(locale)}\n >\n <span className=\"truncate\">{localeLabels[locale]}</span>\n {locale === currentLocale && <Check className=\"size-3.5 shrink-0 text-accent-indigo\" aria-hidden=\"true\" />}\n </button>\n ))}\n </div>\n )}\n </div>\n )\n }\n\n if (id === 'sign-out') {\n return (\n <form key={id} action=\"/api/auth/logout\" method=\"POST\">\n <button\n type=\"submit\"\n className={menuItemClass}\n role=\"menuitem\"\n >\n <LogOut className={menuIconClass} />\n <span className=\"flex-1 truncate text-left\">{t('ui.userMenu.logout', 'Sign Out')}</span>\n </button>\n </form>\n )\n }\n\n return null\n },\n [\n changePasswordHref,\n currentLocale,\n handleThemeToggle,\n isDark,\n languageOpen,\n menuItemClass,\n menuIconClass,\n mounted,\n notificationsHref,\n t,\n ],\n )\n\n return (\n <div className=\"relative\">\n <IconButton\n ref={buttonRef}\n variant=\"ghost\"\n size=\"sm\"\n onClick={() => setOpen(!open)}\n aria-expanded={open}\n aria-haspopup=\"menu\"\n data-testid=\"profile-dropdown-trigger\"\n title={email || t('ui.userMenu.userFallback', 'User')}\n >\n <User className=\"size-4\" />\n </IconButton>\n\n {open && (\n <div\n ref={menuRef}\n className=\"absolute right-0 top-full z-popover mt-2 w-64 overflow-hidden rounded-lg border bg-popover p-0 shadow-lg\"\n role=\"menu\"\n data-testid=\"profile-dropdown\"\n >\n {/* User info header */}\n {(displayName || email) && (\n <div className=\"flex items-center gap-3 border-b bg-muted/30 px-3 py-3\">\n <div className=\"flex size-9 shrink-0 items-center justify-center rounded-full bg-accent-indigo/10 text-accent-indigo\">\n <User className=\"size-4\" aria-hidden=\"true\" />\n </div>\n <div className=\"flex min-w-0 flex-1 flex-col\">\n {displayName ? (\n <span className=\"truncate text-sm font-medium leading-5 text-foreground\">{displayName}</span>\n ) : null}\n {email ? (\n <span className={cn(\n 'truncate text-xs leading-4',\n displayName ? 'text-muted-foreground' : 'text-foreground font-medium',\n )}>\n {email}\n </span>\n ) : null}\n {!displayName && email ? (\n <span className=\"truncate text-overline uppercase tracking-wider text-muted-foreground/80\">\n {t('ui.userMenu.loggedInAs', 'Logged in')}\n </span>\n ) : null}\n </div>\n </div>\n )}\n\n <div className=\"flex flex-col p-1.5\">\n {mergedMenuItems.map((item) => (\n <React.Fragment key={item.id}>\n {item.separator ? <div className=\"my-1 h-px bg-border\" aria-hidden=\"true\" /> : null}\n {item.source === 'injected'\n ? (item.href || item.onClick || item.label || item.labelKey ? renderInjectedItem(item) : null)\n : renderBuiltInItem(item.id)}\n </React.Fragment>\n ))}\n <InjectionSpot\n spotId={BACKEND_TOPBAR_PROFILE_MENU_INJECTION_SPOT_ID}\n context={injectionContext}\n />\n </div>\n </div>\n )}\n </div>\n )\n}\n"],
|
|
5
|
-
"mappings": ";
|
|
4
|
+
"sourcesContent": ["'use client'\nimport * as React from 'react'\nimport { createPortal } from 'react-dom'\nimport Link from 'next/link'\nimport { User, LogOut, Bell, Moon, Sun, Globe, Key, Check, ChevronRight } from 'lucide-react'\nimport { useT, useLocale } from '@open-mercato/shared/lib/i18n/context'\nimport { locales, type Locale } from '@open-mercato/shared/lib/i18n/config'\nimport { useTheme } from '@open-mercato/ui/theme'\nimport { cn } from '@open-mercato/shared/lib/utils'\nimport { IconButton } from '../primitives/icon-button'\nimport { Switch } from '../primitives/switch'\nimport { useInjectedMenuItems } from './injection/useInjectedMenuItems'\nimport { mergeMenuItems, type MergedMenuItem } from './injection/mergeMenuItems'\nimport { resolveInjectedIcon } from './injection/resolveInjectedIcon'\nimport { InjectionSpot } from './injection/InjectionSpot'\nimport { BACKEND_TOPBAR_PROFILE_MENU_INJECTION_SPOT_ID } from './injection/spotIds'\n\nexport type ProfileDropdownProps = {\n email?: string\n displayName?: string\n changePasswordHref?: string\n notificationsHref?: string\n}\n\nconst useIsomorphicLayoutEffect = typeof window !== 'undefined' ? React.useLayoutEffect : React.useEffect\n\nconst localeLabels: Record<Locale, string> = {\n en: 'English',\n de: 'Deutsch',\n es: 'Espa\u00F1ol',\n pl: 'Polski',\n}\n\nexport function ProfileDropdown({\n email,\n displayName,\n changePasswordHref = '/backend/profile/change-password',\n notificationsHref,\n}: ProfileDropdownProps) {\n const t = useT()\n const currentLocale = useLocale()\n const { resolvedTheme, setTheme } = useTheme()\n const [open, setOpen] = React.useState(false)\n const [languageOpen, setLanguageOpen] = React.useState(false)\n const [mounted, setMounted] = React.useState(false)\n const buttonRef = React.useRef<HTMLButtonElement>(null)\n const menuRef = React.useRef<HTMLDivElement>(null)\n const [menuPosition, setMenuPosition] = React.useState<{ top: number; right: number } | null>(null)\n const { items: injectedItems } = useInjectedMenuItems('menu:topbar:profile-dropdown')\n\n React.useEffect(() => {\n setMounted(true)\n }, [])\n\n // Position the portaled menu under the trigger. Rendering the menu in a body\n // portal keeps it out of the sticky header's backdrop-blur stacking context so\n // it always paints above page content such as the DataTable sticky Actions column.\n useIsomorphicLayoutEffect(() => {\n if (!open) {\n setMenuPosition(null)\n return\n }\n const updatePosition = () => {\n const trigger = buttonRef.current\n if (!trigger) return\n const rect = trigger.getBoundingClientRect()\n setMenuPosition({\n top: rect.bottom + 8,\n right: Math.max(0, window.innerWidth - rect.right),\n })\n }\n updatePosition()\n window.addEventListener('resize', updatePosition)\n window.addEventListener('scroll', updatePosition, true)\n return () => {\n window.removeEventListener('resize', updatePosition)\n window.removeEventListener('scroll', updatePosition, true)\n }\n }, [open])\n\n const isDark = resolvedTheme === 'dark'\n\n // Close on click outside\n React.useEffect(() => {\n if (!open) return\n function handleClick(event: MouseEvent) {\n if (\n menuRef.current &&\n !menuRef.current.contains(event.target as Node) &&\n buttonRef.current &&\n !buttonRef.current.contains(event.target as Node)\n ) {\n setOpen(false)\n setLanguageOpen(false)\n }\n }\n document.addEventListener('mousedown', handleClick)\n return () => document.removeEventListener('mousedown', handleClick)\n }, [open])\n\n // Close on Escape\n React.useEffect(() => {\n if (!open) return\n function handleKeyDown(event: KeyboardEvent) {\n if (event.key === 'Escape') {\n if (languageOpen) {\n setLanguageOpen(false)\n } else {\n setOpen(false)\n buttonRef.current?.focus()\n }\n }\n }\n document.addEventListener('keydown', handleKeyDown)\n return () => document.removeEventListener('keydown', handleKeyDown)\n }, [open, languageOpen])\n\n const handleThemeToggle = () => {\n setTheme(isDark ? 'light' : 'dark')\n }\n\n const handleLocaleChange = async (locale: Locale) => {\n try {\n await fetch('/api/auth/locale', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ locale }),\n })\n window.location.reload()\n } catch {}\n }\n\n // Unified row class \u2014 every menu item uses this for perfectly aligned layout.\n // h-9 keeps all rows the same height regardless of whether they have a trailing element.\n const menuItemClass = cn(\n 'group flex h-9 w-full items-center gap-3 rounded-md px-2.5 text-sm text-foreground',\n 'transition-colors hover:bg-muted/60 focus:outline-none focus-visible:bg-muted/60',\n 'cursor-pointer',\n )\n const menuIconClass = 'size-4 shrink-0 text-muted-foreground group-hover:text-foreground'\n\n const resolveMenuLabel = React.useCallback(\n (item: Pick<MergedMenuItem, 'id' | 'label' | 'labelKey'>): string => {\n if (item.labelKey && item.label) return t(item.labelKey, item.label)\n if (item.labelKey) return t(item.labelKey, item.id)\n if (item.label && item.label.includes('.')) return t(item.label, item.id)\n return item.label ?? item.id\n },\n [t],\n )\n\n const builtInMenuItems = React.useMemo(\n () => {\n const items: Array<{ id: string; separator?: boolean }> = [{ id: 'change-password' }]\n if (notificationsHref) items.push({ id: 'notifications' })\n items.push({ id: 'theme-toggle', separator: true }, { id: 'language' }, { id: 'sign-out', separator: true })\n return items\n },\n [notificationsHref],\n )\n\n const mergedMenuItems = React.useMemo(\n () => mergeMenuItems(builtInMenuItems, injectedItems),\n [builtInMenuItems, injectedItems],\n )\n const injectionContext = React.useMemo(\n () => ({\n email,\n displayName,\n locale: currentLocale,\n }),\n [currentLocale, displayName, email],\n )\n\n const renderInjectedItem = React.useCallback(\n (item: MergedMenuItem) => {\n const label = resolveMenuLabel(item)\n const icon = resolveInjectedIcon(item.icon)\n const inner = (\n <>\n <span className={menuIconClass}>{icon}</span>\n <span className=\"flex-1 truncate\">{label}</span>\n </>\n )\n if (item.href) {\n return (\n <Link\n key={item.id}\n href={item.href}\n className={menuItemClass}\n role=\"menuitem\"\n data-menu-item-id={item.id}\n onClick={() => setOpen(false)}\n >\n {inner}\n </Link>\n )\n }\n return (\n <button\n key={item.id}\n type=\"button\"\n className={menuItemClass}\n role=\"menuitem\"\n data-menu-item-id={item.id}\n onClick={() => {\n item.onClick?.()\n setOpen(false)\n }}\n >\n {inner}\n </button>\n )\n },\n [menuItemClass, menuIconClass, resolveMenuLabel],\n )\n\n const renderBuiltInItem = React.useCallback(\n (id: string) => {\n if (id === 'change-password') {\n return (\n <Link\n key={id}\n href={changePasswordHref}\n className={menuItemClass}\n role=\"menuitem\"\n onClick={() => setOpen(false)}\n >\n <Key className={menuIconClass} />\n <span className=\"flex-1 truncate\">{t('ui.profileMenu.changePassword', 'Change Password')}</span>\n </Link>\n )\n }\n\n if (id === 'notifications' && notificationsHref) {\n return (\n <Link\n key={id}\n href={notificationsHref}\n className={menuItemClass}\n role=\"menuitem\"\n onClick={() => setOpen(false)}\n >\n <Bell className={menuIconClass} />\n <span className=\"flex-1 truncate\">{t('ui.profileMenu.notifications', 'Notification Preferences')}</span>\n </Link>\n )\n }\n\n if (id === 'theme-toggle') {\n if (!mounted) return null\n return (\n <div\n key={id}\n className={cn(menuItemClass, 'justify-between')}\n role=\"menuitem\"\n onClick={handleThemeToggle}\n onKeyDown={(e) => {\n if (e.key === 'Enter' || e.key === ' ') {\n e.preventDefault()\n handleThemeToggle()\n }\n }}\n tabIndex={0}\n >\n <span className=\"flex flex-1 items-center gap-3\">\n {isDark ? <Moon className={menuIconClass} /> : <Sun className={menuIconClass} />}\n <span className=\"flex-1 truncate\">{t('ui.profileMenu.theme', 'Theme')}</span>\n </span>\n <Switch\n checked={isDark}\n onCheckedChange={handleThemeToggle}\n aria-label={t('ui.profileMenu.theme', 'Theme')}\n onClick={(e) => e.stopPropagation()}\n />\n </div>\n )\n }\n\n if (id === 'language') {\n return (\n <div key={id} className=\"contents\">\n <button\n type=\"button\"\n className={menuItemClass}\n role=\"menuitem\"\n onClick={() => setLanguageOpen(!languageOpen)}\n aria-expanded={languageOpen}\n >\n <Globe className={menuIconClass} />\n <span className=\"truncate\">{t('ui.profileMenu.language', 'Language')}</span>\n <span className=\"text-xs text-muted-foreground\">{localeLabels[currentLocale]}</span>\n <ChevronRight\n className={cn(\n 'ml-auto size-3.5 shrink-0 text-muted-foreground transition-transform',\n languageOpen && 'rotate-90',\n )}\n aria-hidden=\"true\"\n />\n </button>\n {languageOpen && (\n <div className=\"ml-7 mr-1 flex flex-col gap-0.5 border-l pl-2 py-1\">\n {locales.map((locale) => (\n <button\n key={locale}\n type=\"button\"\n className={cn(\n 'flex h-8 w-full items-center justify-between gap-2 rounded-md px-2 text-sm transition-colors hover:bg-muted/60 focus:outline-none focus-visible:bg-muted/60',\n locale === currentLocale ? 'font-medium text-foreground' : 'text-muted-foreground',\n )}\n onClick={() => handleLocaleChange(locale)}\n >\n <span className=\"truncate\">{localeLabels[locale]}</span>\n {locale === currentLocale && <Check className=\"size-3.5 shrink-0 text-accent-indigo\" aria-hidden=\"true\" />}\n </button>\n ))}\n </div>\n )}\n </div>\n )\n }\n\n if (id === 'sign-out') {\n return (\n <form key={id} action=\"/api/auth/logout\" method=\"POST\">\n <button\n type=\"submit\"\n className={menuItemClass}\n role=\"menuitem\"\n >\n <LogOut className={menuIconClass} />\n <span className=\"flex-1 truncate text-left\">{t('ui.userMenu.logout', 'Sign Out')}</span>\n </button>\n </form>\n )\n }\n\n return null\n },\n [\n changePasswordHref,\n currentLocale,\n handleThemeToggle,\n isDark,\n languageOpen,\n menuItemClass,\n menuIconClass,\n mounted,\n notificationsHref,\n t,\n ],\n )\n\n return (\n <div className=\"relative\">\n <IconButton\n ref={buttonRef}\n variant=\"ghost\"\n size=\"sm\"\n onClick={() => setOpen(!open)}\n aria-expanded={open}\n aria-haspopup=\"menu\"\n data-testid=\"profile-dropdown-trigger\"\n title={email || t('ui.userMenu.userFallback', 'User')}\n >\n <User className=\"size-4\" />\n </IconButton>\n\n {open && menuPosition && typeof document !== 'undefined' && createPortal(\n <div\n ref={menuRef}\n className=\"fixed z-popover w-64 overflow-hidden rounded-lg border bg-popover p-0 shadow-lg\"\n style={{ top: menuPosition.top, right: menuPosition.right }}\n role=\"menu\"\n data-testid=\"profile-dropdown\"\n >\n {/* User info header */}\n {(displayName || email) && (\n <div className=\"flex items-center gap-3 border-b bg-muted/30 px-3 py-3\">\n <div className=\"flex size-9 shrink-0 items-center justify-center rounded-full bg-accent-indigo/10 text-accent-indigo\">\n <User className=\"size-4\" aria-hidden=\"true\" />\n </div>\n <div className=\"flex min-w-0 flex-1 flex-col\">\n {displayName ? (\n <span className=\"truncate text-sm font-medium leading-5 text-foreground\">{displayName}</span>\n ) : null}\n {email ? (\n <span className={cn(\n 'truncate text-xs leading-4',\n displayName ? 'text-muted-foreground' : 'text-foreground font-medium',\n )}>\n {email}\n </span>\n ) : null}\n {!displayName && email ? (\n <span className=\"truncate text-overline uppercase tracking-wider text-muted-foreground/80\">\n {t('ui.userMenu.loggedInAs', 'Logged in')}\n </span>\n ) : null}\n </div>\n </div>\n )}\n\n <div className=\"flex flex-col p-1.5\">\n {mergedMenuItems.map((item) => (\n <React.Fragment key={item.id}>\n {item.separator ? <div className=\"my-1 h-px bg-border\" aria-hidden=\"true\" /> : null}\n {item.source === 'injected'\n ? (item.href || item.onClick || item.label || item.labelKey ? renderInjectedItem(item) : null)\n : renderBuiltInItem(item.id)}\n </React.Fragment>\n ))}\n <InjectionSpot\n spotId={BACKEND_TOPBAR_PROFILE_MENU_INJECTION_SPOT_ID}\n context={injectionContext}\n />\n </div>\n </div>,\n document.body,\n )}\n </div>\n )\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/backend/injection/useGuardedMutation.ts"],
|
|
4
|
-
"sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport { useInjectionSpotEvents } from './InjectionSpot'\nimport { GLOBAL_MUTATION_INJECTION_SPOT_ID, dispatchBackendMutationError } from './mutationEvents'\nimport { withScopedApiRequestHeaders } from '../utils/apiCall'\nimport { surfaceRecordConflict } from '../conflicts'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\n\ntype GuardedMutationContext = Record<string, unknown>\n\ntype GuardedMutationRunInput<TResult, TContext extends GuardedMutationContext> = {\n operation: () => Promise<TResult>\n context: TContext\n mutationPayload?: Record<string, unknown>\n}\n\ntype UseGuardedMutationOptions = {\n contextId: string\n blockedMessage?: string\n spotId?: string\n}\n\ntype StoredMutation<TContext extends GuardedMutationContext> = {\n operation: () => Promise<unknown>\n context: TContext\n mutationPayload: Record<string, unknown>\n}\n\nexport function useGuardedMutation<TContext extends GuardedMutationContext>({\n contextId,\n blockedMessage = 'Save blocked by validation',\n spotId = GLOBAL_MUTATION_INJECTION_SPOT_ID,\n}: UseGuardedMutationOptions) {\n const t = useT()\n const { triggerEvent } = useInjectionSpotEvents<TContext, Record<string, unknown>>(spotId)\n const lastMutationRef = React.useRef<StoredMutation<TContext> | null>(null)\n\n const emitMutationSaveError = React.useCallback((error: unknown) => {\n dispatchBackendMutationError({\n contextId,\n formId: contextId,\n error,\n })\n // Default UX for OSS optimistic-lock conflicts (spec\n // .ai/specs/2026-05-25-oss-optimistic-locking.md \u00A73.6): when the\n // server returns 409 with `code: 'optimistic_lock_conflict'`,\n // surface the conflict on the unified, persistent, error-styled\n // RecordConflictBanner (rendered in AppShell) instead of a transient\n // toast. Callers that have their own handler still see the dispatched\n // event above and can suppress/override by catching the error first.\n try {\n surfaceRecordConflict(error, t)\n } catch {\n // ignore conflict-bar dispatch failures\n }\n }, [contextId, t])\n\n const runMutation = React.useCallback(async <TResult,>({\n operation,\n context,\n mutationPayload,\n }: GuardedMutationRunInput<TResult, TContext>): Promise<TResult> => {\n const payload = mutationPayload ?? {}\n lastMutationRef.current = {\n operation: operation as () => Promise<unknown>,\n context,\n mutationPayload: payload,\n }\n\n const beforeSave = await triggerEvent('onBeforeSave', payload, context)\n if (!beforeSave.ok) {\n emitMutationSaveError(beforeSave.details ?? beforeSave)\n throw new Error(beforeSave.message || blockedMessage)\n }\n\n try {\n const result =\n beforeSave.requestHeaders && Object.keys(beforeSave.requestHeaders).length > 0\n ? await withScopedApiRequestHeaders(beforeSave.requestHeaders, operation)\n : await operation()\n\n try {\n await triggerEvent('onAfterSave', payload, context)\n } catch (error) {\n console.error('[useGuardedMutation] Error in onAfterSave injection event:', error)\n }\n\n return result\n } catch (error) {\n emitMutationSaveError(error)\n throw error\n }\n }, [blockedMessage, emitMutationSaveError, triggerEvent])\n\n const retryLastMutation = React.useCallback(async (): Promise<boolean> => {\n const lastMutation = lastMutationRef.current\n if (!lastMutation) return false\n\n try {\n await runMutation({\n operation: lastMutation.operation,\n context: lastMutation.context,\n mutationPayload: lastMutation.mutationPayload,\n })\n return true\n } catch {\n return false\n }\n }, [runMutation])\n\n return {\n runMutation,\n retryLastMutation,\n }\n}\n"],
|
|
4
|
+
"sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport { useInjectionSpotEvents } from './InjectionSpot'\nimport { GLOBAL_MUTATION_INJECTION_SPOT_ID, dispatchBackendMutationError } from './mutationEvents'\nimport { withScopedApiRequestHeaders } from '../utils/apiCall'\nimport { surfaceRecordConflict } from '../conflicts'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\n\ntype GuardedMutationContext = Record<string, unknown>\n\ntype GuardedMutationRunInput<TResult, TContext extends GuardedMutationContext> = {\n operation: () => Promise<TResult>\n context: TContext\n mutationPayload?: Record<string, unknown>\n}\n\ntype UseGuardedMutationOptions = {\n contextId: string\n blockedMessage?: string\n spotId?: string\n}\n\ntype StoredMutation<TContext extends GuardedMutationContext> = {\n operation: () => Promise<unknown>\n context: TContext\n mutationPayload: Record<string, unknown>\n}\n\nexport function useGuardedMutation<TContext extends GuardedMutationContext>({\n contextId,\n blockedMessage = 'Save blocked by validation',\n spotId = GLOBAL_MUTATION_INJECTION_SPOT_ID,\n}: UseGuardedMutationOptions) {\n const t = useT()\n const { triggerEvent } = useInjectionSpotEvents<TContext, Record<string, unknown>>(spotId)\n const lastMutationRef = React.useRef<StoredMutation<TContext> | null>(null)\n\n const emitMutationSaveError = React.useCallback((error: unknown) => {\n dispatchBackendMutationError({\n contextId,\n formId: contextId,\n error,\n })\n // Default UX for OSS optimistic-lock conflicts (spec\n // .ai/specs/implemented/2026-05-25-oss-optimistic-locking.md \u00A73.6): when the\n // server returns 409 with `code: 'optimistic_lock_conflict'`,\n // surface the conflict on the unified, persistent, error-styled\n // RecordConflictBanner (rendered in AppShell) instead of a transient\n // toast. Callers that have their own handler still see the dispatched\n // event above and can suppress/override by catching the error first.\n try {\n surfaceRecordConflict(error, t)\n } catch {\n // ignore conflict-bar dispatch failures\n }\n }, [contextId, t])\n\n const runMutation = React.useCallback(async <TResult,>({\n operation,\n context,\n mutationPayload,\n }: GuardedMutationRunInput<TResult, TContext>): Promise<TResult> => {\n const payload = mutationPayload ?? {}\n lastMutationRef.current = {\n operation: operation as () => Promise<unknown>,\n context,\n mutationPayload: payload,\n }\n\n const beforeSave = await triggerEvent('onBeforeSave', payload, context)\n if (!beforeSave.ok) {\n emitMutationSaveError(beforeSave.details ?? beforeSave)\n throw new Error(beforeSave.message || blockedMessage)\n }\n\n try {\n const result =\n beforeSave.requestHeaders && Object.keys(beforeSave.requestHeaders).length > 0\n ? await withScopedApiRequestHeaders(beforeSave.requestHeaders, operation)\n : await operation()\n\n try {\n await triggerEvent('onAfterSave', payload, context)\n } catch (error) {\n console.error('[useGuardedMutation] Error in onAfterSave injection event:', error)\n }\n\n return result\n } catch (error) {\n emitMutationSaveError(error)\n throw error\n }\n }, [blockedMessage, emitMutationSaveError, triggerEvent])\n\n const retryLastMutation = React.useCallback(async (): Promise<boolean> => {\n const lastMutation = lastMutationRef.current\n if (!lastMutation) return false\n\n try {\n await runMutation({\n operation: lastMutation.operation,\n context: lastMutation.context,\n mutationPayload: lastMutation.mutationPayload,\n })\n return true\n } catch {\n return false\n }\n }, [runMutation])\n\n return {\n runMutation,\n retryLastMutation,\n }\n}\n"],
|
|
5
5
|
"mappings": ";AAEA,YAAY,WAAW;AACvB,SAAS,8BAA8B;AACvC,SAAS,mCAAmC,oCAAoC;AAChF,SAAS,mCAAmC;AAC5C,SAAS,6BAA6B;AACtC,SAAS,YAAY;AAsBd,SAAS,mBAA4D;AAAA,EAC1E;AAAA,EACA,iBAAiB;AAAA,EACjB,SAAS;AACX,GAA8B;AAC5B,QAAM,IAAI,KAAK;AACf,QAAM,EAAE,aAAa,IAAI,uBAA0D,MAAM;AACzF,QAAM,kBAAkB,MAAM,OAAwC,IAAI;AAE1E,QAAM,wBAAwB,MAAM,YAAY,CAAC,UAAmB;AAClE,iCAA6B;AAAA,MAC3B;AAAA,MACA,QAAQ;AAAA,MACR;AAAA,IACF,CAAC;AAQD,QAAI;AACF,4BAAsB,OAAO,CAAC;AAAA,IAChC,QAAQ;AAAA,IAER;AAAA,EACF,GAAG,CAAC,WAAW,CAAC,CAAC;AAEjB,QAAM,cAAc,MAAM,YAAY,OAAiB;AAAA,IACrD;AAAA,IACA;AAAA,IACA;AAAA,EACF,MAAoE;AAClE,UAAM,UAAU,mBAAmB,CAAC;AACpC,oBAAgB,UAAU;AAAA,MACxB;AAAA,MACA;AAAA,MACA,iBAAiB;AAAA,IACnB;AAEA,UAAM,aAAa,MAAM,aAAa,gBAAgB,SAAS,OAAO;AACtE,QAAI,CAAC,WAAW,IAAI;AAClB,4BAAsB,WAAW,WAAW,UAAU;AACtD,YAAM,IAAI,MAAM,WAAW,WAAW,cAAc;AAAA,IACtD;AAEA,QAAI;AACF,YAAM,SACJ,WAAW,kBAAkB,OAAO,KAAK,WAAW,cAAc,EAAE,SAAS,IACzE,MAAM,4BAA4B,WAAW,gBAAgB,SAAS,IACtE,MAAM,UAAU;AAEtB,UAAI;AACF,cAAM,aAAa,eAAe,SAAS,OAAO;AAAA,MACpD,SAAS,OAAO;AACd,gBAAQ,MAAM,8DAA8D,KAAK;AAAA,MACnF;AAEA,aAAO;AAAA,IACT,SAAS,OAAO;AACd,4BAAsB,KAAK;AAC3B,YAAM;AAAA,IACR;AAAA,EACF,GAAG,CAAC,gBAAgB,uBAAuB,YAAY,CAAC;AAExD,QAAM,oBAAoB,MAAM,YAAY,YAA8B;AACxE,UAAM,eAAe,gBAAgB;AACrC,QAAI,CAAC,aAAc,QAAO;AAE1B,QAAI;AACF,YAAM,YAAY;AAAA,QAChB,WAAW,aAAa;AAAA,QACxB,SAAS,aAAa;AAAA,QACtB,iBAAiB,aAAa;AAAA,MAChC,CAAC;AACD,aAAO;AAAA,IACT,QAAQ;AACN,aAAO;AAAA,IACT;AAAA,EACF,GAAG,CAAC,WAAW,CAAC;AAEhB,SAAO;AAAA,IACL;AAAA,IACA;AAAA,EACF;AACF;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/backend/utils/optimisticLock.ts"],
|
|
4
|
-
"sourcesContent": ["/**\n * Client-side helpers for the OSS opt-in optimistic-locking guard\n * (spec: .ai/specs/2026-05-25-oss-optimistic-locking.md).\n *\n * These are deliberately small and dependency-light so they can be wired\n * into any backend page without touching the shared CrudForm / useGuardedMutation\n * components. A future PR may pull them into CrudForm directly once the\n * reference rollout is broader.\n */\nimport {\n OPTIMISTIC_LOCK_CONFLICT_CODE,\n OPTIMISTIC_LOCK_HEADER_NAME,\n type OptimisticLockConflictBody,\n} from '@open-mercato/shared/lib/crud/optimistic-lock-headers'\n\n/**\n * Build the extension-header bag for an `apiCall` request. Pass into\n * `withScopedApiRequestHeaders(buildOptimisticLockHeader(updatedAt), ...)`\n * when issuing a `PUT`/`PATCH`/`DELETE` for a record that exposes\n * `updatedAt`. Returns an empty object when the input is missing/empty so\n * the call site stays unconditional:\n *\n * ```ts\n * await withScopedApiRequestHeaders(\n * buildOptimisticLockHeader(record.updatedAt),\n * () => updateCrud('customers/companies', id, { ... }),\n * )\n * ```\n */\nexport function buildOptimisticLockHeader(\n updatedAt: string | null | undefined,\n): Record<string, string> {\n if (typeof updatedAt !== 'string') return {}\n const trimmed = updatedAt.trim()\n if (!trimmed) return {}\n return { [OPTIMISTIC_LOCK_HEADER_NAME]: trimmed }\n}\n\n/**\n * Detect whether an error thrown by `raiseCrudError`/`apiCallOrThrow` is an\n * optimistic-lock conflict (HTTP 409 with `code: 'optimistic_lock_conflict'`).\n *\n * Returns the typed conflict body when matched so the caller can show a\n * UI that includes the server's `currentUpdatedAt`, or `null` otherwise.\n */\nexport function extractOptimisticLockConflict(\n err: unknown,\n): OptimisticLockConflictBody | null {\n if (!err || typeof err !== 'object') return null\n const candidate = err as Record<string, unknown>\n const status = candidate.status\n if (status !== 409) return null\n const body = candidate.body && typeof candidate.body === 'object'\n ? candidate.body\n : candidate\n if (!body || typeof body !== 'object') return null\n const bodyRecord = body as Record<string, unknown>\n if (bodyRecord.code !== OPTIMISTIC_LOCK_CONFLICT_CODE) return null\n const currentUpdatedAt = bodyRecord.currentUpdatedAt\n const expectedUpdatedAt = bodyRecord.expectedUpdatedAt\n if (typeof currentUpdatedAt !== 'string' || typeof expectedUpdatedAt !== 'string') return null\n return {\n error: typeof bodyRecord.error === 'string'\n ? (bodyRecord.error as OptimisticLockConflictBody['error'])\n : 'record_modified',\n code: OPTIMISTIC_LOCK_CONFLICT_CODE,\n currentUpdatedAt,\n expectedUpdatedAt,\n }\n}\n"],
|
|
4
|
+
"sourcesContent": ["/**\n * Client-side helpers for the OSS opt-in optimistic-locking guard\n * (spec: .ai/specs/implemented/2026-05-25-oss-optimistic-locking.md).\n *\n * These are deliberately small and dependency-light so they can be wired\n * into any backend page without touching the shared CrudForm / useGuardedMutation\n * components. A future PR may pull them into CrudForm directly once the\n * reference rollout is broader.\n */\nimport {\n OPTIMISTIC_LOCK_CONFLICT_CODE,\n OPTIMISTIC_LOCK_HEADER_NAME,\n type OptimisticLockConflictBody,\n} from '@open-mercato/shared/lib/crud/optimistic-lock-headers'\n\n/**\n * Build the extension-header bag for an `apiCall` request. Pass into\n * `withScopedApiRequestHeaders(buildOptimisticLockHeader(updatedAt), ...)`\n * when issuing a `PUT`/`PATCH`/`DELETE` for a record that exposes\n * `updatedAt`. Returns an empty object when the input is missing/empty so\n * the call site stays unconditional:\n *\n * ```ts\n * await withScopedApiRequestHeaders(\n * buildOptimisticLockHeader(record.updatedAt),\n * () => updateCrud('customers/companies', id, { ... }),\n * )\n * ```\n */\nexport function buildOptimisticLockHeader(\n updatedAt: string | null | undefined,\n): Record<string, string> {\n if (typeof updatedAt !== 'string') return {}\n const trimmed = updatedAt.trim()\n if (!trimmed) return {}\n return { [OPTIMISTIC_LOCK_HEADER_NAME]: trimmed }\n}\n\n/**\n * Detect whether an error thrown by `raiseCrudError`/`apiCallOrThrow` is an\n * optimistic-lock conflict (HTTP 409 with `code: 'optimistic_lock_conflict'`).\n *\n * Returns the typed conflict body when matched so the caller can show a\n * UI that includes the server's `currentUpdatedAt`, or `null` otherwise.\n */\nexport function extractOptimisticLockConflict(\n err: unknown,\n): OptimisticLockConflictBody | null {\n if (!err || typeof err !== 'object') return null\n const candidate = err as Record<string, unknown>\n const status = candidate.status\n if (status !== 409) return null\n const body = candidate.body && typeof candidate.body === 'object'\n ? candidate.body\n : candidate\n if (!body || typeof body !== 'object') return null\n const bodyRecord = body as Record<string, unknown>\n if (bodyRecord.code !== OPTIMISTIC_LOCK_CONFLICT_CODE) return null\n const currentUpdatedAt = bodyRecord.currentUpdatedAt\n const expectedUpdatedAt = bodyRecord.expectedUpdatedAt\n if (typeof currentUpdatedAt !== 'string' || typeof expectedUpdatedAt !== 'string') return null\n return {\n error: typeof bodyRecord.error === 'string'\n ? (bodyRecord.error as OptimisticLockConflictBody['error'])\n : 'record_modified',\n code: OPTIMISTIC_LOCK_CONFLICT_CODE,\n currentUpdatedAt,\n expectedUpdatedAt,\n }\n}\n"],
|
|
5
5
|
"mappings": "AASA;AAAA,EACE;AAAA,EACA;AAAA,OAEK;AAgBA,SAAS,0BACd,WACwB;AACxB,MAAI,OAAO,cAAc,SAAU,QAAO,CAAC;AAC3C,QAAM,UAAU,UAAU,KAAK;AAC/B,MAAI,CAAC,QAAS,QAAO,CAAC;AACtB,SAAO,EAAE,CAAC,2BAA2B,GAAG,QAAQ;AAClD;AASO,SAAS,8BACd,KACmC;AACnC,MAAI,CAAC,OAAO,OAAO,QAAQ,SAAU,QAAO;AAC5C,QAAM,YAAY;AAClB,QAAM,SAAS,UAAU;AACzB,MAAI,WAAW,IAAK,QAAO;AAC3B,QAAM,OAAO,UAAU,QAAQ,OAAO,UAAU,SAAS,WACrD,UAAU,OACV;AACJ,MAAI,CAAC,QAAQ,OAAO,SAAS,SAAU,QAAO;AAC9C,QAAM,aAAa;AACnB,MAAI,WAAW,SAAS,8BAA+B,QAAO;AAC9D,QAAM,mBAAmB,WAAW;AACpC,QAAM,oBAAoB,WAAW;AACrC,MAAI,OAAO,qBAAqB,YAAY,OAAO,sBAAsB,SAAU,QAAO;AAC1F,SAAO;AAAA,IACL,OAAO,OAAO,WAAW,UAAU,WAC9B,WAAW,QACZ;AAAA,IACJ,MAAM;AAAA,IACN;AAAA,IACA;AAAA,EACF;AACF;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
function formatCurrency(value, currency) {
|
|
2
|
+
if (value === null || value === void 0 || value === "") return null;
|
|
3
|
+
const numeric = typeof value === "number" ? value : Number(value);
|
|
4
|
+
if (!Number.isFinite(numeric)) {
|
|
5
|
+
return typeof value === "string" ? value : null;
|
|
6
|
+
}
|
|
7
|
+
const code = currency && currency.length === 3 ? currency.toUpperCase() : void 0;
|
|
8
|
+
try {
|
|
9
|
+
if (code) {
|
|
10
|
+
return new Intl.NumberFormat(void 0, { style: "currency", currency: code }).format(numeric);
|
|
11
|
+
}
|
|
12
|
+
} catch {
|
|
13
|
+
}
|
|
14
|
+
const formatted = new Intl.NumberFormat().format(numeric);
|
|
15
|
+
return code ? `${formatted} ${code}` : formatted;
|
|
16
|
+
}
|
|
17
|
+
function formatDate(value) {
|
|
18
|
+
if (!value) return null;
|
|
19
|
+
const date = new Date(value);
|
|
20
|
+
if (Number.isNaN(date.getTime())) return value;
|
|
21
|
+
return date.toLocaleDateString(void 0, { year: "numeric", month: "short", day: "numeric" });
|
|
22
|
+
}
|
|
23
|
+
export {
|
|
24
|
+
formatCurrency,
|
|
25
|
+
formatDate
|
|
26
|
+
};
|
|
27
|
+
//# sourceMappingURL=format.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../src/utils/format.ts"],
|
|
4
|
+
"sourcesContent": ["/**\n * Format a monetary value with an optional ISO-4217 currency code.\n *\n * Returns `null` for empty input, the original string for non-numeric input,\n * and falls back to a plain number format (optionally suffixed with the code)\n * when the currency code is missing or rejected by `Intl.NumberFormat`.\n */\nexport function formatCurrency(\n value: string | number | null | undefined,\n currency?: string | null,\n): string | null {\n if (value === null || value === undefined || value === '') return null\n const numeric = typeof value === 'number' ? value : Number(value)\n if (!Number.isFinite(numeric)) {\n return typeof value === 'string' ? value : null\n }\n const code = currency && currency.length === 3 ? currency.toUpperCase() : undefined\n try {\n if (code) {\n return new Intl.NumberFormat(undefined, { style: 'currency', currency: code }).format(numeric)\n }\n } catch {\n // fall through to plain number formatting\n }\n const formatted = new Intl.NumberFormat().format(numeric)\n return code ? `${formatted} ${code}` : formatted\n}\n\n/**\n * Format an ISO date string as a localized short date (e.g. `Jun 9, 2026`).\n *\n * Returns `null` for empty input and echoes the original value back when it is\n * not a parseable date.\n */\nexport function formatDate(value: string | null | undefined): string | null {\n if (!value) return null\n const date = new Date(value)\n if (Number.isNaN(date.getTime())) return value\n return date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })\n}\n"],
|
|
5
|
+
"mappings": "AAOO,SAAS,eACd,OACA,UACe;AACf,MAAI,UAAU,QAAQ,UAAU,UAAa,UAAU,GAAI,QAAO;AAClE,QAAM,UAAU,OAAO,UAAU,WAAW,QAAQ,OAAO,KAAK;AAChE,MAAI,CAAC,OAAO,SAAS,OAAO,GAAG;AAC7B,WAAO,OAAO,UAAU,WAAW,QAAQ;AAAA,EAC7C;AACA,QAAM,OAAO,YAAY,SAAS,WAAW,IAAI,SAAS,YAAY,IAAI;AAC1E,MAAI;AACF,QAAI,MAAM;AACR,aAAO,IAAI,KAAK,aAAa,QAAW,EAAE,OAAO,YAAY,UAAU,KAAK,CAAC,EAAE,OAAO,OAAO;AAAA,IAC/F;AAAA,EACF,QAAQ;AAAA,EAER;AACA,QAAM,YAAY,IAAI,KAAK,aAAa,EAAE,OAAO,OAAO;AACxD,SAAO,OAAO,GAAG,SAAS,IAAI,IAAI,KAAK;AACzC;AAQO,SAAS,WAAW,OAAiD;AAC1E,MAAI,CAAC,MAAO,QAAO;AACnB,QAAM,OAAO,IAAI,KAAK,KAAK;AAC3B,MAAI,OAAO,MAAM,KAAK,QAAQ,CAAC,EAAG,QAAO;AACzC,SAAO,KAAK,mBAAmB,QAAW,EAAE,MAAM,WAAW,OAAO,SAAS,KAAK,UAAU,CAAC;AAC/F;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/ui",
|
|
3
|
-
"version": "0.6.5-develop.
|
|
3
|
+
"version": "0.6.5-develop.5382.1.f542de69af",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -74,6 +74,10 @@
|
|
|
74
74
|
"types": "./src/backend/forms/index.ts",
|
|
75
75
|
"default": "./dist/backend/forms/index.js"
|
|
76
76
|
},
|
|
77
|
+
"./backend/markdown": {
|
|
78
|
+
"types": "./src/backend/markdown/index.ts",
|
|
79
|
+
"default": "./dist/backend/markdown/index.js"
|
|
80
|
+
},
|
|
77
81
|
"./backend/messages": {
|
|
78
82
|
"types": "./src/backend/messages/index.ts",
|
|
79
83
|
"default": "./dist/backend/messages/index.js"
|
|
@@ -150,13 +154,13 @@
|
|
|
150
154
|
"remark-gfm": "^4.0.1"
|
|
151
155
|
},
|
|
152
156
|
"peerDependencies": {
|
|
153
|
-
"@open-mercato/shared": "0.6.5-develop.
|
|
157
|
+
"@open-mercato/shared": "0.6.5-develop.5382.1.f542de69af",
|
|
154
158
|
"react": ">=18.0.0",
|
|
155
159
|
"react-dom": ">=18.0.0",
|
|
156
160
|
"react-is": ">=18.0.0"
|
|
157
161
|
},
|
|
158
162
|
"devDependencies": {
|
|
159
|
-
"@open-mercato/shared": "0.6.5-develop.
|
|
163
|
+
"@open-mercato/shared": "0.6.5-develop.5382.1.f542de69af",
|
|
160
164
|
"@testing-library/dom": "^10.4.1",
|
|
161
165
|
"@testing-library/jest-dom": "^6.9.1",
|
|
162
166
|
"@testing-library/react": "^16.3.1",
|
package/src/ai/AiChat.tsx
CHANGED
|
@@ -432,7 +432,7 @@ function formatToolCaption(call: AiChatToolCallSnapshot): string {
|
|
|
432
432
|
|
|
433
433
|
/**
|
|
434
434
|
* Visible agent task plan rendered above raw tool-call rows. Phase 1
|
|
435
|
-
* implementation of spec `.ai/specs/2026-05-13-ai-chat-visible-task-plan.md`:
|
|
435
|
+
* implementation of spec `.ai/specs/implemented/2026-05-13-ai-chat-visible-task-plan.md`:
|
|
436
436
|
* compact, fixed-height rows with icon + label + status badge so the
|
|
437
437
|
* panel does not jump while streaming. Source of truth is the
|
|
438
438
|
* client-local `taskPlan` array on the assistant message.
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Acceptance tests for the visible agent task plan
|
|
7
|
-
* (spec `.ai/specs/2026-05-13-ai-chat-visible-task-plan.md`).
|
|
7
|
+
* (spec `.ai/specs/implemented/2026-05-13-ai-chat-visible-task-plan.md`).
|
|
8
8
|
*
|
|
9
9
|
* Asserts that:
|
|
10
10
|
* - `useAiChat` merges `data-agent-task-plan` snapshots and
|
|
@@ -15,13 +15,7 @@ import type { LucideIcon } from 'lucide-react'
|
|
|
15
15
|
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
16
16
|
import { KeyValueList, RecordCardShell, TagRow, statusToTagVariant } from './RecordCardShell'
|
|
17
17
|
import type { ActivityRecordPayload } from './types'
|
|
18
|
-
|
|
19
|
-
function formatDate(value: string | null | undefined): string | null {
|
|
20
|
-
if (!value) return null
|
|
21
|
-
const d = new Date(value)
|
|
22
|
-
if (Number.isNaN(d.getTime())) return value
|
|
23
|
-
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
|
|
24
|
-
}
|
|
18
|
+
import { formatDate } from '../../utils/format'
|
|
25
19
|
|
|
26
20
|
function pickActivityIcon(type: string | null | undefined): LucideIcon {
|
|
27
21
|
if (!type) return ActivityIcon
|
|
@@ -5,31 +5,7 @@ import { Briefcase, Building2, CalendarDays, CircleDollarSign, User } from 'luci
|
|
|
5
5
|
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
6
6
|
import { KeyValueList, RecordCardShell, TagRow, statusToTagVariant } from './RecordCardShell'
|
|
7
7
|
import type { DealRecordPayload } from './types'
|
|
8
|
-
|
|
9
|
-
function formatAmount(amount: string | number | null | undefined, currency?: string | null): string | null {
|
|
10
|
-
if (amount === null || amount === undefined || amount === '') return null
|
|
11
|
-
const value = typeof amount === 'number' ? amount : Number(amount)
|
|
12
|
-
if (!Number.isFinite(value)) {
|
|
13
|
-
return typeof amount === 'string' ? amount : null
|
|
14
|
-
}
|
|
15
|
-
const code = currency && currency.length === 3 ? currency.toUpperCase() : undefined
|
|
16
|
-
try {
|
|
17
|
-
if (code) {
|
|
18
|
-
return new Intl.NumberFormat(undefined, { style: 'currency', currency: code }).format(value)
|
|
19
|
-
}
|
|
20
|
-
} catch {
|
|
21
|
-
// fall through to fallback
|
|
22
|
-
}
|
|
23
|
-
const formatted = new Intl.NumberFormat().format(value)
|
|
24
|
-
return code ? `${formatted} ${code}` : formatted
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
function formatDate(value: string | null | undefined): string | null {
|
|
28
|
-
if (!value) return null
|
|
29
|
-
const d = new Date(value)
|
|
30
|
-
if (Number.isNaN(d.getTime())) return value
|
|
31
|
-
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
|
|
32
|
-
}
|
|
8
|
+
import { formatCurrency, formatDate } from '../../utils/format'
|
|
33
9
|
|
|
34
10
|
export interface DealCardProps extends DealRecordPayload {}
|
|
35
11
|
|
|
@@ -39,7 +15,7 @@ export function DealCard(props: DealCardProps) {
|
|
|
39
15
|
? { label: props.status, variant: statusToTagVariant(props.status) }
|
|
40
16
|
: null
|
|
41
17
|
const stage = props.stage && props.stage !== props.status ? props.stage : null
|
|
42
|
-
const amount =
|
|
18
|
+
const amount = formatCurrency(props.amount, props.currency)
|
|
43
19
|
const closeDate = formatDate(props.closeDate)
|
|
44
20
|
|
|
45
21
|
const items = [
|
|
@@ -5,24 +5,7 @@ import { Package, Tag as TagIcon } from 'lucide-react'
|
|
|
5
5
|
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
6
6
|
import { KeyValueList, RecordCardShell, TagRow, statusToTagVariant } from './RecordCardShell'
|
|
7
7
|
import type { ProductRecordPayload } from './types'
|
|
8
|
-
|
|
9
|
-
function formatPrice(price: string | number | null | undefined, currency?: string | null): string | null {
|
|
10
|
-
if (price === null || price === undefined || price === '') return null
|
|
11
|
-
const value = typeof price === 'number' ? price : Number(price)
|
|
12
|
-
if (!Number.isFinite(value)) {
|
|
13
|
-
return typeof price === 'string' ? price : null
|
|
14
|
-
}
|
|
15
|
-
const code = currency && currency.length === 3 ? currency.toUpperCase() : undefined
|
|
16
|
-
try {
|
|
17
|
-
if (code) {
|
|
18
|
-
return new Intl.NumberFormat(undefined, { style: 'currency', currency: code }).format(value)
|
|
19
|
-
}
|
|
20
|
-
} catch {
|
|
21
|
-
// fall through
|
|
22
|
-
}
|
|
23
|
-
const formatted = new Intl.NumberFormat().format(value)
|
|
24
|
-
return code ? `${formatted} ${code}` : formatted
|
|
25
|
-
}
|
|
8
|
+
import { formatCurrency } from '../../utils/format'
|
|
26
9
|
|
|
27
10
|
export interface ProductCardProps extends ProductRecordPayload {}
|
|
28
11
|
|
|
@@ -31,7 +14,7 @@ export function ProductCard(props: ProductCardProps) {
|
|
|
31
14
|
const status = props.status
|
|
32
15
|
? { label: props.status, variant: statusToTagVariant(props.status) }
|
|
33
16
|
: null
|
|
34
|
-
const price =
|
|
17
|
+
const price = formatCurrency(props.price, props.currency)
|
|
35
18
|
|
|
36
19
|
const leading = props.imageUrl ? (
|
|
37
20
|
<div className="relative size-12 overflow-hidden rounded-md border border-border bg-muted">
|
package/src/ai/useAiChat.ts
CHANGED
|
@@ -44,7 +44,7 @@ export interface AiChatMessageUiPart {
|
|
|
44
44
|
|
|
45
45
|
/**
|
|
46
46
|
* Snapshot of a single task in the visible agent task plan
|
|
47
|
-
* (spec `.ai/specs/2026-05-13-ai-chat-visible-task-plan.md`). Tasks are
|
|
47
|
+
* (spec `.ai/specs/implemented/2026-05-13-ai-chat-visible-task-plan.md`). Tasks are
|
|
48
48
|
* streamed as additive `data-agent-task-plan` / `data-agent-task-update`
|
|
49
49
|
* SSE chunks and rendered above raw tool-call rows in `<AiChat>`.
|
|
50
50
|
*
|
package/src/backend/CrudForm.tsx
CHANGED
|
@@ -310,7 +310,7 @@ export type CrudFormProps<TValues extends Record<string, unknown>> = {
|
|
|
310
310
|
// When true, shows Delete button whenever onDelete is provided, even without an id
|
|
311
311
|
deleteVisible?: boolean
|
|
312
312
|
/**
|
|
313
|
-
* OSS opt-in optimistic locking (spec: .ai/specs/2026-05-25-oss-optimistic-locking.md).
|
|
313
|
+
* OSS opt-in optimistic locking (spec: .ai/specs/implemented/2026-05-25-oss-optimistic-locking.md).
|
|
314
314
|
*
|
|
315
315
|
* When set to a non-empty ISO-8601 string (typically `record.updatedAt`
|
|
316
316
|
* from the API response), the form auto-injects the
|
|
@@ -272,7 +272,7 @@ export type DataTableProps<T> = {
|
|
|
272
272
|
* or the legacy flat `AdvancedFilterState` shape as a backward-compatibility
|
|
273
273
|
* bridge. The bridge is provided for one minor version; legacy callers SHOULD
|
|
274
274
|
* migrate to the tree shape — see the spec
|
|
275
|
-
* `.ai/specs/2026-05-10-crm-list-filter-redesign.md` "Migration & Backward
|
|
275
|
+
* `.ai/specs/implemented/2026-05-10-crm-list-filter-redesign.md` "Migration & Backward
|
|
276
276
|
* Compatibility" section and `RELEASE_NOTES.md`.
|
|
277
277
|
*
|
|
278
278
|
* When the legacy flat shape is detected, DataTable converts it to a tree via
|
|
@@ -1023,7 +1023,7 @@ export function DataTable<T>({
|
|
|
1023
1023
|
// (`triggerRef`, `externalPopover`, `onApplyTree`, `onTriggerClick`) are
|
|
1024
1024
|
// undefined for legacy callers — they were added in this PR and didn't exist
|
|
1025
1025
|
// in the legacy contract.
|
|
1026
|
-
// See spec `.ai/specs/2026-05-10-crm-list-filter-redesign.md` ("Migration &
|
|
1026
|
+
// See spec `.ai/specs/implemented/2026-05-10-crm-list-filter-redesign.md` ("Migration &
|
|
1027
1027
|
// Backward Compatibility") and BACKWARD_COMPATIBILITY.md §3.
|
|
1028
1028
|
type AdvancedFilterNormalized = {
|
|
1029
1029
|
fields?: AdvancedFilterFieldDef[]
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
import * as React from 'react'
|
|
3
|
+
import { createPortal } from 'react-dom'
|
|
3
4
|
import Link from 'next/link'
|
|
4
5
|
import { User, LogOut, Bell, Moon, Sun, Globe, Key, Check, ChevronRight } from 'lucide-react'
|
|
5
6
|
import { useT, useLocale } from '@open-mercato/shared/lib/i18n/context'
|
|
@@ -21,6 +22,8 @@ export type ProfileDropdownProps = {
|
|
|
21
22
|
notificationsHref?: string
|
|
22
23
|
}
|
|
23
24
|
|
|
25
|
+
const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? React.useLayoutEffect : React.useEffect
|
|
26
|
+
|
|
24
27
|
const localeLabels: Record<Locale, string> = {
|
|
25
28
|
en: 'English',
|
|
26
29
|
de: 'Deutsch',
|
|
@@ -42,12 +45,39 @@ export function ProfileDropdown({
|
|
|
42
45
|
const [mounted, setMounted] = React.useState(false)
|
|
43
46
|
const buttonRef = React.useRef<HTMLButtonElement>(null)
|
|
44
47
|
const menuRef = React.useRef<HTMLDivElement>(null)
|
|
48
|
+
const [menuPosition, setMenuPosition] = React.useState<{ top: number; right: number } | null>(null)
|
|
45
49
|
const { items: injectedItems } = useInjectedMenuItems('menu:topbar:profile-dropdown')
|
|
46
50
|
|
|
47
51
|
React.useEffect(() => {
|
|
48
52
|
setMounted(true)
|
|
49
53
|
}, [])
|
|
50
54
|
|
|
55
|
+
// Position the portaled menu under the trigger. Rendering the menu in a body
|
|
56
|
+
// portal keeps it out of the sticky header's backdrop-blur stacking context so
|
|
57
|
+
// it always paints above page content such as the DataTable sticky Actions column.
|
|
58
|
+
useIsomorphicLayoutEffect(() => {
|
|
59
|
+
if (!open) {
|
|
60
|
+
setMenuPosition(null)
|
|
61
|
+
return
|
|
62
|
+
}
|
|
63
|
+
const updatePosition = () => {
|
|
64
|
+
const trigger = buttonRef.current
|
|
65
|
+
if (!trigger) return
|
|
66
|
+
const rect = trigger.getBoundingClientRect()
|
|
67
|
+
setMenuPosition({
|
|
68
|
+
top: rect.bottom + 8,
|
|
69
|
+
right: Math.max(0, window.innerWidth - rect.right),
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
updatePosition()
|
|
73
|
+
window.addEventListener('resize', updatePosition)
|
|
74
|
+
window.addEventListener('scroll', updatePosition, true)
|
|
75
|
+
return () => {
|
|
76
|
+
window.removeEventListener('resize', updatePosition)
|
|
77
|
+
window.removeEventListener('scroll', updatePosition, true)
|
|
78
|
+
}
|
|
79
|
+
}, [open])
|
|
80
|
+
|
|
51
81
|
const isDark = resolvedTheme === 'dark'
|
|
52
82
|
|
|
53
83
|
// Close on click outside
|
|
@@ -336,10 +366,11 @@ export function ProfileDropdown({
|
|
|
336
366
|
<User className="size-4" />
|
|
337
367
|
</IconButton>
|
|
338
368
|
|
|
339
|
-
{open && (
|
|
369
|
+
{open && menuPosition && typeof document !== 'undefined' && createPortal(
|
|
340
370
|
<div
|
|
341
371
|
ref={menuRef}
|
|
342
|
-
className="
|
|
372
|
+
className="fixed z-popover w-64 overflow-hidden rounded-lg border bg-popover p-0 shadow-lg"
|
|
373
|
+
style={{ top: menuPosition.top, right: menuPosition.right }}
|
|
343
374
|
role="menu"
|
|
344
375
|
data-testid="profile-dropdown"
|
|
345
376
|
>
|
|
@@ -384,7 +415,8 @@ export function ProfileDropdown({
|
|
|
384
415
|
context={injectionContext}
|
|
385
416
|
/>
|
|
386
417
|
</div>
|
|
387
|
-
</div
|
|
418
|
+
</div>,
|
|
419
|
+
document.body,
|
|
388
420
|
)}
|
|
389
421
|
</div>
|
|
390
422
|
)
|