@open-mercato/ui 0.6.7-develop.6834.1.e76f4b8cbc → 0.6.7-develop.6851.1.c1680b00d8

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.
@@ -20,7 +20,8 @@ const localeLabels = {
20
20
  en: "English",
21
21
  de: "Deutsch",
22
22
  es: "Espa\xF1ol",
23
- pl: "Polski"
23
+ pl: "Polski",
24
+ ko: "\uD55C\uAD6D\uC5B4"
24
25
  };
25
26
  function ProfileDropdown({
26
27
  email,
@@ -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 { 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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",
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 ko: '\uD55C\uAD6D\uC5B4',\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
  }
@@ -29,10 +29,7 @@ function useGroupOrder(pageType, defaultGroupIds) {
29
29
  const saved = readJsonFromLocalStorage(getStorageKey(pageType), null);
30
30
  setSavedOrder(Array.isArray(saved) ? saved : null);
31
31
  }, [pageType]);
32
- const mergedIds = React.useMemo(
33
- () => savedOrder ? mergeOrder(savedOrder, defaultGroupIds) : defaultGroupIds,
34
- [defaultGroupIds, savedOrder]
35
- );
32
+ const mergedIds = savedOrder ? mergeOrder(savedOrder, defaultGroupIds) : defaultGroupIds;
36
33
  const stableIdsRef = React.useRef(mergedIds);
37
34
  if (!arraysEqual(stableIdsRef.current, mergedIds)) {
38
35
  stableIdsRef.current = mergedIds;
@@ -43,6 +40,7 @@ function useGroupOrder(pageType, defaultGroupIds) {
43
40
  const next = [...stableIdsRef.current];
44
41
  const [moved] = next.splice(fromIndex, 1);
45
42
  next.splice(toIndex, 0, moved);
43
+ stableIdsRef.current = next;
46
44
  writeJsonToLocalStorage(getStorageKey(pageType), next);
47
45
  setSavedOrder(next);
48
46
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/backend/crud/useGroupOrder.ts"],
4
- "sourcesContent": ["'use client'\nimport * as React from 'react'\nimport {\n readJsonFromLocalStorage,\n writeJsonToLocalStorage,\n} from '@open-mercato/shared/lib/browser/safeLocalStorage'\n\nconst STORAGE_PREFIX = 'om:group-order:'\n\nfunction getStorageKey(pageType: string) {\n return `${STORAGE_PREFIX}${pageType}`\n}\n\nfunction mergeOrder(saved: string[], defaults: string[]): string[] {\n const known = new Set(defaults)\n const result = saved.filter((id) => known.has(id))\n for (const id of defaults) {\n if (!result.includes(id)) result.push(id)\n }\n return result\n}\n\nfunction arraysEqual(a: string[], b: string[]): boolean {\n if (a.length !== b.length) return false\n for (let i = 0; i < a.length; i += 1) {\n if (a[i] !== b[i]) return false\n }\n return true\n}\n\n/**\n * Returns the group IDs in the user's preferred order.\n * Falls back to the default order when no preference is stored.\n *\n * State holds only the saved preference; the visible order is derived during\n * render. Syncing derived order back into state via an effect looped forever\n * when a host recreated `defaultGroupIds` with different content on every\n * render (#4386), so no effect writes state from `defaultGroupIds` here.\n */\nexport function useGroupOrder(pageType: string, defaultGroupIds: string[]) {\n const [savedOrder, setSavedOrder] = React.useState<string[] | null>(null)\n\n React.useEffect(() => {\n const saved = readJsonFromLocalStorage<string[] | null>(getStorageKey(pageType), null)\n setSavedOrder(Array.isArray(saved) ? saved : null)\n }, [pageType])\n\n const mergedIds = React.useMemo(\n () => (savedOrder ? mergeOrder(savedOrder, defaultGroupIds) : defaultGroupIds),\n [defaultGroupIds, savedOrder],\n )\n\n const stableIdsRef = React.useRef(mergedIds)\n if (!arraysEqual(stableIdsRef.current, mergedIds)) {\n stableIdsRef.current = mergedIds\n }\n const orderedIds = stableIdsRef.current\n\n const reorder = React.useCallback(\n (fromIndex: number, toIndex: number) => {\n const next = [...stableIdsRef.current]\n const [moved] = next.splice(fromIndex, 1)\n next.splice(toIndex, 0, moved)\n writeJsonToLocalStorage(getStorageKey(pageType), next)\n setSavedOrder(next)\n },\n [pageType],\n )\n\n return { orderedIds, reorder }\n}\n"],
5
- "mappings": ";AACA,YAAY,WAAW;AACvB;AAAA,EACE;AAAA,EACA;AAAA,OACK;AAEP,MAAM,iBAAiB;AAEvB,SAAS,cAAc,UAAkB;AACvC,SAAO,GAAG,cAAc,GAAG,QAAQ;AACrC;AAEA,SAAS,WAAW,OAAiB,UAA8B;AACjE,QAAM,QAAQ,IAAI,IAAI,QAAQ;AAC9B,QAAM,SAAS,MAAM,OAAO,CAAC,OAAO,MAAM,IAAI,EAAE,CAAC;AACjD,aAAW,MAAM,UAAU;AACzB,QAAI,CAAC,OAAO,SAAS,EAAE,EAAG,QAAO,KAAK,EAAE;AAAA,EAC1C;AACA,SAAO;AACT;AAEA,SAAS,YAAY,GAAa,GAAsB;AACtD,MAAI,EAAE,WAAW,EAAE,OAAQ,QAAO;AAClC,WAAS,IAAI,GAAG,IAAI,EAAE,QAAQ,KAAK,GAAG;AACpC,QAAI,EAAE,CAAC,MAAM,EAAE,CAAC,EAAG,QAAO;AAAA,EAC5B;AACA,SAAO;AACT;AAWO,SAAS,cAAc,UAAkB,iBAA2B;AACzE,QAAM,CAAC,YAAY,aAAa,IAAI,MAAM,SAA0B,IAAI;AAExE,QAAM,UAAU,MAAM;AACpB,UAAM,QAAQ,yBAA0C,cAAc,QAAQ,GAAG,IAAI;AACrF,kBAAc,MAAM,QAAQ,KAAK,IAAI,QAAQ,IAAI;AAAA,EACnD,GAAG,CAAC,QAAQ,CAAC;AAEb,QAAM,YAAY,MAAM;AAAA,IACtB,MAAO,aAAa,WAAW,YAAY,eAAe,IAAI;AAAA,IAC9D,CAAC,iBAAiB,UAAU;AAAA,EAC9B;AAEA,QAAM,eAAe,MAAM,OAAO,SAAS;AAC3C,MAAI,CAAC,YAAY,aAAa,SAAS,SAAS,GAAG;AACjD,iBAAa,UAAU;AAAA,EACzB;AACA,QAAM,aAAa,aAAa;AAEhC,QAAM,UAAU,MAAM;AAAA,IACpB,CAAC,WAAmB,YAAoB;AACtC,YAAM,OAAO,CAAC,GAAG,aAAa,OAAO;AACrC,YAAM,CAAC,KAAK,IAAI,KAAK,OAAO,WAAW,CAAC;AACxC,WAAK,OAAO,SAAS,GAAG,KAAK;AAC7B,8BAAwB,cAAc,QAAQ,GAAG,IAAI;AACrD,oBAAc,IAAI;AAAA,IACpB;AAAA,IACA,CAAC,QAAQ;AAAA,EACX;AAEA,SAAO,EAAE,YAAY,QAAQ;AAC/B;",
4
+ "sourcesContent": ["'use client'\nimport * as React from 'react'\nimport {\n readJsonFromLocalStorage,\n writeJsonToLocalStorage,\n} from '@open-mercato/shared/lib/browser/safeLocalStorage'\n\nconst STORAGE_PREFIX = 'om:group-order:'\n\nfunction getStorageKey(pageType: string) {\n return `${STORAGE_PREFIX}${pageType}`\n}\n\nfunction mergeOrder(saved: string[], defaults: string[]): string[] {\n const known = new Set(defaults)\n const result = saved.filter((id) => known.has(id))\n for (const id of defaults) {\n if (!result.includes(id)) result.push(id)\n }\n return result\n}\n\nfunction arraysEqual(a: string[], b: string[]): boolean {\n if (a.length !== b.length) return false\n for (let i = 0; i < a.length; i += 1) {\n if (a[i] !== b[i]) return false\n }\n return true\n}\n\n/**\n * Returns the group IDs in the user's preferred order.\n * Falls back to the default order when no preference is stored.\n *\n * State holds only the saved preference; the visible order is derived during\n * render. Syncing derived order back into state via an effect looped forever\n * when a host recreated `defaultGroupIds` with different content on every\n * render (#4386), so no effect writes state from `defaultGroupIds` here.\n *\n * `stableIdsRef` is mutated during render, which React's render-purity rule\n * normally forbids. It is safe here because the guard swaps the reference only\n * when the *content* differs: a concurrent render that React discards can leave\n * behind a different array identity, but never a content-stale value, and the\n * render that commits always leaves `stableIdsRef.current` content-equal to its\n * own `mergedIds`. `reorder` advances the same ref from its event handler so\n * that several reorders within one commit compose instead of overwriting each\n * other; a discarded state update leaves the ref ahead of `savedOrder` only\n * until the next render's content guard pulls it back (#4691).\n */\nexport function useGroupOrder(pageType: string, defaultGroupIds: string[]) {\n const [savedOrder, setSavedOrder] = React.useState<string[] | null>(null)\n\n React.useEffect(() => {\n const saved = readJsonFromLocalStorage<string[] | null>(getStorageKey(pageType), null)\n setSavedOrder(Array.isArray(saved) ? saved : null)\n }, [pageType])\n\n const mergedIds = savedOrder ? mergeOrder(savedOrder, defaultGroupIds) : defaultGroupIds\n\n const stableIdsRef = React.useRef(mergedIds)\n if (!arraysEqual(stableIdsRef.current, mergedIds)) {\n stableIdsRef.current = mergedIds\n }\n const orderedIds = stableIdsRef.current\n\n const reorder = React.useCallback(\n (fromIndex: number, toIndex: number) => {\n const next = [...stableIdsRef.current]\n const [moved] = next.splice(fromIndex, 1)\n next.splice(toIndex, 0, moved)\n stableIdsRef.current = next\n writeJsonToLocalStorage(getStorageKey(pageType), next)\n setSavedOrder(next)\n },\n [pageType],\n )\n\n return { orderedIds, reorder }\n}\n"],
5
+ "mappings": ";AACA,YAAY,WAAW;AACvB;AAAA,EACE;AAAA,EACA;AAAA,OACK;AAEP,MAAM,iBAAiB;AAEvB,SAAS,cAAc,UAAkB;AACvC,SAAO,GAAG,cAAc,GAAG,QAAQ;AACrC;AAEA,SAAS,WAAW,OAAiB,UAA8B;AACjE,QAAM,QAAQ,IAAI,IAAI,QAAQ;AAC9B,QAAM,SAAS,MAAM,OAAO,CAAC,OAAO,MAAM,IAAI,EAAE,CAAC;AACjD,aAAW,MAAM,UAAU;AACzB,QAAI,CAAC,OAAO,SAAS,EAAE,EAAG,QAAO,KAAK,EAAE;AAAA,EAC1C;AACA,SAAO;AACT;AAEA,SAAS,YAAY,GAAa,GAAsB;AACtD,MAAI,EAAE,WAAW,EAAE,OAAQ,QAAO;AAClC,WAAS,IAAI,GAAG,IAAI,EAAE,QAAQ,KAAK,GAAG;AACpC,QAAI,EAAE,CAAC,MAAM,EAAE,CAAC,EAAG,QAAO;AAAA,EAC5B;AACA,SAAO;AACT;AAqBO,SAAS,cAAc,UAAkB,iBAA2B;AACzE,QAAM,CAAC,YAAY,aAAa,IAAI,MAAM,SAA0B,IAAI;AAExE,QAAM,UAAU,MAAM;AACpB,UAAM,QAAQ,yBAA0C,cAAc,QAAQ,GAAG,IAAI;AACrF,kBAAc,MAAM,QAAQ,KAAK,IAAI,QAAQ,IAAI;AAAA,EACnD,GAAG,CAAC,QAAQ,CAAC;AAEb,QAAM,YAAY,aAAa,WAAW,YAAY,eAAe,IAAI;AAEzE,QAAM,eAAe,MAAM,OAAO,SAAS;AAC3C,MAAI,CAAC,YAAY,aAAa,SAAS,SAAS,GAAG;AACjD,iBAAa,UAAU;AAAA,EACzB;AACA,QAAM,aAAa,aAAa;AAEhC,QAAM,UAAU,MAAM;AAAA,IACpB,CAAC,WAAmB,YAAoB;AACtC,YAAM,OAAO,CAAC,GAAG,aAAa,OAAO;AACrC,YAAM,CAAC,KAAK,IAAI,KAAK,OAAO,WAAW,CAAC;AACxC,WAAK,OAAO,SAAS,GAAG,KAAK;AAC7B,mBAAa,UAAU;AACvB,8BAAwB,cAAc,QAAQ,GAAG,IAAI;AACrD,oBAAc,IAAI;AAAA,IACpB;AAAA,IACA,CAAC,QAAQ;AAAA,EACX;AAEA,SAAO,EAAE,YAAY,QAAQ;AAC/B;",
6
6
  "names": []
7
7
  }
@@ -3,6 +3,22 @@ import { apiFetch } from "./api.js";
3
3
  import { raiseCrudError, readJsonSafe } from "./serverErrors.js";
4
4
  import { createScopedHeaderStack } from "./scopedHeaderStack.js";
5
5
  const scopedRequestHeaders = createScopedHeaderStack();
6
+ function isAbortError(error) {
7
+ return typeof error === "object" && error !== null && error.name === "AbortError";
8
+ }
9
+ function createAbortError() {
10
+ if (typeof DOMException === "function") {
11
+ return new DOMException("[internal] The operation was aborted.", "AbortError");
12
+ }
13
+ const error = new Error("[internal] The operation was aborted.");
14
+ error.name = "AbortError";
15
+ return error;
16
+ }
17
+ function resolveAbortSignal(input, init) {
18
+ if (init?.signal) return init.signal;
19
+ if (typeof Request !== "undefined" && input instanceof Request) return input.signal ?? null;
20
+ return null;
21
+ }
6
22
  function mergeHeaders(base, extra) {
7
23
  if (!base) return extra;
8
24
  if (typeof Headers !== "undefined" && base instanceof Headers) {
@@ -31,9 +47,13 @@ async function apiCall(input, init, options) {
31
47
  const source = typeof response.clone === "function" ? response.clone() : response;
32
48
  if (parser) result = await parser(source);
33
49
  else result = await readJsonSafe(source, fallback);
34
- } catch {
50
+ } catch (err) {
51
+ if (isAbortError(err)) throw err;
35
52
  result = fallback;
36
53
  }
54
+ if (result == null && resolveAbortSignal(input, init)?.aborted) {
55
+ throw createAbortError();
56
+ }
37
57
  return {
38
58
  ok: response.ok,
39
59
  status: response.status,
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/backend/utils/apiCall.ts"],
4
- "sourcesContent": ["\"use client\"\n\nimport { apiFetch } from './api'\nimport { raiseCrudError, readJsonSafe } from './serverErrors'\nimport { createScopedHeaderStack } from './scopedHeaderStack'\n\nexport type ApiCallOptions<TReturn> = {\n parse?: (res: Response) => Promise<TReturn | null>\n fallback?: TReturn | null\n}\n\nexport type ApiCallResult<TReturn> = {\n ok: boolean\n status: number\n result: TReturn | null\n response: Response\n cacheStatus: 'hit' | 'miss' | null\n}\n\nconst scopedRequestHeaders = createScopedHeaderStack()\n\nfunction mergeHeaders(base: HeadersInit | undefined, extra: Record<string, string>): HeadersInit {\n if (!base) return extra\n if (typeof Headers !== 'undefined' && base instanceof Headers) {\n const merged = new Headers(base)\n Object.entries(extra).forEach(([key, value]) => merged.set(key, value))\n return merged\n }\n if (Array.isArray(base)) {\n return [...base, ...Object.entries(extra)]\n }\n return { ...(base as Record<string, string>), ...extra }\n}\n\nexport async function withScopedApiRequestHeaders<T>(\n headers: Record<string, string>,\n run: () => Promise<T>,\n): Promise<T> {\n return scopedRequestHeaders.withScopedHeaders(headers, run)\n}\n\nexport async function apiCall<TReturn = Record<string, unknown>>(\n input: RequestInfo | URL,\n init?: RequestInit,\n options?: ApiCallOptions<TReturn>,\n): Promise<ApiCallResult<TReturn>> {\n const scopedHeaders = scopedRequestHeaders.resolveScopedHeaders()\n const requestInit = Object.keys(scopedHeaders).length > 0\n ? { ...(init ?? {}), headers: mergeHeaders(init?.headers, scopedHeaders) }\n : init\n const response = await apiFetch(input, requestInit)\n const parser = options?.parse\n const fallback = options?.fallback ?? null\n let result: TReturn | null = null\n const rawCacheStatus =\n response.headers?.get?.('x-om-cache') ??\n response.headers?.get?.('x-cache-status') ??\n null\n const cacheStatus = rawCacheStatus === 'hit' || rawCacheStatus === 'miss' ? rawCacheStatus : null\n try {\n const source = typeof (response as Response & { clone?: () => Response }).clone === 'function'\n ? response.clone()\n : response\n if (parser) result = await parser(source)\n else result = await readJsonSafe<TReturn>(source, fallback)\n } catch {\n result = fallback\n }\n return {\n ok: response.ok,\n status: response.status,\n result,\n response,\n cacheStatus,\n }\n}\n\nexport type ApiCallOrThrowOptions<TReturn> = ApiCallOptions<TReturn> & {\n errorMessage?: string\n}\n\nexport async function apiCallOrThrow<TReturn = Record<string, unknown>>(\n input: RequestInfo | URL,\n init?: RequestInit,\n options?: ApiCallOrThrowOptions<TReturn>,\n): Promise<ApiCallResult<TReturn>> {\n const { errorMessage, ...callOptions } = options ?? {}\n const call = await apiCall<TReturn>(input, init, callOptions)\n if (!call.ok) {\n await raiseCrudError(call.response, errorMessage)\n }\n return call\n}\n\nexport type ReadApiResultOrThrowOptions<TReturn> = ApiCallOrThrowOptions<TReturn> & {\n allowNullResult?: boolean\n emptyResultMessage?: string\n}\n\nexport async function readApiResultOrThrow<TReturn = Record<string, unknown>>(\n input: RequestInfo | URL,\n init?: RequestInit,\n options?: ReadApiResultOrThrowOptions<TReturn> & { allowNullResult?: false },\n): Promise<TReturn>\nexport async function readApiResultOrThrow<TReturn = Record<string, unknown>>(\n input: RequestInfo | URL,\n init: RequestInit | undefined,\n options: ReadApiResultOrThrowOptions<TReturn> & { allowNullResult: true },\n): Promise<TReturn | null>\nexport async function readApiResultOrThrow<TReturn = Record<string, unknown>>(\n input: RequestInfo | URL,\n init?: RequestInit,\n options?: ReadApiResultOrThrowOptions<TReturn>,\n): Promise<TReturn | null> {\n const { allowNullResult = false, emptyResultMessage, ...callOptions } = options ?? {}\n const call = await apiCallOrThrow<TReturn>(input, init, callOptions)\n if (call.result == null && !allowNullResult) {\n const fallback =\n emptyResultMessage ?? callOptions.errorMessage ?? `Missing response payload (${call.status})`\n throw new Error(fallback)\n }\n return call.result\n}\n"],
5
- "mappings": ";AAEA,SAAS,gBAAgB;AACzB,SAAS,gBAAgB,oBAAoB;AAC7C,SAAS,+BAA+B;AAexC,MAAM,uBAAuB,wBAAwB;AAErD,SAAS,aAAa,MAA+B,OAA4C;AAC/F,MAAI,CAAC,KAAM,QAAO;AAClB,MAAI,OAAO,YAAY,eAAe,gBAAgB,SAAS;AAC7D,UAAM,SAAS,IAAI,QAAQ,IAAI;AAC/B,WAAO,QAAQ,KAAK,EAAE,QAAQ,CAAC,CAAC,KAAK,KAAK,MAAM,OAAO,IAAI,KAAK,KAAK,CAAC;AACtE,WAAO;AAAA,EACT;AACA,MAAI,MAAM,QAAQ,IAAI,GAAG;AACvB,WAAO,CAAC,GAAG,MAAM,GAAG,OAAO,QAAQ,KAAK,CAAC;AAAA,EAC3C;AACA,SAAO,EAAE,GAAI,MAAiC,GAAG,MAAM;AACzD;AAEA,eAAsB,4BACpB,SACA,KACY;AACZ,SAAO,qBAAqB,kBAAkB,SAAS,GAAG;AAC5D;AAEA,eAAsB,QACpB,OACA,MACA,SACiC;AACjC,QAAM,gBAAgB,qBAAqB,qBAAqB;AAChE,QAAM,cAAc,OAAO,KAAK,aAAa,EAAE,SAAS,IACpD,EAAE,GAAI,QAAQ,CAAC,GAAI,SAAS,aAAa,MAAM,SAAS,aAAa,EAAE,IACvE;AACJ,QAAM,WAAW,MAAM,SAAS,OAAO,WAAW;AAClD,QAAM,SAAS,SAAS;AACxB,QAAM,WAAW,SAAS,YAAY;AACtC,MAAI,SAAyB;AAC7B,QAAM,iBACJ,SAAS,SAAS,MAAM,YAAY,KACpC,SAAS,SAAS,MAAM,gBAAgB,KACxC;AACF,QAAM,cAAc,mBAAmB,SAAS,mBAAmB,SAAS,iBAAiB;AAC7F,MAAI;AACF,UAAM,SAAS,OAAQ,SAAmD,UAAU,aAChF,SAAS,MAAM,IACf;AACJ,QAAI,OAAQ,UAAS,MAAM,OAAO,MAAM;AAAA,QACnC,UAAS,MAAM,aAAsB,QAAQ,QAAQ;AAAA,EAC5D,QAAQ;AACN,aAAS;AAAA,EACX;AACA,SAAO;AAAA,IACL,IAAI,SAAS;AAAA,IACb,QAAQ,SAAS;AAAA,IACjB;AAAA,IACA;AAAA,IACA;AAAA,EACF;AACF;AAMA,eAAsB,eACpB,OACA,MACA,SACiC;AACjC,QAAM,EAAE,cAAc,GAAG,YAAY,IAAI,WAAW,CAAC;AACrD,QAAM,OAAO,MAAM,QAAiB,OAAO,MAAM,WAAW;AAC5D,MAAI,CAAC,KAAK,IAAI;AACZ,UAAM,eAAe,KAAK,UAAU,YAAY;AAAA,EAClD;AACA,SAAO;AACT;AAiBA,eAAsB,qBACpB,OACA,MACA,SACyB;AACzB,QAAM,EAAE,kBAAkB,OAAO,oBAAoB,GAAG,YAAY,IAAI,WAAW,CAAC;AACpF,QAAM,OAAO,MAAM,eAAwB,OAAO,MAAM,WAAW;AACnE,MAAI,KAAK,UAAU,QAAQ,CAAC,iBAAiB;AAC3C,UAAM,WACJ,sBAAsB,YAAY,gBAAgB,6BAA6B,KAAK,MAAM;AAC5F,UAAM,IAAI,MAAM,QAAQ;AAAA,EAC1B;AACA,SAAO,KAAK;AACd;",
4
+ "sourcesContent": ["\"use client\"\n\nimport { apiFetch } from './api'\nimport { raiseCrudError, readJsonSafe } from './serverErrors'\nimport { createScopedHeaderStack } from './scopedHeaderStack'\n\nexport type ApiCallOptions<TReturn> = {\n parse?: (res: Response) => Promise<TReturn | null>\n fallback?: TReturn | null\n}\n\nexport type ApiCallResult<TReturn> = {\n ok: boolean\n status: number\n result: TReturn | null\n response: Response\n cacheStatus: 'hit' | 'miss' | null\n}\n\nconst scopedRequestHeaders = createScopedHeaderStack()\n\nfunction isAbortError(error: unknown): boolean {\n return typeof error === 'object'\n && error !== null\n && (error as { name?: unknown }).name === 'AbortError'\n}\n\nfunction createAbortError(): Error {\n if (typeof DOMException === 'function') {\n return new DOMException('[internal] The operation was aborted.', 'AbortError')\n }\n const error = new Error('[internal] The operation was aborted.')\n error.name = 'AbortError'\n return error\n}\n\nfunction resolveAbortSignal(input: RequestInfo | URL, init?: RequestInit): AbortSignal | null {\n if (init?.signal) return init.signal\n if (typeof Request !== 'undefined' && input instanceof Request) return input.signal ?? null\n return null\n}\n\nfunction mergeHeaders(base: HeadersInit | undefined, extra: Record<string, string>): HeadersInit {\n if (!base) return extra\n if (typeof Headers !== 'undefined' && base instanceof Headers) {\n const merged = new Headers(base)\n Object.entries(extra).forEach(([key, value]) => merged.set(key, value))\n return merged\n }\n if (Array.isArray(base)) {\n return [...base, ...Object.entries(extra)]\n }\n return { ...(base as Record<string, string>), ...extra }\n}\n\nexport async function withScopedApiRequestHeaders<T>(\n headers: Record<string, string>,\n run: () => Promise<T>,\n): Promise<T> {\n return scopedRequestHeaders.withScopedHeaders(headers, run)\n}\n\nexport async function apiCall<TReturn = Record<string, unknown>>(\n input: RequestInfo | URL,\n init?: RequestInit,\n options?: ApiCallOptions<TReturn>,\n): Promise<ApiCallResult<TReturn>> {\n const scopedHeaders = scopedRequestHeaders.resolveScopedHeaders()\n const requestInit = Object.keys(scopedHeaders).length > 0\n ? { ...(init ?? {}), headers: mergeHeaders(init?.headers, scopedHeaders) }\n : init\n const response = await apiFetch(input, requestInit)\n const parser = options?.parse\n const fallback = options?.fallback ?? null\n let result: TReturn | null = null\n const rawCacheStatus =\n response.headers?.get?.('x-om-cache') ??\n response.headers?.get?.('x-cache-status') ??\n null\n const cacheStatus = rawCacheStatus === 'hit' || rawCacheStatus === 'miss' ? rawCacheStatus : null\n try {\n const source = typeof (response as Response & { clone?: () => Response }).clone === 'function'\n ? response.clone()\n : response\n if (parser) result = await parser(source)\n else result = await readJsonSafe<TReturn>(source, fallback)\n } catch (err) {\n if (isAbortError(err)) throw err\n result = fallback\n }\n // `readJsonSafe` swallows the abort that cancels an in-flight body read, so a\n // request aborted after its response headers arrived would otherwise look like\n // a successful call that returned an empty payload.\n if (result == null && resolveAbortSignal(input, init)?.aborted) {\n throw createAbortError()\n }\n return {\n ok: response.ok,\n status: response.status,\n result,\n response,\n cacheStatus,\n }\n}\n\nexport type ApiCallOrThrowOptions<TReturn> = ApiCallOptions<TReturn> & {\n errorMessage?: string\n}\n\nexport async function apiCallOrThrow<TReturn = Record<string, unknown>>(\n input: RequestInfo | URL,\n init?: RequestInit,\n options?: ApiCallOrThrowOptions<TReturn>,\n): Promise<ApiCallResult<TReturn>> {\n const { errorMessage, ...callOptions } = options ?? {}\n const call = await apiCall<TReturn>(input, init, callOptions)\n if (!call.ok) {\n await raiseCrudError(call.response, errorMessage)\n }\n return call\n}\n\nexport type ReadApiResultOrThrowOptions<TReturn> = ApiCallOrThrowOptions<TReturn> & {\n allowNullResult?: boolean\n emptyResultMessage?: string\n}\n\nexport async function readApiResultOrThrow<TReturn = Record<string, unknown>>(\n input: RequestInfo | URL,\n init?: RequestInit,\n options?: ReadApiResultOrThrowOptions<TReturn> & { allowNullResult?: false },\n): Promise<TReturn>\nexport async function readApiResultOrThrow<TReturn = Record<string, unknown>>(\n input: RequestInfo | URL,\n init: RequestInit | undefined,\n options: ReadApiResultOrThrowOptions<TReturn> & { allowNullResult: true },\n): Promise<TReturn | null>\nexport async function readApiResultOrThrow<TReturn = Record<string, unknown>>(\n input: RequestInfo | URL,\n init?: RequestInit,\n options?: ReadApiResultOrThrowOptions<TReturn>,\n): Promise<TReturn | null> {\n const { allowNullResult = false, emptyResultMessage, ...callOptions } = options ?? {}\n const call = await apiCallOrThrow<TReturn>(input, init, callOptions)\n if (call.result == null && !allowNullResult) {\n const fallback =\n emptyResultMessage ?? callOptions.errorMessage ?? `Missing response payload (${call.status})`\n throw new Error(fallback)\n }\n return call.result\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/utils/customFieldDefs.ts"],
4
- "sourcesContent": ["import * as React from 'react'\nimport { useQuery, type UseQueryResult, type QueryClient } from '@tanstack/react-query'\nimport { readApiResultOrThrow } from './apiCall'\nimport type { CustomFieldOptionDto } from '@open-mercato/shared/modules/entities/options'\n\nexport type CustomFieldDefDto = {\n entityId?: string\n key: string\n kind: string\n label?: string\n description?: string\n options?: CustomFieldOptionDto[]\n optionsUrl?: string\n multi?: boolean\n filterable?: boolean\n formEditable?: boolean\n listVisible?: boolean\n editor?: string\n input?: string\n priority?: number\n fieldset?: string\n fieldsets?: string[]\n group?: { code: string; title?: string; hint?: string }\n // attachments-specific config\n maxAttachmentSizeMb?: number\n acceptExtensions?: string[]\n // phone-specific config\n defaultCountryIso2?: string\n // optional validation rules\n validation?: Array<\n | { rule: 'required'; message: string }\n | { rule: 'date'; message: string }\n | { rule: 'integer'; message: string }\n | { rule: 'float'; message: string }\n | { rule: 'lt' | 'lte' | 'gt' | 'gte'; param: number; message: string }\n | { rule: 'eq' | 'ne'; param: any; message: string }\n | { rule: 'regex'; param: string; message: string }\n >\n dictionaryId?: string\n dictionaryInlineCreate?: boolean\n defaultValue?: string | number | boolean | null\n}\n\nexport type CustomFieldsetGroupDto = {\n code: string\n title?: string\n hint?: string\n}\n\nexport type CustomFieldsetDto = {\n code: string\n label: string\n icon?: string\n description?: string\n groups?: CustomFieldsetGroupDto[]\n}\n\nexport type CustomFieldDefinitionsPayload = {\n items?: CustomFieldDefDto[]\n fieldsetsByEntity?: Record<string, CustomFieldsetDto[]>\n entitySettings?: Record<string, { singleFieldsetPerRecord?: boolean }>\n}\n\nexport function normalizeEntityIds(entityIds: string | string[] | null | undefined): string[] {\n if (entityIds == null) return []\n const list = Array.isArray(entityIds) ? entityIds : [entityIds]\n const dedup = new Set<string>()\n const normalized: string[] = []\n for (const raw of list) {\n const trimmed = String(raw ?? '').trim()\n if (!trimmed || dedup.has(trimmed)) continue\n dedup.add(trimmed)\n normalized.push(trimmed)\n }\n return normalized\n}\n\nexport type CustomFieldDefinitionQueryOptions = {\n fieldset?: string\n}\n\nfunction buildDefinitionsQuery(entityIds: string[], options?: CustomFieldDefinitionQueryOptions): string {\n const params = new URLSearchParams()\n entityIds.forEach((id) => {\n if (id) params.append('entityId', id)\n })\n if (options?.fieldset) params.set('fieldset', options.fieldset)\n return params.toString()\n}\n\ntype CustomFieldDefinitionsResponse = CustomFieldDefinitionsPayload\n\nfunction normalizeRecord<T>(value: unknown): Record<string, T[]> {\n if (!value || typeof value !== 'object') return {}\n const out: Record<string, T[]> = {}\n for (const [key, raw] of Object.entries(value as Record<string, unknown>)) {\n if (!Array.isArray(raw)) continue\n out[key] = raw as T[]\n }\n return out\n}\n\nfunction preparePayload(data: CustomFieldDefinitionsResponse | null | undefined): CustomFieldDefinitionsPayload {\n const items = Array.isArray(data?.items) ? [...data!.items] : []\n items.sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0))\n const fieldsetsByEntity = normalizeRecord<CustomFieldsetDto>(data?.fieldsetsByEntity)\n const entitySettings = (data?.entitySettings && typeof data.entitySettings === 'object'\n ? data.entitySettings\n : {}) as CustomFieldDefinitionsPayload['entitySettings']\n return { items, fieldsetsByEntity, entitySettings }\n}\n\nasync function readDefinitionsViaFetch(\n entityIds: string[],\n fetchImpl: typeof fetch,\n options?: CustomFieldDefinitionQueryOptions,\n): Promise<CustomFieldDefinitionsPayload> {\n const query = buildDefinitionsQuery(entityIds, options)\n const res = await fetchImpl(`/api/entities/definitions?${query}`, {\n headers: { 'content-type': 'application/json' },\n })\n const data = await res.json().catch(() => ({ items: [] }))\n return preparePayload(data)\n}\n\nasync function readDefinitionsViaApi(entityIds: string[], options?: CustomFieldDefinitionQueryOptions): Promise<CustomFieldDefinitionsPayload> {\n const query = buildDefinitionsQuery(entityIds, options)\n const payload = await readApiResultOrThrow<CustomFieldDefinitionsResponse>(\n `/api/entities/definitions?${query}`,\n { headers: { 'content-type': 'application/json' } },\n {\n errorMessage: 'Failed to load custom field definitions',\n fallback: { items: [] },\n },\n )\n return preparePayload(payload)\n}\n\nexport async function fetchCustomFieldDefinitionsPayload(\n entityIds: string | string[],\n fetchImpl?: typeof fetch,\n options?: CustomFieldDefinitionQueryOptions,\n): Promise<CustomFieldDefinitionsPayload> {\n const filtered = normalizeEntityIds(entityIds)\n if (!filtered.length) return { items: [] }\n return fetchImpl\n ? await readDefinitionsViaFetch(filtered, fetchImpl, options)\n : await readDefinitionsViaApi(filtered, options)\n}\n\nexport async function fetchCustomFieldDefs(\n entityIds: string | string[],\n fetchImpl?: typeof fetch,\n options?: CustomFieldDefinitionQueryOptions,\n): Promise<CustomFieldDefDto[]> {\n const payload = await fetchCustomFieldDefinitionsPayload(entityIds, fetchImpl, options)\n return payload.items ?? []\n}\n\nexport type UseCustomFieldDefsOptions<TData = CustomFieldDefDto[]> = {\n enabled?: boolean\n staleTime?: number\n gcTime?: number\n /** @deprecated Custom fetch implementations are no longer needed. */\n fetchImpl?: typeof fetch\n keyExtras?: Array<string | number | boolean | null | undefined>\n fieldset?: string\n select?: (data: CustomFieldDefDto[]) => TData\n}\n\nexport function useCustomFieldDefs<TData = CustomFieldDefDto[]>(\n entityIds: string | string[] | null | undefined,\n options: UseCustomFieldDefsOptions<TData> = {}\n): UseQueryResult<TData> {\n const {\n enabled: enabledOption = true,\n staleTime,\n gcTime,\n keyExtras,\n fetchImpl,\n fieldset,\n } = options\n const normalizedIds = React.useMemo(() => normalizeEntityIds(entityIds), [entityIds])\n const idsSignature = React.useMemo(() => JSON.stringify(normalizedIds), [normalizedIds])\n const extrasSignature = React.useMemo(() => JSON.stringify(keyExtras ?? []), [keyExtras])\n const normalizedFieldset = typeof fieldset === 'string' && fieldset.trim().length ? fieldset.trim() : null\n const queryKey = React.useMemo(\n () => ['customFieldDefs', ...(keyExtras ?? []), ...normalizedIds, `fieldset:${normalizedFieldset ?? 'default'}`],\n [idsSignature, extrasSignature, normalizedFieldset]\n )\n const enabled = enabledOption && normalizedIds.length > 0\n\n return useQuery<CustomFieldDefDto[], Error, TData>({\n queryKey,\n queryFn: () =>\n fetchCustomFieldDefs(\n normalizedIds,\n fetchImpl,\n normalizedFieldset ? { fieldset: normalizedFieldset } : undefined\n ),\n enabled,\n staleTime: staleTime ?? 5 * 60 * 1000,\n gcTime: gcTime ?? 30 * 60 * 1000,\n select: options.select,\n })\n}\n\nexport type CustomFieldVisibility = 'list' | 'form' | 'filter'\n\nexport function isDefVisible(def: CustomFieldDefDto, mode: CustomFieldVisibility): boolean {\n switch (mode) {\n case 'list':\n return def.listVisible !== false\n case 'form':\n return def.formEditable !== false\n case 'filter':\n return !!def.filterable\n default:\n return true\n }\n}\n\nexport function filterCustomFieldDefs(defs: CustomFieldDefDto[], mode: CustomFieldVisibility): CustomFieldDefDto[] {\n return defs\n .filter((d) => isDefVisible(d, mode))\n .sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0))\n}\n\nexport async function invalidateCustomFieldDefs(\n queryClient: QueryClient,\n entityIds?: string | string[] | null,\n): Promise<void> {\n const normalizedIds = normalizeEntityIds(entityIds)\n const targetPrefixes = new Set(['customFieldDefs', 'customFieldForms', 'dealFormFields'])\n if (!normalizedIds.length) {\n await queryClient.invalidateQueries({\n predicate: (query) => Array.isArray(query.queryKey) && typeof query.queryKey[0] === 'string' && targetPrefixes.has(query.queryKey[0] as string),\n })\n return\n }\n await queryClient.invalidateQueries({\n predicate: (query) => {\n if (!Array.isArray(query.queryKey)) return false\n const [prefix] = query.queryKey\n if (typeof prefix !== 'string' || !targetPrefixes.has(prefix)) return false\n return normalizedIds.every((id) => query.queryKey.includes(id))\n },\n })\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import * as React from 'react'\nimport { useQuery, type UseQueryResult, type QueryClient } from '@tanstack/react-query'\nimport { readApiResultOrThrow } from './apiCall'\nimport type { CustomFieldOptionDto } from '@open-mercato/shared/modules/entities/options'\n\nexport type CustomFieldDefDto = {\n entityId?: string\n key: string\n kind: string\n label?: string\n description?: string\n options?: CustomFieldOptionDto[]\n optionsUrl?: string\n multi?: boolean\n filterable?: boolean\n formEditable?: boolean\n listVisible?: boolean\n editor?: string\n input?: string\n priority?: number\n fieldset?: string\n fieldsets?: string[]\n group?: { code: string; title?: string; hint?: string }\n // attachments-specific config\n maxAttachmentSizeMb?: number\n acceptExtensions?: string[]\n // phone-specific config\n defaultCountryIso2?: string\n // optional validation rules\n validation?: Array<\n | { rule: 'required'; message: string }\n | { rule: 'date'; message: string }\n | { rule: 'integer'; message: string }\n | { rule: 'float'; message: string }\n | { rule: 'lt' | 'lte' | 'gt' | 'gte'; param: number; message: string }\n | { rule: 'eq' | 'ne'; param: any; message: string }\n | { rule: 'regex'; param: string; message: string }\n >\n dictionaryId?: string\n dictionaryInlineCreate?: boolean\n defaultValue?: string | number | boolean | null\n relatedEntityId?: string\n}\n\nexport type CustomFieldsetGroupDto = {\n code: string\n title?: string\n hint?: string\n}\n\nexport type CustomFieldsetDto = {\n code: string\n label: string\n icon?: string\n description?: string\n groups?: CustomFieldsetGroupDto[]\n}\n\nexport type CustomFieldDefinitionsPayload = {\n items?: CustomFieldDefDto[]\n fieldsetsByEntity?: Record<string, CustomFieldsetDto[]>\n entitySettings?: Record<string, { singleFieldsetPerRecord?: boolean }>\n}\n\nexport function normalizeEntityIds(entityIds: string | string[] | null | undefined): string[] {\n if (entityIds == null) return []\n const list = Array.isArray(entityIds) ? entityIds : [entityIds]\n const dedup = new Set<string>()\n const normalized: string[] = []\n for (const raw of list) {\n const trimmed = String(raw ?? '').trim()\n if (!trimmed || dedup.has(trimmed)) continue\n dedup.add(trimmed)\n normalized.push(trimmed)\n }\n return normalized\n}\n\nexport type CustomFieldDefinitionQueryOptions = {\n fieldset?: string\n}\n\nfunction buildDefinitionsQuery(entityIds: string[], options?: CustomFieldDefinitionQueryOptions): string {\n const params = new URLSearchParams()\n entityIds.forEach((id) => {\n if (id) params.append('entityId', id)\n })\n if (options?.fieldset) params.set('fieldset', options.fieldset)\n return params.toString()\n}\n\ntype CustomFieldDefinitionsResponse = CustomFieldDefinitionsPayload\n\nfunction normalizeRecord<T>(value: unknown): Record<string, T[]> {\n if (!value || typeof value !== 'object') return {}\n const out: Record<string, T[]> = {}\n for (const [key, raw] of Object.entries(value as Record<string, unknown>)) {\n if (!Array.isArray(raw)) continue\n out[key] = raw as T[]\n }\n return out\n}\n\nfunction preparePayload(data: CustomFieldDefinitionsResponse | null | undefined): CustomFieldDefinitionsPayload {\n const items = Array.isArray(data?.items) ? [...data!.items] : []\n items.sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0))\n const fieldsetsByEntity = normalizeRecord<CustomFieldsetDto>(data?.fieldsetsByEntity)\n const entitySettings = (data?.entitySettings && typeof data.entitySettings === 'object'\n ? data.entitySettings\n : {}) as CustomFieldDefinitionsPayload['entitySettings']\n return { items, fieldsetsByEntity, entitySettings }\n}\n\nasync function readDefinitionsViaFetch(\n entityIds: string[],\n fetchImpl: typeof fetch,\n options?: CustomFieldDefinitionQueryOptions,\n): Promise<CustomFieldDefinitionsPayload> {\n const query = buildDefinitionsQuery(entityIds, options)\n const res = await fetchImpl(`/api/entities/definitions?${query}`, {\n headers: { 'content-type': 'application/json' },\n })\n const data = await res.json().catch(() => ({ items: [] }))\n return preparePayload(data)\n}\n\nasync function readDefinitionsViaApi(entityIds: string[], options?: CustomFieldDefinitionQueryOptions): Promise<CustomFieldDefinitionsPayload> {\n const query = buildDefinitionsQuery(entityIds, options)\n const payload = await readApiResultOrThrow<CustomFieldDefinitionsResponse>(\n `/api/entities/definitions?${query}`,\n { headers: { 'content-type': 'application/json' } },\n {\n errorMessage: 'Failed to load custom field definitions',\n fallback: { items: [] },\n },\n )\n return preparePayload(payload)\n}\n\nexport async function fetchCustomFieldDefinitionsPayload(\n entityIds: string | string[],\n fetchImpl?: typeof fetch,\n options?: CustomFieldDefinitionQueryOptions,\n): Promise<CustomFieldDefinitionsPayload> {\n const filtered = normalizeEntityIds(entityIds)\n if (!filtered.length) return { items: [] }\n return fetchImpl\n ? await readDefinitionsViaFetch(filtered, fetchImpl, options)\n : await readDefinitionsViaApi(filtered, options)\n}\n\nexport async function fetchCustomFieldDefs(\n entityIds: string | string[],\n fetchImpl?: typeof fetch,\n options?: CustomFieldDefinitionQueryOptions,\n): Promise<CustomFieldDefDto[]> {\n const payload = await fetchCustomFieldDefinitionsPayload(entityIds, fetchImpl, options)\n return payload.items ?? []\n}\n\nexport type UseCustomFieldDefsOptions<TData = CustomFieldDefDto[]> = {\n enabled?: boolean\n staleTime?: number\n gcTime?: number\n /** @deprecated Custom fetch implementations are no longer needed. */\n fetchImpl?: typeof fetch\n keyExtras?: Array<string | number | boolean | null | undefined>\n fieldset?: string\n select?: (data: CustomFieldDefDto[]) => TData\n}\n\nexport function useCustomFieldDefs<TData = CustomFieldDefDto[]>(\n entityIds: string | string[] | null | undefined,\n options: UseCustomFieldDefsOptions<TData> = {}\n): UseQueryResult<TData> {\n const {\n enabled: enabledOption = true,\n staleTime,\n gcTime,\n keyExtras,\n fetchImpl,\n fieldset,\n } = options\n const normalizedIds = React.useMemo(() => normalizeEntityIds(entityIds), [entityIds])\n const idsSignature = React.useMemo(() => JSON.stringify(normalizedIds), [normalizedIds])\n const extrasSignature = React.useMemo(() => JSON.stringify(keyExtras ?? []), [keyExtras])\n const normalizedFieldset = typeof fieldset === 'string' && fieldset.trim().length ? fieldset.trim() : null\n const queryKey = React.useMemo(\n () => ['customFieldDefs', ...(keyExtras ?? []), ...normalizedIds, `fieldset:${normalizedFieldset ?? 'default'}`],\n [idsSignature, extrasSignature, normalizedFieldset]\n )\n const enabled = enabledOption && normalizedIds.length > 0\n\n return useQuery<CustomFieldDefDto[], Error, TData>({\n queryKey,\n queryFn: () =>\n fetchCustomFieldDefs(\n normalizedIds,\n fetchImpl,\n normalizedFieldset ? { fieldset: normalizedFieldset } : undefined\n ),\n enabled,\n staleTime: staleTime ?? 5 * 60 * 1000,\n gcTime: gcTime ?? 30 * 60 * 1000,\n select: options.select,\n })\n}\n\nexport type CustomFieldVisibility = 'list' | 'form' | 'filter'\n\nexport function isDefVisible(def: CustomFieldDefDto, mode: CustomFieldVisibility): boolean {\n switch (mode) {\n case 'list':\n return def.listVisible !== false\n case 'form':\n return def.formEditable !== false\n case 'filter':\n return !!def.filterable\n default:\n return true\n }\n}\n\nexport function filterCustomFieldDefs(defs: CustomFieldDefDto[], mode: CustomFieldVisibility): CustomFieldDefDto[] {\n return defs\n .filter((d) => isDefVisible(d, mode))\n .sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0))\n}\n\nexport async function invalidateCustomFieldDefs(\n queryClient: QueryClient,\n entityIds?: string | string[] | null,\n): Promise<void> {\n const normalizedIds = normalizeEntityIds(entityIds)\n const targetPrefixes = new Set(['customFieldDefs', 'customFieldForms', 'dealFormFields'])\n if (!normalizedIds.length) {\n await queryClient.invalidateQueries({\n predicate: (query) => Array.isArray(query.queryKey) && typeof query.queryKey[0] === 'string' && targetPrefixes.has(query.queryKey[0] as string),\n })\n return\n }\n await queryClient.invalidateQueries({\n predicate: (query) => {\n if (!Array.isArray(query.queryKey)) return false\n const [prefix] = query.queryKey\n if (typeof prefix !== 'string' || !targetPrefixes.has(prefix)) return false\n return normalizedIds.every((id) => query.queryKey.includes(id))\n },\n })\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -22,7 +22,8 @@ function LanguageSwitcher() {
22
22
  en: t("common.languages.english", "English"),
23
23
  pl: t("common.languages.polish", "Polski"),
24
24
  es: t("common.languages.spanish", "Espa\xF1ol"),
25
- de: t("common.languages.german", "Deutsch")
25
+ de: t("common.languages.german", "Deutsch"),
26
+ ko: t("common.languages.korean", "\uD55C\uAD6D\uC5B4")
26
27
  };
27
28
  async function setLocale(locale) {
28
29
  if (locale === current) return;
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/frontend/LanguageSwitcher.tsx"],
4
- "sourcesContent": ["\"use client\"\nimport { useId, useTransition } from 'react'\nimport { useLocale, useLocaleLocked, useT } from '@open-mercato/shared/lib/i18n/context'\nimport { useRouter } from 'next/navigation'\nimport { locales, type Locale } from '@open-mercato/shared/lib/i18n/config'\nimport {\n Select,\n SelectContent,\n SelectItem,\n SelectTrigger,\n SelectValue,\n} from '@open-mercato/ui/primitives/select'\n\nexport function LanguageSwitcher() {\n const current = useLocale()\n const localeLocked = useLocaleLocked()\n const t = useT()\n const router = useRouter()\n const [pending, startTransition] = useTransition()\n const selectId = useId()\n\n const languageLabels: Record<Locale, string> = {\n en: t('common.languages.english', 'English'),\n pl: t('common.languages.polish', 'Polski'),\n es: t('common.languages.spanish', 'Espa\u00F1ol'),\n de: t('common.languages.german', 'Deutsch'),\n }\n\n async function setLocale(locale: Locale) {\n if (locale === current) return\n try {\n const res = await fetch('/api/auth/locale', {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ locale }),\n })\n if (!res.ok) return\n startTransition(() => router.refresh())\n try {\n window.dispatchEvent(new Event('om:refresh-sidebar'))\n } catch {\n // Ignore if window is unavailable\n }\n } catch {\n // Ignore network errors; UX fallback keeps previous locale\n }\n }\n\n // Locale is pinned via OM_FORCE_LOCALE \u2014 switching is a no-op, so hide the control.\n if (localeLocked) return null\n\n return (\n <div className=\"flex items-center gap-2 text-xs text-muted-foreground\">\n <label htmlFor={selectId}>{t('common.language')}</label>\n <Select\n value={current}\n onValueChange={(value) => setLocale(value as Locale)}\n disabled={pending}\n >\n <SelectTrigger id={selectId} size=\"sm\">\n <SelectValue />\n </SelectTrigger>\n <SelectContent>\n {locales.map((locale) => (\n <SelectItem key={locale} value={locale}>\n {languageLabels[locale]}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n </div>\n )\n}\n"],
5
- "mappings": ";AAqDM,cACA,YADA;AApDN,SAAS,OAAO,qBAAqB;AACrC,SAAS,WAAW,iBAAiB,YAAY;AACjD,SAAS,iBAAiB;AAC1B,SAAS,eAA4B;AACrC;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AAEA,SAAS,mBAAmB;AACjC,QAAM,UAAU,UAAU;AAC1B,QAAM,eAAe,gBAAgB;AACrC,QAAM,IAAI,KAAK;AACf,QAAM,SAAS,UAAU;AACzB,QAAM,CAAC,SAAS,eAAe,IAAI,cAAc;AACjD,QAAM,WAAW,MAAM;AAEvB,QAAM,iBAAyC;AAAA,IAC7C,IAAI,EAAE,4BAA4B,SAAS;AAAA,IAC3C,IAAI,EAAE,2BAA2B,QAAQ;AAAA,IACzC,IAAI,EAAE,4BAA4B,YAAS;AAAA,IAC3C,IAAI,EAAE,2BAA2B,SAAS;AAAA,EAC5C;AAEA,iBAAe,UAAU,QAAgB;AACvC,QAAI,WAAW,QAAS;AACxB,QAAI;AACF,YAAM,MAAM,MAAM,MAAM,oBAAoB;AAAA,QAC1C,QAAQ;AAAA,QACR,SAAS,EAAE,gBAAgB,mBAAmB;AAAA,QAC9C,MAAM,KAAK,UAAU,EAAE,OAAO,CAAC;AAAA,MACjC,CAAC;AACD,UAAI,CAAC,IAAI,GAAI;AACb,sBAAgB,MAAM,OAAO,QAAQ,CAAC;AACtC,UAAI;AACF,eAAO,cAAc,IAAI,MAAM,oBAAoB,CAAC;AAAA,MACtD,QAAQ;AAAA,MAER;AAAA,IACF,QAAQ;AAAA,IAER;AAAA,EACF;AAGA,MAAI,aAAc,QAAO;AAEzB,SACE,qBAAC,SAAI,WAAU,yDACb;AAAA,wBAAC,WAAM,SAAS,UAAW,YAAE,iBAAiB,GAAE;AAAA,IAChD;AAAA,MAAC;AAAA;AAAA,QACC,OAAO;AAAA,QACP,eAAe,CAAC,UAAU,UAAU,KAAe;AAAA,QACnD,UAAU;AAAA,QAEV;AAAA,8BAAC,iBAAc,IAAI,UAAU,MAAK,MAChC,8BAAC,eAAY,GACf;AAAA,UACA,oBAAC,iBACE,kBAAQ,IAAI,CAAC,WACZ,oBAAC,cAAwB,OAAO,QAC7B,yBAAe,MAAM,KADP,MAEjB,CACD,GACH;AAAA;AAAA;AAAA,IACF;AAAA,KACF;AAEJ;",
4
+ "sourcesContent": ["\"use client\"\nimport { useId, useTransition } from 'react'\nimport { useLocale, useLocaleLocked, useT } from '@open-mercato/shared/lib/i18n/context'\nimport { useRouter } from 'next/navigation'\nimport { locales, type Locale } from '@open-mercato/shared/lib/i18n/config'\nimport {\n Select,\n SelectContent,\n SelectItem,\n SelectTrigger,\n SelectValue,\n} from '@open-mercato/ui/primitives/select'\n\nexport function LanguageSwitcher() {\n const current = useLocale()\n const localeLocked = useLocaleLocked()\n const t = useT()\n const router = useRouter()\n const [pending, startTransition] = useTransition()\n const selectId = useId()\n\n const languageLabels: Record<Locale, string> = {\n en: t('common.languages.english', 'English'),\n pl: t('common.languages.polish', 'Polski'),\n es: t('common.languages.spanish', 'Espa\u00F1ol'),\n de: t('common.languages.german', 'Deutsch'),\n ko: t('common.languages.korean', '\uD55C\uAD6D\uC5B4'),\n }\n\n async function setLocale(locale: Locale) {\n if (locale === current) return\n try {\n const res = await fetch('/api/auth/locale', {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ locale }),\n })\n if (!res.ok) return\n startTransition(() => router.refresh())\n try {\n window.dispatchEvent(new Event('om:refresh-sidebar'))\n } catch {\n // Ignore if window is unavailable\n }\n } catch {\n // Ignore network errors; UX fallback keeps previous locale\n }\n }\n\n // Locale is pinned via OM_FORCE_LOCALE \u2014 switching is a no-op, so hide the control.\n if (localeLocked) return null\n\n return (\n <div className=\"flex items-center gap-2 text-xs text-muted-foreground\">\n <label htmlFor={selectId}>{t('common.language')}</label>\n <Select\n value={current}\n onValueChange={(value) => setLocale(value as Locale)}\n disabled={pending}\n >\n <SelectTrigger id={selectId} size=\"sm\">\n <SelectValue />\n </SelectTrigger>\n <SelectContent>\n {locales.map((locale) => (\n <SelectItem key={locale} value={locale}>\n {languageLabels[locale]}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n </div>\n )\n}\n"],
5
+ "mappings": ";AAsDM,cACA,YADA;AArDN,SAAS,OAAO,qBAAqB;AACrC,SAAS,WAAW,iBAAiB,YAAY;AACjD,SAAS,iBAAiB;AAC1B,SAAS,eAA4B;AACrC;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AAEA,SAAS,mBAAmB;AACjC,QAAM,UAAU,UAAU;AAC1B,QAAM,eAAe,gBAAgB;AACrC,QAAM,IAAI,KAAK;AACf,QAAM,SAAS,UAAU;AACzB,QAAM,CAAC,SAAS,eAAe,IAAI,cAAc;AACjD,QAAM,WAAW,MAAM;AAEvB,QAAM,iBAAyC;AAAA,IAC7C,IAAI,EAAE,4BAA4B,SAAS;AAAA,IAC3C,IAAI,EAAE,2BAA2B,QAAQ;AAAA,IACzC,IAAI,EAAE,4BAA4B,YAAS;AAAA,IAC3C,IAAI,EAAE,2BAA2B,SAAS;AAAA,IAC1C,IAAI,EAAE,2BAA2B,oBAAK;AAAA,EACxC;AAEA,iBAAe,UAAU,QAAgB;AACvC,QAAI,WAAW,QAAS;AACxB,QAAI;AACF,YAAM,MAAM,MAAM,MAAM,oBAAoB;AAAA,QAC1C,QAAQ;AAAA,QACR,SAAS,EAAE,gBAAgB,mBAAmB;AAAA,QAC9C,MAAM,KAAK,UAAU,EAAE,OAAO,CAAC;AAAA,MACjC,CAAC;AACD,UAAI,CAAC,IAAI,GAAI;AACb,sBAAgB,MAAM,OAAO,QAAQ,CAAC;AACtC,UAAI;AACF,eAAO,cAAc,IAAI,MAAM,oBAAoB,CAAC;AAAA,MACtD,QAAQ;AAAA,MAER;AAAA,IACF,QAAQ;AAAA,IAER;AAAA,EACF;AAGA,MAAI,aAAc,QAAO;AAEzB,SACE,qBAAC,SAAI,WAAU,yDACb;AAAA,wBAAC,WAAM,SAAS,UAAW,YAAE,iBAAiB,GAAE;AAAA,IAChD;AAAA,MAAC;AAAA;AAAA,QACC,OAAO;AAAA,QACP,eAAe,CAAC,UAAU,UAAU,KAAe;AAAA,QACnD,UAAU;AAAA,QAEV;AAAA,8BAAC,iBAAc,IAAI,UAAU,MAAK,MAChC,8BAAC,eAAY,GACf;AAAA,UACA,oBAAC,iBACE,kBAAQ,IAAI,CAAC,WACZ,oBAAC,cAAwB,OAAO,QAC7B,yBAAe,MAAM,KADP,MAEjB,CACD,GACH;AAAA;AAAA;AAAA,IACF;AAAA,KACF;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.7-develop.6834.1.e76f4b8cbc",
3
+ "version": "0.6.7-develop.6851.1.c1680b00d8",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -155,16 +155,16 @@
155
155
  "remark-gfm": "^4.0.1"
156
156
  },
157
157
  "peerDependencies": {
158
- "@open-mercato/shared": "0.6.7-develop.6834.1.e76f4b8cbc",
158
+ "@open-mercato/shared": "0.6.7-develop.6851.1.c1680b00d8",
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
164
  "@figma/code-connect": "^1.3.4",
165
- "@open-mercato/shared": "0.6.7-develop.6834.1.e76f4b8cbc",
165
+ "@open-mercato/shared": "0.6.7-develop.6851.1.c1680b00d8",
166
166
  "@testing-library/dom": "^10.4.1",
167
- "@testing-library/jest-dom": "^6.9.1",
167
+ "@testing-library/jest-dom": "^7.0.0",
168
168
  "@testing-library/react": "^16.3.1",
169
169
  "@types/jest": "^30.0.0",
170
170
  "@types/react": "^19.2.17",
@@ -1699,18 +1699,31 @@ export function DataTable<T>({
1699
1699
  })
1700
1700
  }, [table, mergedColumns])
1701
1701
 
1702
- const initialVisibilityApplied = React.useRef(Boolean(mergedInitialSettings?.columnVisibility))
1702
+ // A stored perspective seeds `columnVisibility` at mount and wins outright over the
1703
+ // `meta.hidden` defaults, so the auto-hide pass is skipped entirely in that case.
1704
+ const visibilitySeededByStoredSettings = React.useRef(Boolean(mergedInitialSettings?.columnVisibility))
1705
+ // Auto-hiding is a per-column default, applied once per column — not an enforcement.
1706
+ // It cannot be latched by a single has-run boolean: columns arrive in waves, because
1707
+ // custom-field columns are built from definitions fetched asynchronously. The first
1708
+ // render carries no `cf_*` column at all, so a run-once pass found nothing to hide and
1709
+ // still burned the latch, leaving fields declared `listVisible: false` visible for the
1710
+ // rest of the session after a hard page load (#4859).
1711
+ // It also cannot lean on `columnVisibility` as the record of what has been decided:
1712
+ // `handleColumnChooserToggle` *deletes* a column's entry when the user turns it back on,
1713
+ // so a re-shown column is indistinguishable from one never seen and would be hidden again
1714
+ // on the next wave. Hence an explicit per-column record of what this pass has applied.
1715
+ const autoHiddenColumnIds = React.useRef<Set<string>>(new Set())
1703
1716
  React.useEffect(() => {
1704
- if (initialVisibilityApplied.current) return
1717
+ if (visibilitySeededByStoredSettings.current) return
1705
1718
  const hidden: VisibilityState = {}
1706
1719
  table.getAllLeafColumns().forEach((column) => {
1707
1720
  const hiddenMeta = (column.columnDef as any)?.meta?.hidden
1708
- if (hiddenMeta) hidden[column.id] = false
1721
+ if (!hiddenMeta || autoHiddenColumnIds.current.has(column.id)) return
1722
+ hidden[column.id] = false
1723
+ autoHiddenColumnIds.current.add(column.id)
1709
1724
  })
1710
- if (Object.keys(hidden).length) {
1711
- setColumnVisibility((prev) => ({ ...hidden, ...prev }))
1712
- }
1713
- initialVisibilityApplied.current = true
1725
+ if (!Object.keys(hidden).length) return
1726
+ setColumnVisibility((prev) => ({ ...hidden, ...prev }))
1714
1727
  }, [table, mergedColumns])
1715
1728
 
1716
1729
  const getCurrentSettings = React.useCallback((): PerspectiveSettings => {
@@ -29,6 +29,7 @@ const localeLabels: Record<Locale, string> = {
29
29
  de: 'Deutsch',
30
30
  es: 'Español',
31
31
  pl: 'Polski',
32
+ ko: '한국어',
32
33
  }
33
34
 
34
35
  export function ProfileDropdown({
@@ -0,0 +1,105 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+ import * as React from 'react'
5
+ import { render, screen, waitFor } from '@testing-library/react'
6
+ import type { ColumnDef } from '@tanstack/react-table'
7
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
8
+ import { I18nProvider } from '@open-mercato/shared/lib/i18n/context'
9
+ import { DataTable } from '../DataTable'
10
+
11
+ jest.mock('next/navigation', () => ({
12
+ useRouter: () => ({ push: jest.fn(), replace: jest.fn(), prefetch: jest.fn(), refresh: jest.fn() }),
13
+ }))
14
+
15
+ jest.mock('../injection/useInjectionDataWidgets', () => ({
16
+ useInjectionDataWidgets: () => ({ widgets: [], isLoading: false }),
17
+ }))
18
+
19
+ jest.mock('@open-mercato/shared/modules/widgets/injection-loader', () => ({
20
+ getInjectionRegistryVersion: () => 0,
21
+ subscribeToInjectionRegistryChanges: () => () => {},
22
+ loadInjectionWidgetsForSpot: jest.fn(async () => []),
23
+ loadInjectionDataWidgetsForSpot: jest.fn(async () => []),
24
+ }))
25
+
26
+ type Row = { id: string; name: string }
27
+
28
+ const ROWS: Row[] = [{ id: '1', name: 'Row one' }]
29
+
30
+ const BASE_COLUMNS: ColumnDef<Row>[] = [
31
+ { accessorKey: 'name', header: 'Name' },
32
+ ]
33
+
34
+ const COLUMNS_WITH_CUSTOM_FIELDS: ColumnDef<Row>[] = [
35
+ ...BASE_COLUMNS,
36
+ { accessorKey: 'cf_archived_note', header: 'Archived note', meta: { hidden: true } },
37
+ { accessorKey: 'cf_active_note', header: 'Active note' },
38
+ ]
39
+
40
+ function Harness({ columns, initialSettings }: { columns: ColumnDef<Row>[]; initialSettings?: unknown }) {
41
+ const queryClient = React.useMemo(
42
+ () => new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } }),
43
+ [],
44
+ )
45
+ return (
46
+ <QueryClientProvider client={queryClient}>
47
+ <I18nProvider locale="en" dict={{}}>
48
+ <DataTable
49
+ columns={columns as never}
50
+ data={ROWS as never}
51
+ {...(initialSettings
52
+ ? ({ perspective: { tableId: 'meta-hidden-late-columns', initialState: { initialSettings } } } as never)
53
+ : {})}
54
+ />
55
+ </I18nProvider>
56
+ </QueryClientProvider>
57
+ )
58
+ }
59
+
60
+ describe('DataTable — meta.hidden on columns that arrive after the first render', () => {
61
+ it('hides a meta.hidden column that only appears on a later render', async () => {
62
+ // The regression: the auto-hide pass used to be latched by a single boolean ref, so it
63
+ // ran on the first render — when no `cf_*` column existed yet — found nothing to hide,
64
+ // and never ran again. Custom fields declared `listVisible: false` stayed visible for
65
+ // the whole session after a hard page load, while a client-side navigation (definitions
66
+ // already in the query cache) rendered them correctly.
67
+ const { rerender } = render(<Harness columns={BASE_COLUMNS} />)
68
+ await waitFor(() => expect(screen.getByText('Name')).toBeTruthy())
69
+
70
+ rerender(<Harness columns={COLUMNS_WITH_CUSTOM_FIELDS} />)
71
+
72
+ await waitFor(() => expect(screen.queryByText('Archived note')).toBeNull())
73
+ })
74
+
75
+ it('leaves a late column without meta.hidden visible', async () => {
76
+ // Pins the other half of the contract, so the fix can never degrade into
77
+ // "columns that arrive late are dropped".
78
+ const { rerender } = render(<Harness columns={BASE_COLUMNS} />)
79
+ await waitFor(() => expect(screen.getByText('Name')).toBeTruthy())
80
+
81
+ rerender(<Harness columns={COLUMNS_WITH_CUSTOM_FIELDS} />)
82
+
83
+ await waitFor(() => expect(screen.getByText('Active note')).toBeTruthy())
84
+ })
85
+
86
+ it('hides a meta.hidden column that is present from the very first render', async () => {
87
+ // The behaviour the original latch was written for, which must survive the fix.
88
+ render(<Harness columns={COLUMNS_WITH_CUSTOM_FIELDS} />)
89
+
90
+ await waitFor(() => expect(screen.getByText('Active note')).toBeTruthy())
91
+ expect(screen.queryByText('Archived note')).toBeNull()
92
+ })
93
+
94
+ it('lets a stored perspective override the meta.hidden defaults', async () => {
95
+ // A saved perspective seeds columnVisibility at mount and wins outright.
96
+ render(
97
+ <Harness
98
+ columns={COLUMNS_WITH_CUSTOM_FIELDS}
99
+ initialSettings={{ columnVisibility: { cf_archived_note: true } }}
100
+ />,
101
+ )
102
+
103
+ await waitFor(() => expect(screen.getByText('Archived note')).toBeTruthy())
104
+ })
105
+ })
@@ -37,6 +37,16 @@ describe('useGroupOrder', () => {
37
37
  expect(result.current.orderedIds).toEqual(['a', 'd', 'b', 'c'])
38
38
  })
39
39
 
40
+ it('composes two reorder() calls made within a single commit', () => {
41
+ const { result } = renderHook(() => useGroupOrder('people', ['a', 'b', 'c']))
42
+ act(() => {
43
+ result.current.reorder(0, 2)
44
+ result.current.reorder(0, 1)
45
+ })
46
+ expect(result.current.orderedIds).toEqual(['c', 'b', 'a'])
47
+ expect(JSON.parse(localStorage.getItem('om:group-order:people')!)).toEqual(['c', 'b', 'a'])
48
+ })
49
+
40
50
  it('updates ordering when defaults change to include a new id', () => {
41
51
  const { result, rerender } = renderHook(
42
52
  ({ ids }) => useGroupOrder('p', ids),
@@ -36,6 +36,16 @@ function arraysEqual(a: string[], b: string[]): boolean {
36
36
  * render. Syncing derived order back into state via an effect looped forever
37
37
  * when a host recreated `defaultGroupIds` with different content on every
38
38
  * render (#4386), so no effect writes state from `defaultGroupIds` here.
39
+ *
40
+ * `stableIdsRef` is mutated during render, which React's render-purity rule
41
+ * normally forbids. It is safe here because the guard swaps the reference only
42
+ * when the *content* differs: a concurrent render that React discards can leave
43
+ * behind a different array identity, but never a content-stale value, and the
44
+ * render that commits always leaves `stableIdsRef.current` content-equal to its
45
+ * own `mergedIds`. `reorder` advances the same ref from its event handler so
46
+ * that several reorders within one commit compose instead of overwriting each
47
+ * other; a discarded state update leaves the ref ahead of `savedOrder` only
48
+ * until the next render's content guard pulls it back (#4691).
39
49
  */
40
50
  export function useGroupOrder(pageType: string, defaultGroupIds: string[]) {
41
51
  const [savedOrder, setSavedOrder] = React.useState<string[] | null>(null)
@@ -45,10 +55,7 @@ export function useGroupOrder(pageType: string, defaultGroupIds: string[]) {
45
55
  setSavedOrder(Array.isArray(saved) ? saved : null)
46
56
  }, [pageType])
47
57
 
48
- const mergedIds = React.useMemo(
49
- () => (savedOrder ? mergeOrder(savedOrder, defaultGroupIds) : defaultGroupIds),
50
- [defaultGroupIds, savedOrder],
51
- )
58
+ const mergedIds = savedOrder ? mergeOrder(savedOrder, defaultGroupIds) : defaultGroupIds
52
59
 
53
60
  const stableIdsRef = React.useRef(mergedIds)
54
61
  if (!arraysEqual(stableIdsRef.current, mergedIds)) {
@@ -61,6 +68,7 @@ export function useGroupOrder(pageType: string, defaultGroupIds: string[]) {
61
68
  const next = [...stableIdsRef.current]
62
69
  const [moved] = next.splice(fromIndex, 1)
63
70
  next.splice(toIndex, 0, moved)
71
+ stableIdsRef.current = next
64
72
  writeJsonToLocalStorage(getStorageKey(pageType), next)
65
73
  setSavedOrder(next)
66
74
  },