@open-mercato/ui 0.6.7-develop.6860.1.f796b22e80 → 0.6.7

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.
Files changed (167) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +2 -4
  3. package/agentic/standalone-guide.md +1 -1
  4. package/build.mjs +54 -2
  5. package/dist/ai/AiChat.js +2 -21
  6. package/dist/ai/AiChat.js.map +2 -2
  7. package/dist/backend/AppShell.js +4 -43
  8. package/dist/backend/AppShell.js.map +2 -2
  9. package/dist/backend/AuthSessionGuard.js +0 -14
  10. package/dist/backend/AuthSessionGuard.js.map +2 -2
  11. package/dist/backend/BackendChromeProvider.js +1 -6
  12. package/dist/backend/BackendChromeProvider.js.map +2 -2
  13. package/dist/backend/CrudForm.js +4 -6
  14. package/dist/backend/CrudForm.js.map +2 -2
  15. package/dist/backend/DataTable.js +18 -20
  16. package/dist/backend/DataTable.js.map +2 -2
  17. package/dist/backend/JsonBuilder.js +3 -3
  18. package/dist/backend/JsonBuilder.js.map +2 -2
  19. package/dist/backend/NextStepCallout.js +4 -4
  20. package/dist/backend/NextStepCallout.js.map +1 -1
  21. package/dist/backend/ProfileDropdown.js +1 -2
  22. package/dist/backend/ProfileDropdown.js.map +2 -2
  23. package/dist/backend/RowActions.js +2 -2
  24. package/dist/backend/RowActions.js.map +2 -2
  25. package/dist/backend/confirm-dialog/ConfirmDialog.js +1 -13
  26. package/dist/backend/confirm-dialog/ConfirmDialog.js.map +2 -2
  27. package/dist/backend/crud/useGroupOrder.js +21 -14
  28. package/dist/backend/crud/useGroupOrder.js.map +2 -2
  29. package/dist/backend/detail/AddressTiles.js +5 -4
  30. package/dist/backend/detail/AddressTiles.js.map +2 -2
  31. package/dist/backend/detail/AttachmentDeleteDialog.js +1 -1
  32. package/dist/backend/detail/AttachmentDeleteDialog.js.map +2 -2
  33. package/dist/backend/detail/AttachmentMetadataDialog.js.map +1 -1
  34. package/dist/backend/detail/AttachmentsSection.js +1 -1
  35. package/dist/backend/detail/AttachmentsSection.js.map +2 -2
  36. package/dist/backend/detail/NotesSection.js +1 -1
  37. package/dist/backend/detail/NotesSection.js.map +2 -2
  38. package/dist/backend/detail/TagsSection.js +1 -1
  39. package/dist/backend/detail/TagsSection.js.map +2 -2
  40. package/dist/backend/forms/FormHeader.js +2 -1
  41. package/dist/backend/forms/FormHeader.js.map +2 -2
  42. package/dist/backend/icons/lucideRegistry.generated.js +25 -1
  43. package/dist/backend/icons/lucideRegistry.generated.js.map +2 -2
  44. package/dist/backend/icons/lucideRegistry.js +3 -3
  45. package/dist/backend/icons/lucideRegistry.js.map +2 -2
  46. package/dist/backend/injection/spotIds.js +18 -23
  47. package/dist/backend/injection/spotIds.js.map +2 -2
  48. package/dist/backend/schedule/ScheduleAgenda.js +3 -3
  49. package/dist/backend/schedule/ScheduleAgenda.js.map +1 -1
  50. package/dist/backend/schedule/ScheduleGrid.js +3 -3
  51. package/dist/backend/schedule/ScheduleGrid.js.map +1 -1
  52. package/dist/backend/schedule/ScheduleToolbar.js +70 -87
  53. package/dist/backend/schedule/ScheduleToolbar.js.map +2 -2
  54. package/dist/backend/sidebar/SidebarCustomizationEditor.js +2 -30
  55. package/dist/backend/sidebar/SidebarCustomizationEditor.js.map +2 -2
  56. package/dist/backend/sidebar/customization-helpers.js +0 -30
  57. package/dist/backend/sidebar/customization-helpers.js.map +2 -2
  58. package/dist/backend/utils/api.js +13 -3
  59. package/dist/backend/utils/api.js.map +2 -2
  60. package/dist/backend/utils/apiCall.js +1 -21
  61. package/dist/backend/utils/apiCall.js.map +2 -2
  62. package/dist/backend/utils/customFieldDefs.js.map +2 -2
  63. package/dist/backend/utils/customFieldForms.js +9 -10
  64. package/dist/backend/utils/customFieldForms.js.map +2 -2
  65. package/dist/backend/utils/nav.js +2 -5
  66. package/dist/backend/utils/nav.js.map +2 -2
  67. package/dist/frontend/LanguageSwitcher.js +1 -2
  68. package/dist/frontend/LanguageSwitcher.js.map +2 -2
  69. package/dist/portal/PortalContext.js +1 -1
  70. package/dist/portal/PortalContext.js.map +2 -2
  71. package/dist/portal/hooks/usePortalEventBridge.js +0 -6
  72. package/dist/portal/hooks/usePortalEventBridge.js.map +2 -2
  73. package/dist/portal/hooks/usePortalNotifications.js +4 -42
  74. package/dist/portal/hooks/usePortalNotifications.js.map +2 -2
  75. package/dist/portal/utils/nav.js +3 -2
  76. package/dist/portal/utils/nav.js.map +2 -2
  77. package/dist/primitives/button.js +4 -9
  78. package/dist/primitives/button.js.map +2 -2
  79. package/jest.config.cjs +1 -2
  80. package/package.json +18 -21
  81. package/src/ai/AiChat.tsx +1 -30
  82. package/src/ai/__tests__/AiChat.test.tsx +0 -31
  83. package/src/backend/AppShell.tsx +4 -55
  84. package/src/backend/AuthSessionGuard.tsx +0 -29
  85. package/src/backend/BackendChromeProvider.tsx +1 -19
  86. package/src/backend/CrudForm.tsx +4 -18
  87. package/src/backend/DataTable.tsx +19 -36
  88. package/src/backend/JsonBuilder.tsx +3 -3
  89. package/src/backend/NextStepCallout.tsx +4 -4
  90. package/src/backend/ProfileDropdown.tsx +0 -1
  91. package/src/backend/RowActions.tsx +2 -2
  92. package/src/backend/__tests__/AppShell.test.tsx +7 -65
  93. package/src/backend/__tests__/AuthSessionGuard.test.tsx +0 -52
  94. package/src/backend/__tests__/BackendChromeProvider.test.tsx +2 -98
  95. package/src/backend/__tests__/DataTable.perspectiveStorage.test.ts +1 -62
  96. package/src/backend/__tests__/nav-utils.test.ts +0 -139
  97. package/src/backend/confirm-dialog/ConfirmDialog.tsx +1 -16
  98. package/src/backend/confirm-dialog/__tests__/ConfirmDialog.test.tsx +1 -68
  99. package/src/backend/crud/__tests__/useGroupOrder.test.ts +1 -39
  100. package/src/backend/crud/useGroupOrder.ts +25 -30
  101. package/src/backend/detail/AddressTiles.tsx +6 -3
  102. package/src/backend/detail/AttachmentDeleteDialog.tsx +1 -1
  103. package/src/backend/detail/AttachmentMetadataDialog.tsx +1 -1
  104. package/src/backend/detail/AttachmentsSection.tsx +1 -1
  105. package/src/backend/detail/NotesSection.tsx +1 -1
  106. package/src/backend/detail/TagsSection.tsx +1 -1
  107. package/src/backend/detail/__tests__/AttachmentAssignmentsEditor.test.tsx +0 -21
  108. package/src/backend/forms/FormHeader.tsx +2 -1
  109. package/src/backend/icons/lucideRegistry.generated.tsx +37 -3
  110. package/src/backend/icons/lucideRegistry.ts +2 -2
  111. package/src/backend/injection/spotIds.ts +18 -23
  112. package/src/backend/schedule/ScheduleAgenda.tsx +3 -3
  113. package/src/backend/schedule/ScheduleGrid.tsx +3 -3
  114. package/src/backend/schedule/ScheduleToolbar.tsx +68 -89
  115. package/src/backend/sidebar/SidebarCustomizationEditor.tsx +3 -42
  116. package/src/backend/sidebar/customization-helpers.ts +0 -47
  117. package/src/backend/utils/__tests__/api.test.ts +27 -0
  118. package/src/backend/utils/__tests__/apiCall.test.ts +0 -61
  119. package/src/backend/utils/api.ts +20 -3
  120. package/src/backend/utils/apiCall.ts +1 -29
  121. package/src/backend/utils/customFieldDefs.ts +0 -1
  122. package/src/backend/utils/customFieldForms.ts +11 -15
  123. package/src/backend/utils/nav.ts +2 -22
  124. package/src/frontend/LanguageSwitcher.tsx +0 -1
  125. package/src/portal/PortalContext.tsx +1 -1
  126. package/src/portal/hooks/usePortalEventBridge.ts +0 -6
  127. package/src/portal/hooks/usePortalNotifications.ts +12 -55
  128. package/src/portal/utils/__tests__/nav.test.ts +2 -2
  129. package/src/portal/utils/nav.ts +4 -3
  130. package/src/primitives/button.tsx +4 -10
  131. package/dist/backend/icons/lucideRegistryRuntime.js +0 -28
  132. package/dist/backend/icons/lucideRegistryRuntime.js.map +0 -7
  133. package/dist/backend/perspectiveState.js +0 -34
  134. package/dist/backend/perspectiveState.js.map +0 -7
  135. package/dist/backend/utils/fetchAllPages.js +0 -57
  136. package/dist/backend/utils/fetchAllPages.js.map +0 -7
  137. package/dist/portal/hooks/portalBridgeStatus.js +0 -25
  138. package/dist/portal/hooks/portalBridgeStatus.js.map +0 -7
  139. package/figma/alert.figma.tsx +0 -35
  140. package/figma/badge.figma.tsx +0 -33
  141. package/figma/button.figma.tsx +0 -33
  142. package/figma/checkbox.figma.tsx +0 -28
  143. package/figma/drawer.figma.tsx +0 -40
  144. package/figma/input-variants.figma.tsx +0 -58
  145. package/figma/radio.figma.tsx +0 -16
  146. package/figma/select.figma.tsx +0 -24
  147. package/figma/status-badge.figma.tsx +0 -24
  148. package/figma/switch.figma.tsx +0 -28
  149. package/figma/tabs.figma.tsx +0 -22
  150. package/figma/tag.figma.tsx +0 -27
  151. package/figma.config.json +0 -6
  152. package/scripts/lucideRegistrySource.cjs +0 -133
  153. package/src/backend/__tests__/CrudForm.fieldsetBinding.test.tsx +0 -158
  154. package/src/backend/__tests__/CrudForm.groupInjectionColumn.test.tsx +0 -98
  155. package/src/backend/__tests__/DataTable.metaHiddenLateColumns.test.tsx +0 -105
  156. package/src/backend/icons/__tests__/lucideRegistryGenerator.test.ts +0 -177
  157. package/src/backend/icons/lucideRegistryRuntime.tsx +0 -36
  158. package/src/backend/injection/__tests__/spotIds.extension-points.test.ts +0 -38
  159. package/src/backend/perspectiveState.ts +0 -44
  160. package/src/backend/sidebar/__tests__/group-visibility.test.ts +0 -131
  161. package/src/backend/utils/__tests__/customFieldForms.optionsPaging.test.ts +0 -95
  162. package/src/backend/utils/__tests__/fetchAllPages.test.ts +0 -103
  163. package/src/backend/utils/fetchAllPages.ts +0 -93
  164. package/src/portal/hooks/__tests__/usePortalEventBridge.test.tsx +0 -66
  165. package/src/portal/hooks/__tests__/usePortalNotifications.test.tsx +0 -155
  166. package/src/portal/hooks/portalBridgeStatus.ts +0 -29
  167. package/src/primitives/__tests__/button.test.tsx +0 -70
@@ -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 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;",
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;",
6
6
  "names": []
7
7
  }
@@ -33,7 +33,7 @@ function PortalProvider({ orgSlug, children, initialAuth, initialTenant }) {
33
33
  },
34
34
  roles: [],
35
35
  resolvedFeatures: initialAuth.resolvedFeatures,
36
- isPortalAdmin: initialAuth.isPortalAdmin === true,
36
+ isPortalAdmin: initialAuth.resolvedFeatures.some((f) => f === "portal.*"),
37
37
  loading: false,
38
38
  error: null
39
39
  };
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/portal/PortalContext.tsx"],
4
- "sourcesContent": ["\"use client\"\nimport {\n type ReactNode,\n createContext,\n useCallback,\n useContext,\n useEffect,\n useRef,\n useState,\n} from 'react'\nimport type { CustomerAuthResult } from '@open-mercato/shared/modules/customer-auth'\nimport type { CustomerAuthContext } from '@open-mercato/shared/modules/customer-auth'\nimport { apiCall } from '../backend/utils/apiCall'\n\n/* ------------------------------------------------------------------ */\n/* Types */\n/* ------------------------------------------------------------------ */\n\ntype TenantState = {\n tenantId: string | undefined\n organizationId: string | undefined\n organizationName: string | undefined\n loading: boolean\n error: string | null\n}\n\ntype PortalContextValue = {\n auth: CustomerAuthResult & { logout: () => Promise<void> }\n tenant: TenantState\n orgSlug: string\n}\n\nconst PortalCtx = createContext<PortalContextValue | null>(null)\n\n/* ------------------------------------------------------------------ */\n/* Hook */\n/* ------------------------------------------------------------------ */\n\nexport function usePortalContext(): PortalContextValue {\n const ctx = useContext(PortalCtx)\n if (!ctx) throw new Error('usePortalContext must be used inside <PortalProvider>')\n return ctx\n}\n\n/* ------------------------------------------------------------------ */\n/* Provider */\n/* ------------------------------------------------------------------ */\n\ntype InitialTenant = {\n tenantId?: string\n organizationId?: string\n organizationName?: string\n}\n\ntype PortalProviderProps = {\n orgSlug: string\n children: ReactNode\n /** Server-resolved customer auth. When provided, skips client-side fetching. */\n initialAuth?: CustomerAuthContext | null\n /** Server-resolved tenant data. When provided, skips client-side fetching. */\n initialTenant?: InitialTenant\n}\n\n/**\n * Provides auth + tenant state for portal pages.\n *\n * When `initialAuth` and `initialTenant` are provided (from server layout),\n * the context initializes with that data immediately \u2014 no loading state,\n * no client-side fetching, no blink.\n *\n * Falls back to client-side fetching only when initial data is not provided.\n */\nexport function PortalProvider({ orgSlug, children, initialAuth, initialTenant }: PortalProviderProps) {\n const hasServerData = initialAuth !== undefined\n\n /* ---- Auth state ---- */\n const [authState, setAuthState] = useState<CustomerAuthResult>(() => {\n if (hasServerData && initialAuth) {\n // Server-resolved auth \u2014 start fully loaded, no client-side fetch needed\n return {\n user: {\n id: initialAuth.sub,\n email: initialAuth.email,\n displayName: initialAuth.displayName,\n emailVerified: false,\n customerEntityId: initialAuth.customerEntityId ?? null,\n personEntityId: initialAuth.personEntityId ?? null,\n isActive: true,\n lastLoginAt: null,\n createdAt: '',\n },\n roles: [],\n resolvedFeatures: initialAuth.resolvedFeatures,\n isPortalAdmin: initialAuth.isPortalAdmin === true,\n loading: false,\n error: null,\n }\n }\n if (hasServerData && !initialAuth) {\n // Server confirmed: no auth cookie \u2192 not authenticated\n return { user: null, roles: [], resolvedFeatures: [], isPortalAdmin: false, loading: false, error: null }\n }\n // No server data \u2192 need client-side fetch\n return { user: null, roles: [], resolvedFeatures: [], isPortalAdmin: false, loading: true, error: null }\n })\n\n // Guard against re-fetching when the provider persists across navigations\n // and `initialAuth` arrives as a new object reference on each server render.\n const profileEnrichedRef = useRef(false)\n\n // Client-side profile fetch \u2014 only runs when NO server data was provided,\n // or to enrich server-provided auth with full profile (roles, etc.)\n useEffect(() => {\n if (hasServerData && !initialAuth) return // Server said: not authenticated, nothing to fetch\n if (profileEnrichedRef.current) return\n let cancelled = false\n\n async function fetchProfile() {\n try {\n const { ok, status, result: data } = await apiCall<{ ok: boolean; user: CustomerAuthResult['user']; roles: CustomerAuthResult['roles']; resolvedFeatures: string[]; isPortalAdmin: boolean }>('/api/customer_accounts/portal/profile')\n if (cancelled) return\n if (status === 401) {\n setAuthState((prev) => ({ ...prev, loading: false, user: prev.user }))\n return\n }\n if (!ok || !data?.ok) {\n setAuthState((prev) => ({ ...prev, loading: false }))\n return\n }\n profileEnrichedRef.current = true\n setAuthState({\n user: data!.user,\n roles: data!.roles || [],\n resolvedFeatures: data!.resolvedFeatures || [],\n isPortalAdmin: data!.isPortalAdmin || false,\n loading: false,\n error: null,\n })\n } catch {\n if (!cancelled) {\n setAuthState((prev) => ({ ...prev, loading: false }))\n }\n }\n }\n\n fetchProfile()\n return () => { cancelled = true }\n }, [hasServerData, initialAuth])\n\n const logout = useCallback(async () => {\n try {\n await apiCall('/api/customer_accounts/portal/logout', { method: 'POST' })\n } catch {\n // Best-effort logout \u2014 redirect regardless\n }\n profileEnrichedRef.current = false\n setAuthState({ user: null, roles: [], resolvedFeatures: [], isPortalAdmin: false, loading: false, error: null })\n window.location.assign(`/${orgSlug}/portal/login`)\n }, [orgSlug])\n\n /* ---- Tenant state ---- */\n const [tenantState] = useState<TenantState>(() => {\n if (initialTenant) {\n return {\n tenantId: initialTenant.tenantId,\n organizationId: initialTenant.organizationId,\n organizationName: initialTenant.organizationName,\n loading: false,\n error: null,\n }\n }\n return { tenantId: undefined, organizationId: undefined, organizationName: undefined, loading: true, error: null }\n })\n\n // Client-side tenant fetch \u2014 only when no server data\n useEffect(() => {\n if (initialTenant) return // Server already resolved\n // Fallback: kept for backward compat but shouldn't be hit when layout provides data\n }, [initialTenant])\n\n const value: PortalContextValue = {\n auth: { ...authState, logout },\n tenant: tenantState,\n orgSlug,\n }\n\n return <PortalCtx.Provider value={value}>{children}</PortalCtx.Provider>\n}\n"],
5
- "mappings": ";AA0LS;AAzLT;AAAA,EAEE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AAGP,SAAS,eAAe;AAoBxB,MAAM,YAAY,cAAyC,IAAI;AAMxD,SAAS,mBAAuC;AACrD,QAAM,MAAM,WAAW,SAAS;AAChC,MAAI,CAAC,IAAK,OAAM,IAAI,MAAM,uDAAuD;AACjF,SAAO;AACT;AA8BO,SAAS,eAAe,EAAE,SAAS,UAAU,aAAa,cAAc,GAAwB;AACrG,QAAM,gBAAgB,gBAAgB;AAGtC,QAAM,CAAC,WAAW,YAAY,IAAI,SAA6B,MAAM;AACnE,QAAI,iBAAiB,aAAa;AAEhC,aAAO;AAAA,QACL,MAAM;AAAA,UACJ,IAAI,YAAY;AAAA,UAChB,OAAO,YAAY;AAAA,UACnB,aAAa,YAAY;AAAA,UACzB,eAAe;AAAA,UACf,kBAAkB,YAAY,oBAAoB;AAAA,UAClD,gBAAgB,YAAY,kBAAkB;AAAA,UAC9C,UAAU;AAAA,UACV,aAAa;AAAA,UACb,WAAW;AAAA,QACb;AAAA,QACA,OAAO,CAAC;AAAA,QACR,kBAAkB,YAAY;AAAA,QAC9B,eAAe,YAAY,kBAAkB;AAAA,QAC7C,SAAS;AAAA,QACT,OAAO;AAAA,MACT;AAAA,IACF;AACA,QAAI,iBAAiB,CAAC,aAAa;AAEjC,aAAO,EAAE,MAAM,MAAM,OAAO,CAAC,GAAG,kBAAkB,CAAC,GAAG,eAAe,OAAO,SAAS,OAAO,OAAO,KAAK;AAAA,IAC1G;AAEA,WAAO,EAAE,MAAM,MAAM,OAAO,CAAC,GAAG,kBAAkB,CAAC,GAAG,eAAe,OAAO,SAAS,MAAM,OAAO,KAAK;AAAA,EACzG,CAAC;AAID,QAAM,qBAAqB,OAAO,KAAK;AAIvC,YAAU,MAAM;AACd,QAAI,iBAAiB,CAAC,YAAa;AACnC,QAAI,mBAAmB,QAAS;AAChC,QAAI,YAAY;AAEhB,mBAAe,eAAe;AAC5B,UAAI;AACF,cAAM,EAAE,IAAI,QAAQ,QAAQ,KAAK,IAAI,MAAM,QAAmJ,uCAAuC;AACrO,YAAI,UAAW;AACf,YAAI,WAAW,KAAK;AAClB,uBAAa,CAAC,UAAU,EAAE,GAAG,MAAM,SAAS,OAAO,MAAM,KAAK,KAAK,EAAE;AACrE;AAAA,QACF;AACA,YAAI,CAAC,MAAM,CAAC,MAAM,IAAI;AACpB,uBAAa,CAAC,UAAU,EAAE,GAAG,MAAM,SAAS,MAAM,EAAE;AACpD;AAAA,QACF;AACA,2BAAmB,UAAU;AAC7B,qBAAa;AAAA,UACX,MAAM,KAAM;AAAA,UACZ,OAAO,KAAM,SAAS,CAAC;AAAA,UACvB,kBAAkB,KAAM,oBAAoB,CAAC;AAAA,UAC7C,eAAe,KAAM,iBAAiB;AAAA,UACtC,SAAS;AAAA,UACT,OAAO;AAAA,QACT,CAAC;AAAA,MACH,QAAQ;AACN,YAAI,CAAC,WAAW;AACd,uBAAa,CAAC,UAAU,EAAE,GAAG,MAAM,SAAS,MAAM,EAAE;AAAA,QACtD;AAAA,MACF;AAAA,IACF;AAEA,iBAAa;AACb,WAAO,MAAM;AAAE,kBAAY;AAAA,IAAK;AAAA,EAClC,GAAG,CAAC,eAAe,WAAW,CAAC;AAE/B,QAAM,SAAS,YAAY,YAAY;AACrC,QAAI;AACF,YAAM,QAAQ,wCAAwC,EAAE,QAAQ,OAAO,CAAC;AAAA,IAC1E,QAAQ;AAAA,IAER;AACA,uBAAmB,UAAU;AAC7B,iBAAa,EAAE,MAAM,MAAM,OAAO,CAAC,GAAG,kBAAkB,CAAC,GAAG,eAAe,OAAO,SAAS,OAAO,OAAO,KAAK,CAAC;AAC/G,WAAO,SAAS,OAAO,IAAI,OAAO,eAAe;AAAA,EACnD,GAAG,CAAC,OAAO,CAAC;AAGZ,QAAM,CAAC,WAAW,IAAI,SAAsB,MAAM;AAChD,QAAI,eAAe;AACjB,aAAO;AAAA,QACL,UAAU,cAAc;AAAA,QACxB,gBAAgB,cAAc;AAAA,QAC9B,kBAAkB,cAAc;AAAA,QAChC,SAAS;AAAA,QACT,OAAO;AAAA,MACT;AAAA,IACF;AACA,WAAO,EAAE,UAAU,QAAW,gBAAgB,QAAW,kBAAkB,QAAW,SAAS,MAAM,OAAO,KAAK;AAAA,EACnH,CAAC;AAGD,YAAU,MAAM;AACd,QAAI,cAAe;AAAA,EAErB,GAAG,CAAC,aAAa,CAAC;AAElB,QAAM,QAA4B;AAAA,IAChC,MAAM,EAAE,GAAG,WAAW,OAAO;AAAA,IAC7B,QAAQ;AAAA,IACR;AAAA,EACF;AAEA,SAAO,oBAAC,UAAU,UAAV,EAAmB,OAAe,UAAS;AACrD;",
4
+ "sourcesContent": ["\"use client\"\nimport {\n type ReactNode,\n createContext,\n useCallback,\n useContext,\n useEffect,\n useRef,\n useState,\n} from 'react'\nimport type { CustomerAuthResult } from '@open-mercato/shared/modules/customer-auth'\nimport type { CustomerAuthContext } from '@open-mercato/shared/modules/customer-auth'\nimport { apiCall } from '../backend/utils/apiCall'\n\n/* ------------------------------------------------------------------ */\n/* Types */\n/* ------------------------------------------------------------------ */\n\ntype TenantState = {\n tenantId: string | undefined\n organizationId: string | undefined\n organizationName: string | undefined\n loading: boolean\n error: string | null\n}\n\ntype PortalContextValue = {\n auth: CustomerAuthResult & { logout: () => Promise<void> }\n tenant: TenantState\n orgSlug: string\n}\n\nconst PortalCtx = createContext<PortalContextValue | null>(null)\n\n/* ------------------------------------------------------------------ */\n/* Hook */\n/* ------------------------------------------------------------------ */\n\nexport function usePortalContext(): PortalContextValue {\n const ctx = useContext(PortalCtx)\n if (!ctx) throw new Error('usePortalContext must be used inside <PortalProvider>')\n return ctx\n}\n\n/* ------------------------------------------------------------------ */\n/* Provider */\n/* ------------------------------------------------------------------ */\n\ntype InitialTenant = {\n tenantId?: string\n organizationId?: string\n organizationName?: string\n}\n\ntype PortalProviderProps = {\n orgSlug: string\n children: ReactNode\n /** Server-resolved customer auth. When provided, skips client-side fetching. */\n initialAuth?: CustomerAuthContext | null\n /** Server-resolved tenant data. When provided, skips client-side fetching. */\n initialTenant?: InitialTenant\n}\n\n/**\n * Provides auth + tenant state for portal pages.\n *\n * When `initialAuth` and `initialTenant` are provided (from server layout),\n * the context initializes with that data immediately \u2014 no loading state,\n * no client-side fetching, no blink.\n *\n * Falls back to client-side fetching only when initial data is not provided.\n */\nexport function PortalProvider({ orgSlug, children, initialAuth, initialTenant }: PortalProviderProps) {\n const hasServerData = initialAuth !== undefined\n\n /* ---- Auth state ---- */\n const [authState, setAuthState] = useState<CustomerAuthResult>(() => {\n if (hasServerData && initialAuth) {\n // Server-resolved auth \u2014 start fully loaded, no client-side fetch needed\n return {\n user: {\n id: initialAuth.sub,\n email: initialAuth.email,\n displayName: initialAuth.displayName,\n emailVerified: false,\n customerEntityId: initialAuth.customerEntityId ?? null,\n personEntityId: initialAuth.personEntityId ?? null,\n isActive: true,\n lastLoginAt: null,\n createdAt: '',\n },\n roles: [],\n resolvedFeatures: initialAuth.resolvedFeatures,\n isPortalAdmin: initialAuth.resolvedFeatures.some((f) => f === 'portal.*'),\n loading: false,\n error: null,\n }\n }\n if (hasServerData && !initialAuth) {\n // Server confirmed: no auth cookie \u2192 not authenticated\n return { user: null, roles: [], resolvedFeatures: [], isPortalAdmin: false, loading: false, error: null }\n }\n // No server data \u2192 need client-side fetch\n return { user: null, roles: [], resolvedFeatures: [], isPortalAdmin: false, loading: true, error: null }\n })\n\n // Guard against re-fetching when the provider persists across navigations\n // and `initialAuth` arrives as a new object reference on each server render.\n const profileEnrichedRef = useRef(false)\n\n // Client-side profile fetch \u2014 only runs when NO server data was provided,\n // or to enrich server-provided auth with full profile (roles, etc.)\n useEffect(() => {\n if (hasServerData && !initialAuth) return // Server said: not authenticated, nothing to fetch\n if (profileEnrichedRef.current) return\n let cancelled = false\n\n async function fetchProfile() {\n try {\n const { ok, status, result: data } = await apiCall<{ ok: boolean; user: CustomerAuthResult['user']; roles: CustomerAuthResult['roles']; resolvedFeatures: string[]; isPortalAdmin: boolean }>('/api/customer_accounts/portal/profile')\n if (cancelled) return\n if (status === 401) {\n setAuthState((prev) => ({ ...prev, loading: false, user: prev.user }))\n return\n }\n if (!ok || !data?.ok) {\n setAuthState((prev) => ({ ...prev, loading: false }))\n return\n }\n profileEnrichedRef.current = true\n setAuthState({\n user: data!.user,\n roles: data!.roles || [],\n resolvedFeatures: data!.resolvedFeatures || [],\n isPortalAdmin: data!.isPortalAdmin || false,\n loading: false,\n error: null,\n })\n } catch {\n if (!cancelled) {\n setAuthState((prev) => ({ ...prev, loading: false }))\n }\n }\n }\n\n fetchProfile()\n return () => { cancelled = true }\n }, [hasServerData, initialAuth])\n\n const logout = useCallback(async () => {\n try {\n await apiCall('/api/customer_accounts/portal/logout', { method: 'POST' })\n } catch {\n // Best-effort logout \u2014 redirect regardless\n }\n profileEnrichedRef.current = false\n setAuthState({ user: null, roles: [], resolvedFeatures: [], isPortalAdmin: false, loading: false, error: null })\n window.location.assign(`/${orgSlug}/portal/login`)\n }, [orgSlug])\n\n /* ---- Tenant state ---- */\n const [tenantState] = useState<TenantState>(() => {\n if (initialTenant) {\n return {\n tenantId: initialTenant.tenantId,\n organizationId: initialTenant.organizationId,\n organizationName: initialTenant.organizationName,\n loading: false,\n error: null,\n }\n }\n return { tenantId: undefined, organizationId: undefined, organizationName: undefined, loading: true, error: null }\n })\n\n // Client-side tenant fetch \u2014 only when no server data\n useEffect(() => {\n if (initialTenant) return // Server already resolved\n // Fallback: kept for backward compat but shouldn't be hit when layout provides data\n }, [initialTenant])\n\n const value: PortalContextValue = {\n auth: { ...authState, logout },\n tenant: tenantState,\n orgSlug,\n }\n\n return <PortalCtx.Provider value={value}>{children}</PortalCtx.Provider>\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -2,7 +2,6 @@
2
2
  import { useEffect, useRef } from "react";
3
3
  import { PORTAL_EVENT_DOM_NAME } from "./usePortalAppEvent.js";
4
4
  import { createLogger } from "@open-mercato/shared/lib/logger";
5
- import { publishPortalBridgeHealth } from "./portalBridgeStatus.js";
6
5
  const logger = createLogger("ui").child({ component: "PortalEventBridge" });
7
6
  const PORTAL_SSE_ENDPOINT = "/api/customer_accounts/portal/events/stream";
8
7
  const HEARTBEAT_TIMEOUT = 45e3;
@@ -37,7 +36,6 @@ function usePortalEventBridge() {
37
36
  if (heartbeatTimer.current) clearTimeout(heartbeatTimer.current);
38
37
  heartbeatTimer.current = setTimeout(() => {
39
38
  logger.warn("Heartbeat timeout \u2014 reconnecting");
40
- publishPortalBridgeHealth(false);
41
39
  disconnect();
42
40
  scheduleReconnect();
43
41
  }, HEARTBEAT_TIMEOUT);
@@ -53,7 +51,6 @@ function usePortalEventBridge() {
53
51
  hasEverConnected.current = true;
54
52
  reconnectPending.current = false;
55
53
  reconnectAttempts.current = 0;
56
- publishPortalBridgeHealth(true);
57
54
  resetHeartbeatTimer();
58
55
  if (shouldEmitReconnect) {
59
56
  window.dispatchEvent(
@@ -85,7 +82,6 @@ function usePortalEventBridge() {
85
82
  if (hasEverConnected.current) {
86
83
  reconnectPending.current = true;
87
84
  }
88
- publishPortalBridgeHealth(false);
89
85
  disconnect();
90
86
  if (mounted) scheduleReconnect();
91
87
  };
@@ -93,7 +89,6 @@ function usePortalEventBridge() {
93
89
  if (hasEverConnected.current) {
94
90
  reconnectPending.current = true;
95
91
  }
96
- publishPortalBridgeHealth(false);
97
92
  if (mounted) scheduleReconnect();
98
93
  }
99
94
  }
@@ -122,7 +117,6 @@ function usePortalEventBridge() {
122
117
  connect();
123
118
  return () => {
124
119
  mounted = false;
125
- publishPortalBridgeHealth(false);
126
120
  disconnect();
127
121
  if (reconnectTimer.current) {
128
122
  clearTimeout(reconnectTimer.current);
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/portal/hooks/usePortalEventBridge.ts"],
4
- "sourcesContent": ["\"use client\"\nimport { useEffect, useRef } from 'react'\nimport type { AppEventPayload } from '@open-mercato/shared/modules/widgets/injection'\nimport { PORTAL_EVENT_DOM_NAME } from './usePortalAppEvent'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { publishPortalBridgeHealth } from './portalBridgeStatus'\n\nconst logger = createLogger('ui').child({ component: 'PortalEventBridge' })\n\nconst PORTAL_SSE_ENDPOINT = '/api/customer_accounts/portal/events/stream'\nconst HEARTBEAT_TIMEOUT = 45_000\nconst RECONNECT_BASE_MS = 1_000\nconst RECONNECT_MAX_MS = 30_000\nconst DEDUP_WINDOW_MS = 500\nconst PORTAL_BRIDGE_RECONNECTED_EVENT_ID = 'om:portal-bridge:reconnected'\n\n/**\n * React hook that establishes a singleton SSE connection to the portal event bridge.\n *\n * Mount once in the portal shell/layout. Receives server-side events with\n * `portalBroadcast: true` and dispatches them as `om:portal-event` CustomEvents\n * on the window object for consumption by `usePortalAppEvent`.\n *\n * Uses customer auth (cookie-based JWT) instead of staff auth.\n *\n * @example\n * ```tsx\n * import { usePortalEventBridge } from '@open-mercato/ui/portal/hooks/usePortalEventBridge'\n *\n * function PortalShell({ children }) {\n * usePortalEventBridge()\n * return <div>{children}</div>\n * }\n * ```\n */\nexport function usePortalEventBridge(): void {\n const sourceRef = useRef<EventSource | null>(null)\n const reconnectAttempts = useRef(0)\n const reconnectTimer = useRef<ReturnType<typeof setTimeout> | null>(null)\n const heartbeatTimer = useRef<ReturnType<typeof setTimeout> | null>(null)\n const recentEvents = useRef<Map<string, number>>(new Map())\n const hasEverConnected = useRef(false)\n const reconnectPending = useRef(false)\n\n useEffect(() => {\n let mounted = true\n\n function isDuplicate(eventPayload: AppEventPayload): boolean {\n const key = `${eventPayload.id}:${JSON.stringify(eventPayload.payload ?? {})}`\n const lastSeen = recentEvents.current.get(key)\n if (lastSeen && Date.now() - lastSeen < DEDUP_WINDOW_MS) return true\n recentEvents.current.set(key, Date.now())\n if (recentEvents.current.size > 100) {\n const now = Date.now()\n for (const [k, v] of recentEvents.current) {\n if (now - v > DEDUP_WINDOW_MS * 2) recentEvents.current.delete(k)\n }\n }\n return false\n }\n\n function resetHeartbeatTimer() {\n if (heartbeatTimer.current) clearTimeout(heartbeatTimer.current)\n heartbeatTimer.current = setTimeout(() => {\n logger.warn('Heartbeat timeout \u2014 reconnecting')\n publishPortalBridgeHealth(false)\n disconnect()\n scheduleReconnect()\n }, HEARTBEAT_TIMEOUT)\n }\n\n function connect() {\n if (!mounted) return\n if (sourceRef.current) return\n\n try {\n const source = new EventSource(PORTAL_SSE_ENDPOINT, { withCredentials: true })\n sourceRef.current = source\n\n source.onopen = () => {\n const shouldEmitReconnect = hasEverConnected.current && reconnectPending.current\n hasEverConnected.current = true\n reconnectPending.current = false\n reconnectAttempts.current = 0\n publishPortalBridgeHealth(true)\n resetHeartbeatTimer()\n if (shouldEmitReconnect) {\n window.dispatchEvent(\n new CustomEvent(PORTAL_EVENT_DOM_NAME, {\n detail: {\n id: PORTAL_BRIDGE_RECONNECTED_EVENT_ID,\n payload: {},\n timestamp: Date.now(),\n organizationId: '',\n } satisfies AppEventPayload,\n }),\n )\n }\n }\n\n source.onmessage = (event) => {\n resetHeartbeatTimer()\n if (!event.data || event.data === ':heartbeat') return\n\n try {\n const parsed = JSON.parse(event.data) as AppEventPayload\n if (!parsed.id || typeof parsed.id !== 'string') return\n if (isDuplicate(parsed)) return\n\n window.dispatchEvent(\n new CustomEvent(PORTAL_EVENT_DOM_NAME, { detail: parsed }),\n )\n } catch {\n // Ignore malformed events\n }\n }\n\n source.onerror = () => {\n if (hasEverConnected.current) {\n reconnectPending.current = true\n }\n publishPortalBridgeHealth(false)\n disconnect()\n if (mounted) scheduleReconnect()\n }\n } catch {\n if (hasEverConnected.current) {\n reconnectPending.current = true\n }\n publishPortalBridgeHealth(false)\n if (mounted) scheduleReconnect()\n }\n }\n\n function disconnect() {\n if (sourceRef.current) {\n sourceRef.current.close()\n sourceRef.current = null\n }\n if (heartbeatTimer.current) {\n clearTimeout(heartbeatTimer.current)\n heartbeatTimer.current = null\n }\n }\n\n function scheduleReconnect() {\n if (reconnectTimer.current) return\n const delay = Math.min(\n RECONNECT_BASE_MS * Math.pow(2, reconnectAttempts.current),\n RECONNECT_MAX_MS,\n )\n reconnectAttempts.current++\n reconnectTimer.current = setTimeout(() => {\n reconnectTimer.current = null\n connect()\n }, delay)\n }\n\n connect()\n\n return () => {\n mounted = false\n publishPortalBridgeHealth(false)\n disconnect()\n if (reconnectTimer.current) {\n clearTimeout(reconnectTimer.current)\n reconnectTimer.current = null\n }\n }\n }, [])\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["\"use client\"\nimport { useEffect, useRef } from 'react'\nimport type { AppEventPayload } from '@open-mercato/shared/modules/widgets/injection'\nimport { PORTAL_EVENT_DOM_NAME } from './usePortalAppEvent'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('ui').child({ component: 'PortalEventBridge' })\n\nconst PORTAL_SSE_ENDPOINT = '/api/customer_accounts/portal/events/stream'\nconst HEARTBEAT_TIMEOUT = 45_000\nconst RECONNECT_BASE_MS = 1_000\nconst RECONNECT_MAX_MS = 30_000\nconst DEDUP_WINDOW_MS = 500\nconst PORTAL_BRIDGE_RECONNECTED_EVENT_ID = 'om:portal-bridge:reconnected'\n\n/**\n * React hook that establishes a singleton SSE connection to the portal event bridge.\n *\n * Mount once in the portal shell/layout. Receives server-side events with\n * `portalBroadcast: true` and dispatches them as `om:portal-event` CustomEvents\n * on the window object for consumption by `usePortalAppEvent`.\n *\n * Uses customer auth (cookie-based JWT) instead of staff auth.\n *\n * @example\n * ```tsx\n * import { usePortalEventBridge } from '@open-mercato/ui/portal/hooks/usePortalEventBridge'\n *\n * function PortalShell({ children }) {\n * usePortalEventBridge()\n * return <div>{children}</div>\n * }\n * ```\n */\nexport function usePortalEventBridge(): void {\n const sourceRef = useRef<EventSource | null>(null)\n const reconnectAttempts = useRef(0)\n const reconnectTimer = useRef<ReturnType<typeof setTimeout> | null>(null)\n const heartbeatTimer = useRef<ReturnType<typeof setTimeout> | null>(null)\n const recentEvents = useRef<Map<string, number>>(new Map())\n const hasEverConnected = useRef(false)\n const reconnectPending = useRef(false)\n\n useEffect(() => {\n let mounted = true\n\n function isDuplicate(eventPayload: AppEventPayload): boolean {\n const key = `${eventPayload.id}:${JSON.stringify(eventPayload.payload ?? {})}`\n const lastSeen = recentEvents.current.get(key)\n if (lastSeen && Date.now() - lastSeen < DEDUP_WINDOW_MS) return true\n recentEvents.current.set(key, Date.now())\n if (recentEvents.current.size > 100) {\n const now = Date.now()\n for (const [k, v] of recentEvents.current) {\n if (now - v > DEDUP_WINDOW_MS * 2) recentEvents.current.delete(k)\n }\n }\n return false\n }\n\n function resetHeartbeatTimer() {\n if (heartbeatTimer.current) clearTimeout(heartbeatTimer.current)\n heartbeatTimer.current = setTimeout(() => {\n logger.warn('Heartbeat timeout \u2014 reconnecting')\n disconnect()\n scheduleReconnect()\n }, HEARTBEAT_TIMEOUT)\n }\n\n function connect() {\n if (!mounted) return\n if (sourceRef.current) return\n\n try {\n const source = new EventSource(PORTAL_SSE_ENDPOINT, { withCredentials: true })\n sourceRef.current = source\n\n source.onopen = () => {\n const shouldEmitReconnect = hasEverConnected.current && reconnectPending.current\n hasEverConnected.current = true\n reconnectPending.current = false\n reconnectAttempts.current = 0\n resetHeartbeatTimer()\n if (shouldEmitReconnect) {\n window.dispatchEvent(\n new CustomEvent(PORTAL_EVENT_DOM_NAME, {\n detail: {\n id: PORTAL_BRIDGE_RECONNECTED_EVENT_ID,\n payload: {},\n timestamp: Date.now(),\n organizationId: '',\n } satisfies AppEventPayload,\n }),\n )\n }\n }\n\n source.onmessage = (event) => {\n resetHeartbeatTimer()\n if (!event.data || event.data === ':heartbeat') return\n\n try {\n const parsed = JSON.parse(event.data) as AppEventPayload\n if (!parsed.id || typeof parsed.id !== 'string') return\n if (isDuplicate(parsed)) return\n\n window.dispatchEvent(\n new CustomEvent(PORTAL_EVENT_DOM_NAME, { detail: parsed }),\n )\n } catch {\n // Ignore malformed events\n }\n }\n\n source.onerror = () => {\n if (hasEverConnected.current) {\n reconnectPending.current = true\n }\n disconnect()\n if (mounted) scheduleReconnect()\n }\n } catch {\n if (hasEverConnected.current) {\n reconnectPending.current = true\n }\n if (mounted) scheduleReconnect()\n }\n }\n\n function disconnect() {\n if (sourceRef.current) {\n sourceRef.current.close()\n sourceRef.current = null\n }\n if (heartbeatTimer.current) {\n clearTimeout(heartbeatTimer.current)\n heartbeatTimer.current = null\n }\n }\n\n function scheduleReconnect() {\n if (reconnectTimer.current) return\n const delay = Math.min(\n RECONNECT_BASE_MS * Math.pow(2, reconnectAttempts.current),\n RECONNECT_MAX_MS,\n )\n reconnectAttempts.current++\n reconnectTimer.current = setTimeout(() => {\n reconnectTimer.current = null\n connect()\n }, delay)\n }\n\n connect()\n\n return () => {\n mounted = false\n disconnect()\n if (reconnectTimer.current) {\n clearTimeout(reconnectTimer.current)\n reconnectTimer.current = null\n }\n }\n }, [])\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -1,10 +1,6 @@
1
1
  "use client";
2
2
  import * as React from "react";
3
3
  import { apiCall } from "../../backend/utils/apiCall.js";
4
- import {
5
- PORTAL_BRIDGE_STATUS_DOM_NAME,
6
- readPortalBridgeHealth
7
- } from "./portalBridgeStatus.js";
8
4
  const POLL_INTERVAL = 8e3;
9
5
  const BASE = "/api/customer_accounts/portal/notifications";
10
6
  async function fetchJson(url, init) {
@@ -41,55 +37,21 @@ function usePortalNotifications() {
41
37
  }
42
38
  setIsLoading(false);
43
39
  }, []);
44
- const [usePolling, setUsePolling] = React.useState(() => {
45
- if (typeof window === "undefined" || !("EventSource" in window)) {
46
- return true;
47
- }
48
- return readPortalBridgeHealth() !== true;
49
- });
50
- React.useEffect(() => {
51
- if (typeof window === "undefined" || !("EventSource" in window)) {
52
- return;
53
- }
54
- const handleStatusChange = (event) => {
55
- const detail = event.detail;
56
- if (detail && typeof detail.healthy === "boolean") {
57
- setUsePolling(!detail.healthy);
58
- if (!detail.healthy) {
59
- fetchAll();
60
- }
61
- }
62
- };
63
- window.addEventListener(PORTAL_BRIDGE_STATUS_DOM_NAME, handleStatusChange);
64
- return () => {
65
- window.removeEventListener(PORTAL_BRIDGE_STATUS_DOM_NAME, handleStatusChange);
66
- };
67
- }, [fetchAll]);
68
40
  React.useEffect(() => {
69
41
  fetchAll();
70
- }, [fetchAll]);
71
- React.useEffect(() => {
72
- if (!usePolling) return;
73
42
  const interval = setInterval(fetchAll, POLL_INTERVAL);
74
43
  return () => clearInterval(interval);
75
- }, [fetchAll, usePolling]);
44
+ }, [fetchAll]);
76
45
  React.useEffect(() => {
77
- const handler = (event) => {
78
- const detail = event.detail;
79
- if (detail?.id === "notifications.notification.created" || detail?.id === "notifications.notification.batch_created" || detail?.id === "om:portal-bridge:reconnected") {
46
+ const handler = (e) => {
47
+ const detail = e.detail;
48
+ if (detail?.id === "notifications.notification.created" || detail?.id === "notifications.notification.batch_created") {
80
49
  fetchAll();
81
50
  }
82
51
  };
83
52
  window.addEventListener("om:portal-event", handler);
84
53
  return () => window.removeEventListener("om:portal-event", handler);
85
54
  }, [fetchAll]);
86
- React.useEffect(() => {
87
- const onFocus = () => {
88
- fetchAll();
89
- };
90
- window.addEventListener("focus", onFocus);
91
- return () => window.removeEventListener("focus", onFocus);
92
- }, [fetchAll]);
93
55
  const markAsRead = React.useCallback(async (id) => {
94
56
  await fetchJson(`${BASE}/${id}/read`, { method: "PUT" });
95
57
  setNotifications(
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/portal/hooks/usePortalNotifications.ts"],
4
- "sourcesContent": ["\"use client\"\nimport * as React from 'react'\nimport type { NotificationDto } from '@open-mercato/shared/modules/notifications/types'\nimport { apiCall } from '../../backend/utils/apiCall'\nimport {\n PORTAL_BRIDGE_STATUS_DOM_NAME,\n readPortalBridgeHealth,\n type PortalBridgeStatusDetail,\n} from './portalBridgeStatus'\n\nexport type UsePortalNotificationsResult = {\n notifications: NotificationDto[]\n unreadCount: number\n hasNew: boolean\n isLoading: boolean\n refresh: () => void\n markAsRead: (id: string) => Promise<void>\n dismiss: (id: string) => Promise<void>\n markAllRead: () => Promise<void>\n}\n\nconst POLL_INTERVAL = 8000\nconst BASE = '/api/customer_accounts/portal/notifications'\n\nasync function fetchJson<T>(url: string, init?: RequestInit): Promise<T | null> {\n try {\n const { ok, result } = await apiCall<T>(url, init)\n if (!ok) return null\n return result\n } catch {\n return null\n }\n}\n\n/**\n * Portal notification hook with SSE-first delivery and polling fallback.\n *\n * Performs an initial list/count reconciliation, refreshes on notification,\n * reconnect, and focus events, and polls every 8 seconds until the portal\n * event bridge explicitly reports a healthy connection.\n */\nexport function usePortalNotifications(): UsePortalNotificationsResult {\n const [notifications, setNotifications] = React.useState<NotificationDto[]>([])\n const [unreadCount, setUnreadCount] = React.useState(0)\n const [hasNew, setHasNew] = React.useState(false)\n const [isLoading, setIsLoading] = React.useState(true)\n const lastIdRef = React.useRef<string | null>(null)\n\n const fetchAll = React.useCallback(async () => {\n const [listData, countData] = await Promise.all([\n fetchJson<{ ok: boolean; items: NotificationDto[] }>(`${BASE}?pageSize=50`),\n fetchJson<{ ok: boolean; unreadCount: number }>(`${BASE}/unread-count`),\n ])\n\n if (listData?.ok && listData.items) {\n const items = listData.items\n if (lastIdRef.current && items.length > 0 && items[0].id !== lastIdRef.current) {\n setHasNew(true)\n setTimeout(() => setHasNew(false), 3000)\n }\n if (items.length > 0) lastIdRef.current = items[0].id\n setNotifications(items)\n }\n\n if (countData?.ok) {\n setUnreadCount(countData.unreadCount)\n }\n\n setIsLoading(false)\n }, [])\n\n const [usePolling, setUsePolling] = React.useState(() => {\n if (typeof window === 'undefined' || !('EventSource' in window)) {\n return true\n }\n return readPortalBridgeHealth() !== true\n })\n\n React.useEffect(() => {\n if (typeof window === 'undefined' || !('EventSource' in window)) {\n return\n }\n const handleStatusChange = (event: Event) => {\n const detail = (event as CustomEvent<PortalBridgeStatusDetail>).detail\n if (detail && typeof detail.healthy === 'boolean') {\n setUsePolling(!detail.healthy)\n if (!detail.healthy) {\n fetchAll()\n }\n }\n }\n window.addEventListener(PORTAL_BRIDGE_STATUS_DOM_NAME, handleStatusChange)\n return () => {\n window.removeEventListener(PORTAL_BRIDGE_STATUS_DOM_NAME, handleStatusChange)\n }\n }, [fetchAll])\n\n React.useEffect(() => {\n fetchAll()\n }, [fetchAll])\n\n React.useEffect(() => {\n if (!usePolling) return\n const interval = setInterval(fetchAll, POLL_INTERVAL)\n return () => clearInterval(interval)\n }, [fetchAll, usePolling])\n\n React.useEffect(() => {\n const handler = (event: Event) => {\n const detail = (event as CustomEvent<{ id?: string }>).detail\n if (\n detail?.id === 'notifications.notification.created' ||\n detail?.id === 'notifications.notification.batch_created' ||\n detail?.id === 'om:portal-bridge:reconnected'\n ) {\n fetchAll()\n }\n }\n window.addEventListener('om:portal-event', handler)\n return () => window.removeEventListener('om:portal-event', handler)\n }, [fetchAll])\n\n React.useEffect(() => {\n const onFocus = () => {\n fetchAll()\n }\n window.addEventListener('focus', onFocus)\n return () => window.removeEventListener('focus', onFocus)\n }, [fetchAll])\n\n const markAsRead = React.useCallback(async (id: string) => {\n await fetchJson(`${BASE}/${id}/read`, { method: 'PUT' })\n setNotifications((prev) =>\n prev.map((n) => (n.id === id ? { ...n, status: 'read', readAt: new Date().toISOString() } : n)),\n )\n setUnreadCount((prev) => Math.max(0, prev - 1))\n }, [])\n\n const dismiss = React.useCallback(async (id: string) => {\n await fetchJson(`${BASE}/${id}/dismiss`, { method: 'PUT' })\n setNotifications((prev) => prev.filter((n) => n.id !== id))\n setUnreadCount((prev) => {\n const wasDismissedUnread = notifications.find((n) => n.id === id)?.status === 'unread'\n return wasDismissedUnread ? Math.max(0, prev - 1) : prev\n })\n }, [notifications])\n\n const markAllRead = React.useCallback(async () => {\n await fetchJson(`${BASE}/mark-all-read`, { method: 'PUT' })\n setNotifications((prev) =>\n prev.map((n) => (n.status === 'unread' ? { ...n, status: 'read', readAt: new Date().toISOString() } : n)),\n )\n setUnreadCount(0)\n }, [])\n\n const refresh = React.useCallback(() => { fetchAll() }, [fetchAll])\n\n return { notifications, unreadCount, hasNew, isLoading, refresh, markAsRead, dismiss, markAllRead }\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["\"use client\"\nimport * as React from 'react'\nimport type { NotificationDto } from '@open-mercato/shared/modules/notifications/types'\nimport { apiCall } from '../../backend/utils/apiCall'\n\nexport type UsePortalNotificationsResult = {\n notifications: NotificationDto[]\n unreadCount: number\n hasNew: boolean\n isLoading: boolean\n refresh: () => void\n markAsRead: (id: string) => Promise<void>\n dismiss: (id: string) => Promise<void>\n markAllRead: () => Promise<void>\n}\n\nconst POLL_INTERVAL = 8000\nconst BASE = '/api/customer_accounts/portal/notifications'\n\nasync function fetchJson<T>(url: string, init?: RequestInit): Promise<T | null> {\n try {\n const { ok, result } = await apiCall<T>(url, init)\n if (!ok) return null\n return result\n } catch {\n return null\n }\n}\n\n/**\n * Portal notification hook \u2014 polls customer notification endpoints.\n *\n * Fetches notifications from `/api/customer_accounts/portal/notifications`\n * and unread count from `.../unread-count`. Polls every 8 seconds.\n *\n * Also listens for portal SSE events (`notifications.notification.created`)\n * to trigger immediate refresh.\n */\nexport function usePortalNotifications(): UsePortalNotificationsResult {\n const [notifications, setNotifications] = React.useState<NotificationDto[]>([])\n const [unreadCount, setUnreadCount] = React.useState(0)\n const [hasNew, setHasNew] = React.useState(false)\n const [isLoading, setIsLoading] = React.useState(true)\n const lastIdRef = React.useRef<string | null>(null)\n\n const fetchAll = React.useCallback(async () => {\n const [listData, countData] = await Promise.all([\n fetchJson<{ ok: boolean; items: NotificationDto[] }>(`${BASE}?pageSize=50`),\n fetchJson<{ ok: boolean; unreadCount: number }>(`${BASE}/unread-count`),\n ])\n\n if (listData?.ok && listData.items) {\n const items = listData.items\n if (lastIdRef.current && items.length > 0 && items[0].id !== lastIdRef.current) {\n setHasNew(true)\n setTimeout(() => setHasNew(false), 3000)\n }\n if (items.length > 0) lastIdRef.current = items[0].id\n setNotifications(items)\n }\n\n if (countData?.ok) {\n setUnreadCount(countData.unreadCount)\n }\n\n setIsLoading(false)\n }, [])\n\n // Poll\n React.useEffect(() => {\n fetchAll()\n const interval = setInterval(fetchAll, POLL_INTERVAL)\n return () => clearInterval(interval)\n }, [fetchAll])\n\n // Listen for portal SSE notification events\n React.useEffect(() => {\n const handler = (e: Event) => {\n const detail = (e as CustomEvent).detail\n if (detail?.id === 'notifications.notification.created' || detail?.id === 'notifications.notification.batch_created') {\n fetchAll()\n }\n }\n window.addEventListener('om:portal-event', handler)\n return () => window.removeEventListener('om:portal-event', handler)\n }, [fetchAll])\n\n const markAsRead = React.useCallback(async (id: string) => {\n await fetchJson(`${BASE}/${id}/read`, { method: 'PUT' })\n setNotifications((prev) =>\n prev.map((n) => (n.id === id ? { ...n, status: 'read', readAt: new Date().toISOString() } : n)),\n )\n setUnreadCount((prev) => Math.max(0, prev - 1))\n }, [])\n\n const dismiss = React.useCallback(async (id: string) => {\n await fetchJson(`${BASE}/${id}/dismiss`, { method: 'PUT' })\n setNotifications((prev) => prev.filter((n) => n.id !== id))\n setUnreadCount((prev) => {\n const wasDismissedUnread = notifications.find((n) => n.id === id)?.status === 'unread'\n return wasDismissedUnread ? Math.max(0, prev - 1) : prev\n })\n }, [notifications])\n\n const markAllRead = React.useCallback(async () => {\n await fetchJson(`${BASE}/mark-all-read`, { method: 'PUT' })\n setNotifications((prev) =>\n prev.map((n) => (n.status === 'unread' ? { ...n, status: 'read', readAt: new Date().toISOString() } : n)),\n )\n setUnreadCount(0)\n }, [])\n\n const refresh = React.useCallback(() => { fetchAll() }, [fetchAll])\n\n return { notifications, unreadCount, hasNew, isLoading, refresh, markAsRead, dismiss, markAllRead }\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -26,7 +26,8 @@ function pickPreferredRoute(existing, candidate) {
26
26
  function buildPortalNav({
27
27
  routes,
28
28
  orgSlug,
29
- grantedFeatures
29
+ grantedFeatures,
30
+ isPortalAdmin = false
30
31
  }) {
31
32
  const mainItems = [];
32
33
  const accountItems = [];
@@ -44,7 +45,7 @@ function buildPortalNav({
44
45
  const pattern = route.pattern ?? route.path;
45
46
  const nav = route.nav;
46
47
  const requireFeatures = route.requireCustomerFeatures ?? [];
47
- if (requireFeatures.length) {
48
+ if (!isPortalAdmin && requireFeatures.length) {
48
49
  if (!hasAllFeatures(grantedFeatures, requireFeatures)) continue;
49
50
  }
50
51
  const href = resolveHref(pattern, orgSlug);
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/portal/utils/nav.ts"],
4
- "sourcesContent": ["import type { FrontendRouteManifestEntry } from '@open-mercato/shared/modules/registry'\nimport { hasAllFeatures } from '@open-mercato/shared/security/features'\n\nexport type PortalNavGroupId = 'main' | 'account'\n\nexport type PortalNavItem = {\n id: string\n label: string\n labelKey?: string\n href: string\n icon?: string\n order: number\n}\n\nexport type PortalNavGroup = {\n id: PortalNavGroupId\n items: PortalNavItem[]\n}\n\nexport type BuildPortalNavOptions = {\n /** Route manifest to inspect (typically `getFrontendRouteManifests()`). */\n routes: readonly FrontendRouteManifestEntry[]\n /** Current customer org slug \u2014 substituted into `[orgSlug]` patterns. */\n orgSlug: string\n /** Concrete effective features granted to the current customer. */\n grantedFeatures: readonly string[]\n /** @deprecated Portal admins must receive concrete effective features. */\n isPortalAdmin?: boolean\n}\n\nfunction isPortalPattern(pattern: string | undefined): pattern is string {\n if (!pattern) return false\n return pattern.startsWith('/[orgSlug]/portal/') || pattern === '/[orgSlug]/portal'\n}\n\nfunction hasNoUnresolvedParams(href: string): boolean {\n return !href.includes('[')\n}\n\nfunction resolveHref(pattern: string, orgSlug: string): string {\n return pattern.replace('[orgSlug]', orgSlug)\n}\n\nfunction pickGroup(group: unknown): PortalNavGroupId {\n if (group === 'main' || group === 'account') return group\n return 'main'\n}\n\nfunction pickPreferredRoute(\n existing: FrontendRouteManifestEntry,\n candidate: FrontendRouteManifestEntry,\n): FrontendRouteManifestEntry {\n const existingFeatures = existing.requireCustomerFeatures ?? []\n const candidateFeatures = candidate.requireCustomerFeatures ?? []\n if (existingFeatures.length === 0 && candidateFeatures.length > 0) return candidate\n if (candidateFeatures.length === 0 && existingFeatures.length > 0) return existing\n if (existingFeatures.length !== candidateFeatures.length) {\n return candidateFeatures.length > existingFeatures.length ? candidate : existing\n }\n return existing\n}\n\n/**\n * Build the portal sidebar from the frontend route manifest.\n *\n * Mirrors `buildAdminNav()` for the portal surface: selects routes under\n * `/[orgSlug]/portal/*` that declare a `nav` block, applies\n * `requireCustomerFeatures` against the caller's grants (wildcards honored),\n * and returns ordered sidebar groups.\n *\n * Absence of `nav` on a metadata file means the page is routable but not\n * auto-listed \u2014 useful for detail/create pages.\n */\nexport function buildPortalNav({\n routes,\n orgSlug,\n grantedFeatures,\n}: BuildPortalNavOptions): PortalNavGroup[] {\n const mainItems: PortalNavItem[] = []\n const accountItems: PortalNavItem[] = []\n\n const dedupedByPattern = new Map<string, FrontendRouteManifestEntry>()\n for (const route of routes) {\n const pattern = route.pattern ?? route.path\n if (!isPortalPattern(pattern)) continue\n if (route.navHidden) continue\n const nav = route.nav\n if (!nav || typeof nav.label !== 'string' || nav.label.length === 0) continue\n const existing = dedupedByPattern.get(pattern)\n dedupedByPattern.set(pattern, existing ? pickPreferredRoute(existing, route) : route)\n }\n\n for (const route of dedupedByPattern.values()) {\n const pattern = (route.pattern ?? route.path) as string\n const nav = route.nav!\n\n const requireFeatures = route.requireCustomerFeatures ?? []\n if (requireFeatures.length) {\n if (!hasAllFeatures(grantedFeatures as string[], requireFeatures as string[])) continue\n }\n\n const href = resolveHref(pattern, orgSlug)\n if (!hasNoUnresolvedParams(href)) continue\n\n const group = pickGroup(nav.group)\n const item: PortalNavItem = {\n id: `portal-nav:${pattern}`,\n label: nav.label,\n labelKey: nav.labelKey,\n href,\n icon: nav.icon,\n order: typeof nav.order === 'number' ? nav.order : 100,\n }\n if (group === 'account') accountItems.push(item)\n else mainItems.push(item)\n }\n\n const sortItems = (items: PortalNavItem[]) =>\n items.sort((a, b) => {\n if (a.order !== b.order) return a.order - b.order\n return a.label.localeCompare(b.label)\n })\n\n sortItems(mainItems)\n sortItems(accountItems)\n\n const groups: PortalNavGroup[] = []\n if (mainItems.length) groups.push({ id: 'main', items: mainItems })\n if (accountItems.length) groups.push({ id: 'account', items: accountItems })\n return groups\n}\n\n/**\n * Merge sidebar groups from the portal nav endpoint with items contributed via\n * `usePortalInjectedMenuItems`. Auto-discovered entries take precedence \u2014\n * injected items with matching `id` or `href` are dropped as duplicates.\n */\nexport function mergePortalSidebarGroupsWithInjected<TInjected extends { id: string; href?: string }>(\n discovered: readonly PortalNavGroup[],\n injected: {\n main: readonly TInjected[]\n account: readonly TInjected[]\n },\n): {\n main: Array<PortalNavItem | TInjected>\n account: Array<PortalNavItem | TInjected>\n} {\n const mergeGroup = <T extends PortalNavItem | TInjected>(\n base: readonly PortalNavItem[],\n extra: readonly TInjected[],\n ): Array<PortalNavItem | TInjected> => {\n const knownIds = new Set(base.map((item) => item.id))\n const knownHrefs = new Set(base.map((item) => item.href).filter((href): href is string => Boolean(href)))\n const merged: Array<PortalNavItem | TInjected> = [...base]\n for (const item of extra) {\n if (knownIds.has(item.id)) continue\n if (item.href && knownHrefs.has(item.href)) continue\n merged.push(item)\n knownIds.add(item.id)\n if (item.href) knownHrefs.add(item.href)\n }\n return merged\n }\n\n const mainBase = discovered.find((g) => g.id === 'main')?.items ?? []\n const accountBase = discovered.find((g) => g.id === 'account')?.items ?? []\n return {\n main: mergeGroup(mainBase, injected.main),\n account: mergeGroup(accountBase, injected.account),\n }\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import type { FrontendRouteManifestEntry } from '@open-mercato/shared/modules/registry'\nimport { hasAllFeatures } from '@open-mercato/shared/security/features'\n\nexport type PortalNavGroupId = 'main' | 'account'\n\nexport type PortalNavItem = {\n id: string\n label: string\n labelKey?: string\n href: string\n icon?: string\n order: number\n}\n\nexport type PortalNavGroup = {\n id: PortalNavGroupId\n items: PortalNavItem[]\n}\n\nexport type BuildPortalNavOptions = {\n /** Route manifest to inspect (typically `getFrontendRouteManifests()`). */\n routes: readonly FrontendRouteManifestEntry[]\n /** Current customer org slug \u2014 substituted into `[orgSlug]` patterns. */\n orgSlug: string\n /** Feature strings granted to the current customer (may include wildcards). */\n grantedFeatures: readonly string[]\n /** If true, bypass feature checks (portal admin). Defaults to false. */\n isPortalAdmin?: boolean\n}\n\nfunction isPortalPattern(pattern: string | undefined): pattern is string {\n if (!pattern) return false\n return pattern.startsWith('/[orgSlug]/portal/') || pattern === '/[orgSlug]/portal'\n}\n\nfunction hasNoUnresolvedParams(href: string): boolean {\n return !href.includes('[')\n}\n\nfunction resolveHref(pattern: string, orgSlug: string): string {\n return pattern.replace('[orgSlug]', orgSlug)\n}\n\nfunction pickGroup(group: unknown): PortalNavGroupId {\n if (group === 'main' || group === 'account') return group\n return 'main'\n}\n\nfunction pickPreferredRoute(\n existing: FrontendRouteManifestEntry,\n candidate: FrontendRouteManifestEntry,\n): FrontendRouteManifestEntry {\n const existingFeatures = existing.requireCustomerFeatures ?? []\n const candidateFeatures = candidate.requireCustomerFeatures ?? []\n if (existingFeatures.length === 0 && candidateFeatures.length > 0) return candidate\n if (candidateFeatures.length === 0 && existingFeatures.length > 0) return existing\n if (existingFeatures.length !== candidateFeatures.length) {\n return candidateFeatures.length > existingFeatures.length ? candidate : existing\n }\n return existing\n}\n\n/**\n * Build the portal sidebar from the frontend route manifest.\n *\n * Mirrors `buildAdminNav()` for the portal surface: selects routes under\n * `/[orgSlug]/portal/*` that declare a `nav` block, applies\n * `requireCustomerFeatures` against the caller's grants (wildcards honored),\n * and returns ordered sidebar groups.\n *\n * Absence of `nav` on a metadata file means the page is routable but not\n * auto-listed \u2014 useful for detail/create pages.\n */\nexport function buildPortalNav({\n routes,\n orgSlug,\n grantedFeatures,\n isPortalAdmin = false,\n}: BuildPortalNavOptions): PortalNavGroup[] {\n const mainItems: PortalNavItem[] = []\n const accountItems: PortalNavItem[] = []\n\n const dedupedByPattern = new Map<string, FrontendRouteManifestEntry>()\n for (const route of routes) {\n const pattern = route.pattern ?? route.path\n if (!isPortalPattern(pattern)) continue\n if (route.navHidden) continue\n const nav = route.nav\n if (!nav || typeof nav.label !== 'string' || nav.label.length === 0) continue\n const existing = dedupedByPattern.get(pattern)\n dedupedByPattern.set(pattern, existing ? pickPreferredRoute(existing, route) : route)\n }\n\n for (const route of dedupedByPattern.values()) {\n const pattern = (route.pattern ?? route.path) as string\n const nav = route.nav!\n\n const requireFeatures = route.requireCustomerFeatures ?? []\n if (!isPortalAdmin && requireFeatures.length) {\n if (!hasAllFeatures(grantedFeatures as string[], requireFeatures as string[])) continue\n }\n\n const href = resolveHref(pattern, orgSlug)\n if (!hasNoUnresolvedParams(href)) continue\n\n const group = pickGroup(nav.group)\n const item: PortalNavItem = {\n id: `portal-nav:${pattern}`,\n label: nav.label,\n labelKey: nav.labelKey,\n href,\n icon: nav.icon,\n order: typeof nav.order === 'number' ? nav.order : 100,\n }\n if (group === 'account') accountItems.push(item)\n else mainItems.push(item)\n }\n\n const sortItems = (items: PortalNavItem[]) =>\n items.sort((a, b) => {\n if (a.order !== b.order) return a.order - b.order\n return a.label.localeCompare(b.label)\n })\n\n sortItems(mainItems)\n sortItems(accountItems)\n\n const groups: PortalNavGroup[] = []\n if (mainItems.length) groups.push({ id: 'main', items: mainItems })\n if (accountItems.length) groups.push({ id: 'account', items: accountItems })\n return groups\n}\n\n/**\n * Merge sidebar groups from the portal nav endpoint with items contributed via\n * `usePortalInjectedMenuItems`. Auto-discovered entries take precedence \u2014\n * injected items with matching `id` or `href` are dropped as duplicates.\n */\nexport function mergePortalSidebarGroupsWithInjected<TInjected extends { id: string; href?: string }>(\n discovered: readonly PortalNavGroup[],\n injected: {\n main: readonly TInjected[]\n account: readonly TInjected[]\n },\n): {\n main: Array<PortalNavItem | TInjected>\n account: Array<PortalNavItem | TInjected>\n} {\n const mergeGroup = <T extends PortalNavItem | TInjected>(\n base: readonly PortalNavItem[],\n extra: readonly TInjected[],\n ): Array<PortalNavItem | TInjected> => {\n const knownIds = new Set(base.map((item) => item.id))\n const knownHrefs = new Set(base.map((item) => item.href).filter((href): href is string => Boolean(href)))\n const merged: Array<PortalNavItem | TInjected> = [...base]\n for (const item of extra) {\n if (knownIds.has(item.id)) continue\n if (item.href && knownHrefs.has(item.href)) continue\n merged.push(item)\n knownIds.add(item.id)\n if (item.href) knownHrefs.add(item.href)\n }\n return merged\n }\n\n const mainBase = discovered.find((g) => g.id === 'main')?.items ?? []\n const accountBase = discovered.find((g) => g.id === 'account')?.items ?? []\n return {\n main: mergeGroup(mainBase, injected.main),\n account: mergeGroup(accountBase, injected.account),\n }\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -8,15 +8,10 @@ const buttonVariants = cva(
8
8
  variants: {
9
9
  variant: {
10
10
  default: "bg-primary text-primary-foreground shadow-xs hover:bg-primary-hover",
11
- /* Destructive is quiet by design: red text on a calm surface. A wall
12
- of solid red buttons trains users to ignore red. The filled form is
13
- `destructive-solid`, reserved for the single point-of-no-return
14
- confirmation inside a dialog — never for the button that opens it. */
15
- destructive: "border border-destructive bg-background text-destructive shadow-xs hover:bg-destructive/10 aria-invalid:ring-destructive dark:aria-invalid:ring-destructive",
16
- "destructive-solid": "bg-destructive text-white shadow-xs hover:bg-destructive/90 aria-invalid:ring-destructive dark:aria-invalid:ring-destructive",
17
- "destructive-outline": "border border-destructive bg-background text-destructive shadow-xs hover:bg-destructive/10",
18
- "destructive-soft": "bg-destructive/10 text-destructive hover:bg-destructive/15",
19
- "destructive-ghost": "text-destructive hover:bg-destructive/10",
11
+ destructive: "bg-destructive text-white shadow-xs hover:bg-destructive/90 aria-invalid:ring-destructive dark:aria-invalid:ring-destructive dark:bg-destructive/10",
12
+ "destructive-outline": "border border-destructive/30 bg-background text-destructive shadow-xs hover:bg-destructive/10 dark:border-destructive/40 dark:hover:bg-destructive/15",
13
+ "destructive-soft": "bg-destructive/10 text-destructive hover:bg-destructive/15 dark:bg-destructive/15 dark:hover:bg-destructive/20",
14
+ "destructive-ghost": "text-destructive hover:bg-destructive/10 dark:hover:bg-destructive/15",
20
15
  outline: "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
21
16
  secondary: "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
22
17
  ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/primitives/button.tsx"],
4
- "sourcesContent": ["import * as React from 'react'\nimport { Slot } from '@radix-ui/react-slot'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from '@open-mercato/shared/lib/utils'\n\nconst buttonVariants = cva(\n \"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium cursor-pointer transition-all disabled:pointer-events-none disabled:bg-bg-disabled disabled:text-text-disabled disabled:border-border-disabled disabled:shadow-none disabled:[background-image:none] [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:outline-none focus-visible:shadow-focus aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive\",\n {\n variants: {\n variant: {\n default: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary-hover',\n /* Destructive is quiet by design: red text on a calm surface. A wall\n of solid red buttons trains users to ignore red. The filled form is\n `destructive-solid`, reserved for the single point-of-no-return\n confirmation inside a dialog \u2014 never for the button that opens it. */\n destructive:\n 'border border-destructive bg-background text-destructive shadow-xs hover:bg-destructive/10 aria-invalid:ring-destructive dark:aria-invalid:ring-destructive',\n 'destructive-solid':\n 'bg-destructive text-white shadow-xs hover:bg-destructive/90 aria-invalid:ring-destructive dark:aria-invalid:ring-destructive',\n 'destructive-outline':\n 'border border-destructive bg-background text-destructive shadow-xs hover:bg-destructive/10',\n 'destructive-soft':\n 'bg-destructive/10 text-destructive hover:bg-destructive/15',\n 'destructive-ghost':\n 'text-destructive hover:bg-destructive/10',\n outline:\n 'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',\n secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',\n ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',\n muted: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',\n link: 'text-primary underline-offset-4 hover:underline',\n },\n size: {\n default: 'h-9 px-4 py-2 has-[>svg]:px-3',\n sm: 'h-8 gap-1.5 px-3 has-[>svg]:px-2.5',\n lg: 'h-10 px-6 has-[>svg]:px-4',\n '2xs': 'h-7 gap-1 px-2 py-0.5 has-[>svg]:px-1.5 text-xs',\n icon: 'size-9',\n },\n },\n defaultVariants: {\n variant: 'default',\n size: 'default',\n },\n }\n)\n\nexport function Button({\n className,\n variant,\n size,\n asChild = false,\n ...props\n}: React.ComponentProps<'button'> &\n VariantProps<typeof buttonVariants> & { asChild?: boolean }) {\n const Comp = asChild ? Slot : 'button'\n return <Comp data-slot=\"button\" className={cn(buttonVariants({ variant, size, className }))} {...props} />\n}\n\nexport { buttonVariants }\n\n"],
5
- "mappings": "AAwDS;AAvDT,SAAS,YAAY;AACrB,SAAS,WAA8B;AACvC,SAAS,UAAU;AAEnB,MAAM,iBAAiB;AAAA,EACrB;AAAA,EACA;AAAA,IACE,UAAU;AAAA,MACR,SAAS;AAAA,QACP,SAAS;AAAA;AAAA;AAAA;AAAA;AAAA,QAKT,aACE;AAAA,QACF,qBACE;AAAA,QACF,uBACE;AAAA,QACF,oBACE;AAAA,QACF,qBACE;AAAA,QACF,SACE;AAAA,QACF,WAAW;AAAA,QACX,OAAO;AAAA,QACP,OAAO;AAAA,QACP,MAAM;AAAA,MACR;AAAA,MACA,MAAM;AAAA,QACJ,SAAS;AAAA,QACT,IAAI;AAAA,QACJ,IAAI;AAAA,QACJ,OAAO;AAAA,QACP,MAAM;AAAA,MACR;AAAA,IACF;AAAA,IACA,iBAAiB;AAAA,MACf,SAAS;AAAA,MACT,MAAM;AAAA,IACR;AAAA,EACF;AACF;AAEO,SAAS,OAAO;AAAA,EACrB;AAAA,EACA;AAAA,EACA;AAAA,EACA,UAAU;AAAA,EACV,GAAG;AACL,GAC+D;AAC7D,QAAM,OAAO,UAAU,OAAO;AAC9B,SAAO,oBAAC,QAAK,aAAU,UAAS,WAAW,GAAG,eAAe,EAAE,SAAS,MAAM,UAAU,CAAC,CAAC,GAAI,GAAG,OAAO;AAC1G;",
4
+ "sourcesContent": ["import * as React from 'react'\nimport { Slot } from '@radix-ui/react-slot'\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport { cn } from '@open-mercato/shared/lib/utils'\n\nconst buttonVariants = cva(\n \"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium cursor-pointer transition-all disabled:pointer-events-none disabled:bg-bg-disabled disabled:text-text-disabled disabled:border-border-disabled disabled:shadow-none disabled:[background-image:none] [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:outline-none focus-visible:shadow-focus aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive\",\n {\n variants: {\n variant: {\n default: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary-hover',\n destructive:\n 'bg-destructive text-white shadow-xs hover:bg-destructive/90 aria-invalid:ring-destructive dark:aria-invalid:ring-destructive dark:bg-destructive/10',\n 'destructive-outline':\n 'border border-destructive/30 bg-background text-destructive shadow-xs hover:bg-destructive/10 dark:border-destructive/40 dark:hover:bg-destructive/15',\n 'destructive-soft':\n 'bg-destructive/10 text-destructive hover:bg-destructive/15 dark:bg-destructive/15 dark:hover:bg-destructive/20',\n 'destructive-ghost':\n 'text-destructive hover:bg-destructive/10 dark:hover:bg-destructive/15',\n outline:\n 'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',\n secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',\n ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',\n muted: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',\n link: 'text-primary underline-offset-4 hover:underline',\n },\n size: {\n default: 'h-9 px-4 py-2 has-[>svg]:px-3',\n sm: 'h-8 gap-1.5 px-3 has-[>svg]:px-2.5',\n lg: 'h-10 px-6 has-[>svg]:px-4',\n '2xs': 'h-7 gap-1 px-2 py-0.5 has-[>svg]:px-1.5 text-xs',\n icon: 'size-9',\n },\n },\n defaultVariants: {\n variant: 'default',\n size: 'default',\n },\n }\n)\n\nexport function Button({\n className,\n variant,\n size,\n asChild = false,\n ...props\n}: React.ComponentProps<'button'> &\n VariantProps<typeof buttonVariants> & { asChild?: boolean }) {\n const Comp = asChild ? Slot : 'button'\n return <Comp data-slot=\"button\" className={cn(buttonVariants({ variant, size, className }))} {...props} />\n}\n\nexport { buttonVariants }\n\n"],
5
+ "mappings": "AAkDS;AAjDT,SAAS,YAAY;AACrB,SAAS,WAA8B;AACvC,SAAS,UAAU;AAEnB,MAAM,iBAAiB;AAAA,EACrB;AAAA,EACA;AAAA,IACE,UAAU;AAAA,MACR,SAAS;AAAA,QACP,SAAS;AAAA,QACT,aACE;AAAA,QACF,uBACE;AAAA,QACF,oBACE;AAAA,QACF,qBACE;AAAA,QACF,SACE;AAAA,QACF,WAAW;AAAA,QACX,OAAO;AAAA,QACP,OAAO;AAAA,QACP,MAAM;AAAA,MACR;AAAA,MACA,MAAM;AAAA,QACJ,SAAS;AAAA,QACT,IAAI;AAAA,QACJ,IAAI;AAAA,QACJ,OAAO;AAAA,QACP,MAAM;AAAA,MACR;AAAA,IACF;AAAA,IACA,iBAAiB;AAAA,MACf,SAAS;AAAA,MACT,MAAM;AAAA,IACR;AAAA,EACF;AACF;AAEO,SAAS,OAAO;AAAA,EACrB;AAAA,EACA;AAAA,EACA;AAAA,EACA,UAAU;AAAA,EACV,GAAG;AACL,GAC+D;AAC7D,QAAM,OAAO,UAAU,OAAO;AAC9B,SAAO,oBAAC,QAAK,aAAU,UAAS,WAAW,GAAG,eAAe,EAAE,SAAS,MAAM,UAAU,CAAC,CAAC,GAAI,GAAG,OAAO;AAC1G;",
6
6
  "names": []
7
7
  }
package/jest.config.cjs CHANGED
@@ -16,8 +16,7 @@ module.exports = {
16
16
  '^remark-gfm$': '<rootDir>/jest.markdown-mock.tsx',
17
17
  },
18
18
  transform: {
19
- // `.cjs` is included so the build-time source emitters under scripts/ are testable.
20
- '^.+\\.(cjs|(t|j)sx?)$': [
19
+ '^.+\\.(t|j)sx?$': [
21
20
  '<rootDir>/../../scripts/jest-mikroorm-transformer.cjs',
22
21
  {
23
22
  tsconfig: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/ui",
3
- "version": "0.6.7-develop.6860.1.f796b22e80",
3
+ "version": "0.6.7",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -137,45 +137,43 @@
137
137
  "@dnd-kit/sortable": "^10.0.0",
138
138
  "@dnd-kit/utilities": "^3.2.2",
139
139
  "@mdxeditor/editor": "^4.0.4",
140
- "@radix-ui/react-accordion": "^1.2.20",
141
- "@radix-ui/react-dialog": "^1.1.23",
142
- "@radix-ui/react-popover": "^1.1.23",
143
- "@radix-ui/react-radio-group": "^1.4.7",
144
- "@radix-ui/react-scroll-area": "^1.2.18",
145
- "@radix-ui/react-select": "^2.3.7",
146
- "@radix-ui/react-slider": "^1.4.7",
147
- "@radix-ui/react-tooltip": "^1.2.16",
140
+ "@radix-ui/react-accordion": "^1.2.16",
141
+ "@radix-ui/react-dialog": "^1.1.19",
142
+ "@radix-ui/react-popover": "^1.1.19",
143
+ "@radix-ui/react-radio-group": "^1.4.3",
144
+ "@radix-ui/react-scroll-area": "^1.2.14",
145
+ "@radix-ui/react-select": "^2.3.3",
146
+ "@radix-ui/react-slider": "^1.4.3",
147
+ "@radix-ui/react-tooltip": "^1.2.12",
148
148
  "@tanstack/react-virtual": "^3.14.6",
149
149
  "cmdk": "^1.0.0",
150
150
  "date-fns": "4.4.0",
151
151
  "react-big-calendar": "^1.20.0",
152
152
  "react-day-picker": "^10.0.1",
153
153
  "react-markdown": "^10.1.0",
154
- "recharts": "^3.10.1",
154
+ "recharts": "^3.9.2",
155
155
  "remark-gfm": "^4.0.1"
156
156
  },
157
157
  "peerDependencies": {
158
- "@open-mercato/shared": "0.6.7-develop.6860.1.f796b22e80",
158
+ "@open-mercato/shared": "0.6.7",
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
- "@figma/code-connect": "^1.3.4",
165
- "@open-mercato/shared": "0.6.7-develop.6860.1.f796b22e80",
164
+ "@open-mercato/shared": "0.6.7",
166
165
  "@testing-library/dom": "^10.4.1",
167
- "@testing-library/jest-dom": "^7.0.0",
166
+ "@testing-library/jest-dom": "^6.9.1",
168
167
  "@testing-library/react": "^16.3.1",
169
168
  "@types/jest": "^30.0.0",
170
169
  "@types/react": "^19.2.17",
171
170
  "@types/react-dom": "^19.2.3",
172
171
  "jest": "^30.4.2",
173
172
  "jest-environment-jsdom": "^30.4.1",
174
- "react": "19.2.8",
175
- "react-dom": "19.2.8",
176
- "react-is": "^19.2.8",
177
- "ts-jest": "^29.4.12",
178
- "ts-morph": "^28.0.0",
173
+ "react": "19.2.7",
174
+ "react-dom": "19.2.7",
175
+ "react-is": "^19.2.7",
176
+ "ts-jest": "^29.4.11",
179
177
  "typescript": "7.0.2"
180
178
  },
181
179
  "publishConfig": {
@@ -185,6 +183,5 @@
185
183
  "type": "git",
186
184
  "url": "https://github.com/open-mercato/open-mercato",
187
185
  "directory": "packages/ui"
188
- },
189
- "stableVersion": "0.6.6"
186
+ }
190
187
  }
package/src/ai/AiChat.tsx CHANGED
@@ -304,39 +304,10 @@ function mapErrorCodeToVariant(
304
304
  'tool_not_whitelisted',
305
305
  'tool_features_denied',
306
306
  'attachment_type_not_accepted',
307
- // Content-safety rejections are soft: the user can rephrase and retry.
308
- 'moderation_blocked',
309
- 'moderation_unavailable',
310
307
  ])
311
308
  return warningCodes.has(code) ? 'warning' : 'destructive'
312
309
  }
313
310
 
314
- /**
315
- * Maps known error codes to a translated, user-safe message. For content-safety
316
- * codes the raw server message is intentionally generic (no category oracle);
317
- * the UI shows the friendly localized copy instead of the internal text.
318
- */
319
- function resolveChatErrorMessage(
320
- code: string | undefined,
321
- fallback: string,
322
- translate: (key: string, fallbackText?: string) => string,
323
- ): string {
324
- switch (code) {
325
- case 'moderation_blocked':
326
- return translate(
327
- 'ai_assistant.errors.moderationBlocked',
328
- 'Your message was blocked by the content safety filter. Please rephrase and try again.',
329
- )
330
- case 'moderation_unavailable':
331
- return translate(
332
- 'ai_assistant.errors.moderationUnavailable',
333
- 'The content safety check is temporarily unavailable. Please try again in a moment.',
334
- )
335
- default:
336
- return fallback
337
- }
338
- }
339
-
340
311
  const MARKDOWN_TYPOGRAPHY_CLASS = cn(
341
312
  '[&_p]:my-1 [&_p:first-child]:mt-0 [&_p:last-child]:mb-0',
342
313
  '[&_ul]:my-2 [&_ol]:my-2 [&_ul]:ml-4 [&_ol]:ml-4 [&_ul]:list-disc [&_ol]:list-decimal',
@@ -1659,7 +1630,7 @@ export function AiChat({
1659
1630
  {chat.error.code ? (
1660
1631
  <span className="mr-2 font-mono text-xs">{chat.error.code}</span>
1661
1632
  ) : null}
1662
- {resolveChatErrorMessage(chat.error.code, chat.error.message, t)}
1633
+ {chat.error.message}
1663
1634
  </AlertDescription>
1664
1635
  </Alert>
1665
1636
  ) : null}
@@ -93,8 +93,6 @@ const dict = {
93
93
  'ai_assistant.chat.emptyTranscript':
94
94
  'No messages yet. Ask the agent anything to get started.',
95
95
  'ai_assistant.chat.errorTitle': 'Agent dispatch failed',
96
- 'ai_assistant.errors.moderationBlocked':
97
- 'Your message was blocked by the content safety filter. Please rephrase and try again.',
98
96
  'ai_assistant.chat.agentTasksTitle': 'Tool calls',
99
97
  'ai_assistant.chat.regionLabel': 'AI chat',
100
98
  'ai_assistant.chat.send': 'Send message',
@@ -579,35 +577,6 @@ describe('<AiChat>', () => {
579
577
  expect(screen.getByText('agent_unknown')).toBeInTheDocument()
580
578
  })
581
579
 
582
- it('renders the translated message (not the raw server text) for a moderation_blocked envelope', async () => {
583
- const fetchMock = apiFetch as unknown as jest.Mock
584
- fetchMock.mockResolvedValueOnce(
585
- createErrorResponse(400, {
586
- error: 'Input rejected by the content safety filter.',
587
- code: 'moderation_blocked',
588
- }),
589
- )
590
-
591
- renderWithProviders(<AiChat agent="customers.account_assistant" />, { dict })
592
-
593
- const textarea = screen.getByLabelText('Message composer') as HTMLTextAreaElement
594
- fireEvent.change(textarea, { target: { value: 'flagged input' } })
595
- await act(async () => {
596
- fireEvent.keyDown(textarea, { key: 'Enter', ctrlKey: true })
597
- })
598
-
599
- await waitFor(() => {
600
- expect(screen.getByText('moderation_blocked')).toBeInTheDocument()
601
- })
602
- // The friendly localized copy is shown; the internal server text is not.
603
- expect(
604
- screen.getByText(/blocked by the content safety filter/i),
605
- ).toBeInTheDocument()
606
- expect(screen.queryByText('Input rejected by the content safety filter.')).toBeNull()
607
- // Content-safety rejections render as a soft warning, not destructive.
608
- expect(document.querySelector('[data-ai-chat-error="moderation_blocked"]')).not.toBeNull()
609
- })
610
-
611
580
  it('Escape aborts an in-flight streaming response', async () => {
612
581
  const fetchMock = apiFetch as unknown as jest.Mock
613
582
  // Build a stream we can keep open until the component aborts it.