@open-mercato/ui 0.4.11-develop.2635.9f9e474720 → 0.5.0

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 (41) hide show
  1. package/.turbo/turbo-build.log +2 -2
  2. package/AGENTS.md +28 -4
  3. package/agentic/standalone-guide.md +97 -0
  4. package/build.mjs +10 -6
  5. package/dist/backend/AppShell.js +15 -2
  6. package/dist/backend/AppShell.js.map +2 -2
  7. package/dist/backend/DataTable.js +22 -1
  8. package/dist/backend/DataTable.js.map +2 -2
  9. package/dist/backend/detail/CustomDataSection.js +1 -5
  10. package/dist/backend/detail/CustomDataSection.js.map +2 -2
  11. package/dist/backend/detail/InlineEditors.js +2 -5
  12. package/dist/backend/detail/InlineEditors.js.map +2 -2
  13. package/dist/backend/detail/NotesSection.js +2 -6
  14. package/dist/backend/detail/NotesSection.js.map +2 -2
  15. package/dist/backend/icons/lucideRegistry.generated.js +93 -3
  16. package/dist/backend/icons/lucideRegistry.generated.js.map +2 -2
  17. package/dist/backend/markdown/MarkdownContent.js +47 -4
  18. package/dist/backend/markdown/MarkdownContent.js.map +2 -2
  19. package/dist/portal/PortalShell.js +41 -11
  20. package/dist/portal/PortalShell.js.map +2 -2
  21. package/dist/portal/hooks/usePortalDashboardWidgets.js +40 -1
  22. package/dist/portal/hooks/usePortalDashboardWidgets.js.map +2 -2
  23. package/dist/portal/utils/nav.js +84 -0
  24. package/dist/portal/utils/nav.js.map +7 -0
  25. package/package.json +13 -9
  26. package/src/backend/AppShell.tsx +22 -2
  27. package/src/backend/DataTable.tsx +28 -5
  28. package/src/backend/__tests__/AppShell.test.tsx +67 -0
  29. package/src/backend/__tests__/FormHeader.test.tsx +0 -1
  30. package/src/backend/detail/CustomDataSection.tsx +1 -10
  31. package/src/backend/detail/InlineEditors.tsx +3 -15
  32. package/src/backend/detail/NotesSection.tsx +5 -14
  33. package/src/backend/icons/lucideRegistry.generated.tsx +93 -3
  34. package/src/backend/injection/__tests__/resolveInjectedIcon.test.tsx +7 -0
  35. package/src/backend/markdown/MarkdownContent.tsx +76 -6
  36. package/src/backend/section-page/types.ts +1 -0
  37. package/src/portal/PortalShell.tsx +43 -11
  38. package/src/portal/hooks/__tests__/usePortalDashboardWidgets.test.tsx +117 -0
  39. package/src/portal/hooks/usePortalDashboardWidgets.ts +55 -1
  40. package/src/portal/utils/__tests__/nav.test.ts +199 -0
  41. package/src/portal/utils/nav.ts +150 -0
@@ -4,7 +4,65 @@ import * as React from 'react'
4
4
  import type { PluggableList } from 'unified'
5
5
  import { useMarkdownRemarkPlugins } from './useMarkdownRemarkPlugins'
6
6
 
7
- const ReactMarkdown = React.lazy(() => import('react-markdown'))
7
+ type ReactMarkdownProps = {
8
+ children: React.ReactNode
9
+ remarkPlugins?: PluggableList
10
+ }
11
+
12
+ const isTestEnv = typeof process !== 'undefined' && process.env.NODE_ENV === 'test'
13
+
14
+ const TestMarkdownComponent: React.ComponentType<ReactMarkdownProps> = ({ children }) => <>{children}</>
15
+
16
+ let loadedReactMarkdownComponent: React.ComponentType<ReactMarkdownProps> | null = isTestEnv
17
+ ? TestMarkdownComponent
18
+ : null
19
+ let reactMarkdownComponentPromise: Promise<React.ComponentType<ReactMarkdownProps>> | null = null
20
+
21
+ function loadReactMarkdownComponent(): Promise<React.ComponentType<ReactMarkdownProps>> {
22
+ if (loadedReactMarkdownComponent) {
23
+ return Promise.resolve(loadedReactMarkdownComponent)
24
+ }
25
+
26
+ if (!reactMarkdownComponentPromise) {
27
+ reactMarkdownComponentPromise = import('react-markdown').then((mod) => {
28
+ const component = mod.default as React.ComponentType<ReactMarkdownProps>
29
+ loadedReactMarkdownComponent = component
30
+ return component
31
+ })
32
+ }
33
+
34
+ return reactMarkdownComponentPromise
35
+ }
36
+
37
+ function ReactMarkdownComponent(props: ReactMarkdownProps) {
38
+ const [Component, setComponent] = React.useState<React.ComponentType<ReactMarkdownProps> | null>(
39
+ () => loadedReactMarkdownComponent,
40
+ )
41
+
42
+ React.useEffect(() => {
43
+ if (Component) {
44
+ return
45
+ }
46
+
47
+ let active = true
48
+
49
+ void loadReactMarkdownComponent().then((resolved) => {
50
+ if (active) {
51
+ setComponent(() => resolved)
52
+ }
53
+ })
54
+
55
+ return () => {
56
+ active = false
57
+ }
58
+ }, [Component])
59
+
60
+ if (!Component) {
61
+ return null
62
+ }
63
+
64
+ return <Component {...props} />
65
+ }
8
66
 
9
67
  export type MarkdownContentProps = {
10
68
  body: string
@@ -13,8 +71,22 @@ export type MarkdownContentProps = {
13
71
  remarkPlugins?: PluggableList
14
72
  }
15
73
 
74
+ export type MarkdownPreviewProps = {
75
+ children: string
76
+ className?: string
77
+ remarkPlugins?: PluggableList
78
+ }
79
+
16
80
  const EMPTY_PLUGINS: PluggableList = []
17
81
 
82
+ export function MarkdownPreview({ children, className, remarkPlugins }: MarkdownPreviewProps) {
83
+ return (
84
+ <div className={className}>
85
+ <ReactMarkdownComponent remarkPlugins={remarkPlugins}>{children}</ReactMarkdownComponent>
86
+ </div>
87
+ )
88
+ }
89
+
18
90
  export function MarkdownContent({
19
91
  body,
20
92
  format = 'text',
@@ -31,10 +103,8 @@ export function MarkdownContent({
31
103
  }
32
104
 
33
105
  return (
34
- <React.Suspense fallback={<div className={className}>{body}</div>}>
35
- <ReactMarkdown className={className} remarkPlugins={plugins}>
36
- {body}
37
- </ReactMarkdown>
38
- </React.Suspense>
106
+ <MarkdownPreview className={className} remarkPlugins={plugins}>
107
+ {body}
108
+ </MarkdownPreview>
39
109
  )
40
110
  }
@@ -6,6 +6,7 @@ export type SectionNavItem = {
6
6
  labelKey?: string
7
7
  href: string
8
8
  icon?: ReactNode
9
+ iconName?: string
9
10
  iconMarkup?: string
10
11
  requireFeatures?: string[]
11
12
  order?: number
@@ -1,5 +1,5 @@
1
1
  "use client"
2
- import { type ReactNode, useState, useCallback, useMemo, useContext } from 'react'
2
+ import { type ReactNode, useEffect, useState, useCallback, useMemo, useContext } from 'react'
3
3
  import Image from 'next/image'
4
4
  import Link from 'next/link'
5
5
  import { usePathname } from 'next/navigation'
@@ -12,6 +12,8 @@ import { mergeMenuItems } from '../backend/injection/mergeMenuItems'
12
12
  import type { MergedMenuItem } from '../backend/injection/mergeMenuItems'
13
13
  import { PortalNotificationBell } from './components/PortalNotificationBell'
14
14
  import { usePortalContext } from './PortalContext'
15
+ import { apiCall } from '../backend/utils/apiCall'
16
+ import type { PortalNavGroup } from './utils/nav'
15
17
 
16
18
  // Component replacement handle IDs (FROZEN once shipped)
17
19
  export const PORTAL_SHELL_HANDLE = 'page:portal:layout'
@@ -174,29 +176,59 @@ export function PortalShell({
174
176
  const portalHome = orgSlug ? `/${orgSlug}/portal` : '/portal'
175
177
  const loginHref = orgSlug ? `/${orgSlug}/portal/login` : '/portal/login'
176
178
  const signupHref = orgSlug ? `/${orgSlug}/portal/signup` : '/portal/signup'
177
- const dashboardHref = orgSlug ? `/${orgSlug}/portal/dashboard` : '/portal/dashboard'
178
- const profileHref = orgSlug ? `/${orgSlug}/portal/profile` : '/portal/profile'
179
179
  // Always use the resolved organization name from the database.
180
180
  // Fall back to the generic portal title — never display the raw slug.
181
181
  const headerTitle = orgName || t('portal.title', 'Customer Portal')
182
182
 
183
183
  const closeMobile = useCallback(() => setMobileOpen(false), [])
184
184
 
185
+ const [autoNavGroups, setAutoNavGroups] = useState<PortalNavGroup[]>([])
186
+ useEffect(() => {
187
+ if (!authenticated) {
188
+ setAutoNavGroups([])
189
+ return
190
+ }
191
+ let cancelled = false
192
+ const load = async () => {
193
+ try {
194
+ const { ok, result } = await apiCall<{ ok: boolean; groups?: PortalNavGroup[] }>(
195
+ '/api/customer_accounts/portal/nav',
196
+ )
197
+ if (cancelled || !ok || !result?.ok) return
198
+ setAutoNavGroups(Array.isArray(result.groups) ? result.groups : [])
199
+ } catch {
200
+ if (!cancelled) setAutoNavGroups([])
201
+ }
202
+ }
203
+ void load()
204
+ return () => {
205
+ cancelled = true
206
+ }
207
+ }, [authenticated])
208
+
185
209
  const mergedNavItems = useMemo(() => {
186
210
  if (!authenticated) return []
187
- const builtIn = [
188
- { id: 'portal-dashboard', labelKey: 'portal.nav.dashboard', href: dashboardHref },
189
- ]
211
+ const discovered = autoNavGroups.find((g) => g.id === 'main')?.items ?? []
212
+ const builtIn = discovered.map((item) => ({
213
+ id: item.id,
214
+ labelKey: item.labelKey,
215
+ label: item.label,
216
+ href: item.href,
217
+ }))
190
218
  return mergeMenuItems(builtIn, injectedMainItems)
191
- }, [authenticated, dashboardHref, injectedMainItems])
219
+ }, [authenticated, autoNavGroups, injectedMainItems])
192
220
 
193
221
  const mergedAccountItems = useMemo(() => {
194
222
  if (!authenticated) return []
195
- const builtIn = [
196
- { id: 'portal-profile', labelKey: 'portal.nav.profile', href: profileHref },
197
- ]
223
+ const discovered = autoNavGroups.find((g) => g.id === 'account')?.items ?? []
224
+ const builtIn = discovered.map((item) => ({
225
+ id: item.id,
226
+ labelKey: item.labelKey,
227
+ label: item.label,
228
+ href: item.href,
229
+ }))
198
230
  return mergeMenuItems(builtIn, injectedAccountItems)
199
- }, [authenticated, profileHref, injectedAccountItems])
231
+ }, [authenticated, autoNavGroups, injectedAccountItems])
200
232
 
201
233
  /* ---- PUBLIC LAYOUT ---- */
202
234
  if (!authenticated) {
@@ -0,0 +1,117 @@
1
+ /** @jest-environment jsdom */
2
+ import * as React from 'react'
3
+ import { renderHook, waitFor } from '@testing-library/react'
4
+
5
+ const loadInjectionWidgetsForSpotMock = jest.fn()
6
+ const apiCallMock = jest.fn()
7
+
8
+ jest.mock('@open-mercato/shared/modules/widgets/injection-loader', () => ({
9
+ loadInjectionWidgetsForSpot: (...args: unknown[]) => loadInjectionWidgetsForSpotMock(...args),
10
+ }))
11
+
12
+ jest.mock('../../../backend/utils/apiCall', () => ({
13
+ apiCall: (...args: unknown[]) => apiCallMock(...args),
14
+ }))
15
+
16
+ import { usePortalDashboardWidgets } from '../usePortalDashboardWidgets'
17
+
18
+ function widget(id: string, features?: string[]) {
19
+ return {
20
+ moduleId: 'test',
21
+ spotId: 'portal:dashboard:sections',
22
+ widgetId: id,
23
+ Widget: () => null,
24
+ metadata: { id, features },
25
+ }
26
+ }
27
+
28
+ function mockFeatureCheckGranted(granted: string[]) {
29
+ apiCallMock.mockImplementation(async (url: string) => {
30
+ if (url === '/api/customer_accounts/portal/feature-check') {
31
+ return { ok: true, result: { ok: true, granted } }
32
+ }
33
+ throw new Error(`unexpected apiCall: ${url}`)
34
+ })
35
+ }
36
+
37
+ describe('usePortalDashboardWidgets — feature gating (Phase 1 regression)', () => {
38
+ beforeEach(() => {
39
+ jest.clearAllMocks()
40
+ })
41
+
42
+ it('returns widgets without required features regardless of grants', async () => {
43
+ loadInjectionWidgetsForSpotMock.mockResolvedValueOnce([widget('always-visible')])
44
+ // No features required → hook should skip the feature-check entirely
45
+
46
+ const { result } = renderHook(() => usePortalDashboardWidgets('portal:dashboard:sections' as any))
47
+
48
+ await waitFor(() => expect(result.current.isLoading).toBe(false))
49
+ expect(result.current.widgets).toHaveLength(1)
50
+ expect(result.current.widgets[0].widgetId).toBe('always-visible')
51
+ expect(apiCallMock).not.toHaveBeenCalled()
52
+ })
53
+
54
+ it('filters out widgets whose required feature the user lacks', async () => {
55
+ loadInjectionWidgetsForSpotMock.mockResolvedValueOnce([
56
+ widget('visible', ['portal.orders.view']),
57
+ widget('hidden', ['portal.billing.manage']),
58
+ ])
59
+ mockFeatureCheckGranted(['portal.orders.view'])
60
+
61
+ const { result } = renderHook(() => usePortalDashboardWidgets('portal:dashboard:sections' as any))
62
+
63
+ await waitFor(() => expect(result.current.isLoading).toBe(false))
64
+ const ids = result.current.widgets.map((w) => w.widgetId)
65
+ expect(ids).toEqual(['visible'])
66
+ expect(apiCallMock).toHaveBeenCalledWith(
67
+ '/api/customer_accounts/portal/feature-check',
68
+ expect.objectContaining({ method: 'POST' }),
69
+ )
70
+ })
71
+
72
+ it('resolves wildcard grants through the shared matcher', async () => {
73
+ loadInjectionWidgetsForSpotMock.mockResolvedValueOnce([
74
+ widget('orders-view', ['portal.orders.view']),
75
+ widget('orders-create', ['portal.orders.create']),
76
+ widget('billing', ['portal.billing.manage']),
77
+ ])
78
+ // Grant is a wildcard — server returns the concrete grants it matched.
79
+ mockFeatureCheckGranted(['portal.orders.view', 'portal.orders.create'])
80
+
81
+ const { result } = renderHook(() => usePortalDashboardWidgets('portal:dashboard:sections' as any))
82
+
83
+ await waitFor(() => expect(result.current.isLoading).toBe(false))
84
+ const ids = result.current.widgets.map((w) => w.widgetId).sort()
85
+ expect(ids).toEqual(['orders-create', 'orders-view'])
86
+ })
87
+
88
+ it('excludes all gated widgets when feature-check fails', async () => {
89
+ loadInjectionWidgetsForSpotMock.mockResolvedValueOnce([
90
+ widget('ungated'),
91
+ widget('gated', ['portal.orders.view']),
92
+ ])
93
+ apiCallMock.mockRejectedValueOnce(new Error('network down'))
94
+
95
+ const { result } = renderHook(() => usePortalDashboardWidgets('portal:dashboard:sections' as any))
96
+
97
+ await waitFor(() => expect(result.current.isLoading).toBe(false))
98
+ const ids = result.current.widgets.map((w) => w.widgetId)
99
+ // Ungated widget stays; gated widget is filtered because granted set is empty.
100
+ expect(ids).toEqual(['ungated'])
101
+ })
102
+
103
+ it('excludes widgets without a Widget component', async () => {
104
+ const noWidget = {
105
+ moduleId: 'test',
106
+ spotId: 'portal:dashboard:sections',
107
+ widgetId: 'data-only',
108
+ metadata: { id: 'data-only' },
109
+ } as any
110
+ loadInjectionWidgetsForSpotMock.mockResolvedValueOnce([widget('real'), noWidget])
111
+
112
+ const { result } = renderHook(() => usePortalDashboardWidgets('portal:dashboard:sections' as any))
113
+
114
+ await waitFor(() => expect(result.current.isLoading).toBe(false))
115
+ expect(result.current.widgets.map((w) => w.widgetId)).toEqual(['real'])
116
+ })
117
+ })
@@ -3,6 +3,39 @@
3
3
  import * as React from 'react'
4
4
  import type { InjectionSpotId } from '@open-mercato/shared/modules/widgets/injection'
5
5
  import { loadInjectionWidgetsForSpot, type LoadedInjectionWidget } from '@open-mercato/shared/modules/widgets/injection-loader'
6
+ import { hasAllFeatures } from '@open-mercato/shared/security/features'
7
+ import { apiCall } from '../../backend/utils/apiCall'
8
+
9
+ type PortalFeatureCheckResponse = {
10
+ ok: boolean
11
+ granted?: string[]
12
+ }
13
+
14
+ function collectRequiredFeatures(widgets: LoadedInjectionWidget[]): string[] {
15
+ const set = new Set<string>()
16
+ for (const widget of widgets) {
17
+ for (const feature of widget.metadata.features ?? []) {
18
+ if (!feature || feature.trim().length === 0) continue
19
+ set.add(feature)
20
+ }
21
+ }
22
+ return Array.from(set)
23
+ }
24
+
25
+ async function readPortalGrantedFeatures(features: string[]): Promise<Set<string>> {
26
+ if (features.length === 0) return new Set()
27
+ try {
28
+ const { ok, result: data } = await apiCall<PortalFeatureCheckResponse>('/api/customer_accounts/portal/feature-check', {
29
+ method: 'POST',
30
+ headers: { 'content-type': 'application/json' },
31
+ body: JSON.stringify({ features }),
32
+ })
33
+ if (!ok || !data?.ok) return new Set()
34
+ return new Set(data.granted ?? [])
35
+ } catch {
36
+ return new Set()
37
+ }
38
+ }
6
39
 
7
40
  /**
8
41
  * Loads UI injection widgets (with Widget component) for a portal spot.
@@ -10,6 +43,11 @@ import { loadInjectionWidgetsForSpot, type LoadedInjectionWidget } from '@open-m
10
43
  * Unlike `useInjectionDataWidgets` which loads data-only widgets (columns, fields, menuItems),
11
44
  * this hook loads widgets that export a `Widget` React component — suitable for
12
45
  * portal dashboard sections and other UI injection spots.
46
+ *
47
+ * Feature gating: widgets declaring `metadata.features` are filtered against the
48
+ * authenticated customer's grants resolved via
49
+ * `/api/customer_accounts/portal/feature-check`. Wildcard grants (`portal.*`) resolve
50
+ * through the shared matcher.
13
51
  */
14
52
  export function usePortalDashboardWidgets(spotId: InjectionSpotId): {
15
53
  widgets: LoadedInjectionWidget[]
@@ -19,6 +57,7 @@ export function usePortalDashboardWidgets(spotId: InjectionSpotId): {
19
57
  const [widgets, setWidgets] = React.useState<LoadedInjectionWidget[]>([])
20
58
  const [isLoading, setIsLoading] = React.useState(true)
21
59
  const [error, setError] = React.useState<string | null>(null)
60
+ const [grantedFeatures, setGrantedFeatures] = React.useState<Set<string>>(new Set())
22
61
 
23
62
  React.useEffect(() => {
24
63
  let mounted = true
@@ -31,6 +70,10 @@ export function usePortalDashboardWidgets(spotId: InjectionSpotId): {
31
70
  // Only keep widgets that have a Widget component
32
71
  const uiWidgets = loaded.filter((w) => typeof w.Widget === 'function')
33
72
  setWidgets(uiWidgets)
73
+ const required = collectRequiredFeatures(uiWidgets)
74
+ const granted = await readPortalGrantedFeatures(required)
75
+ if (!mounted) return
76
+ setGrantedFeatures(granted)
34
77
  } catch (loadError) {
35
78
  if (!mounted) return
36
79
  console.error(`[usePortalDashboardWidgets] Failed to load widgets for spot ${spotId}:`, loadError)
@@ -46,5 +89,16 @@ export function usePortalDashboardWidgets(spotId: InjectionSpotId): {
46
89
  }
47
90
  }, [spotId])
48
91
 
49
- return { widgets, isLoading, error }
92
+ const grantedFeatureList = React.useMemo(() => Array.from(grantedFeatures), [grantedFeatures])
93
+
94
+ const visibleWidgets = React.useMemo(
95
+ () =>
96
+ widgets.filter((widget) => {
97
+ const required = widget.metadata.features ?? []
98
+ return required.length === 0 || hasAllFeatures(grantedFeatureList, required)
99
+ }),
100
+ [widgets, grantedFeatureList],
101
+ )
102
+
103
+ return { widgets: visibleWidgets, isLoading, error }
50
104
  }
@@ -0,0 +1,199 @@
1
+ import type { FrontendRouteManifestEntry } from '@open-mercato/shared/modules/registry'
2
+ import { buildPortalNav, mergePortalSidebarGroupsWithInjected } from '../nav'
3
+
4
+ function makeRoute(partial: Partial<FrontendRouteManifestEntry>): FrontendRouteManifestEntry {
5
+ return {
6
+ moduleId: 'test',
7
+ pattern: '/[orgSlug]/portal/test',
8
+ load: async () => null as any,
9
+ ...partial,
10
+ } as FrontendRouteManifestEntry
11
+ }
12
+
13
+ describe('buildPortalNav', () => {
14
+ it('auto-lists portal pages that declare nav metadata', () => {
15
+ const routes: FrontendRouteManifestEntry[] = [
16
+ makeRoute({
17
+ pattern: '/[orgSlug]/portal/dashboard',
18
+ nav: { label: 'Dashboard', labelKey: 'portal.nav.dashboard', group: 'main', order: 10 },
19
+ }),
20
+ makeRoute({
21
+ pattern: '/[orgSlug]/portal/profile',
22
+ nav: { label: 'Profile', labelKey: 'portal.nav.profile', group: 'account', order: 10 },
23
+ }),
24
+ ]
25
+
26
+ const groups = buildPortalNav({ routes, orgSlug: 'my-org', grantedFeatures: [] })
27
+
28
+ expect(groups).toEqual([
29
+ {
30
+ id: 'main',
31
+ items: [
32
+ expect.objectContaining({
33
+ label: 'Dashboard',
34
+ labelKey: 'portal.nav.dashboard',
35
+ href: '/my-org/portal/dashboard',
36
+ order: 10,
37
+ }),
38
+ ],
39
+ },
40
+ {
41
+ id: 'account',
42
+ items: [
43
+ expect.objectContaining({
44
+ label: 'Profile',
45
+ labelKey: 'portal.nav.profile',
46
+ href: '/my-org/portal/profile',
47
+ order: 10,
48
+ }),
49
+ ],
50
+ },
51
+ ])
52
+ })
53
+
54
+ it('skips pages without nav metadata', () => {
55
+ const routes: FrontendRouteManifestEntry[] = [
56
+ makeRoute({ pattern: '/[orgSlug]/portal/login' }),
57
+ makeRoute({
58
+ pattern: '/[orgSlug]/portal/dashboard',
59
+ nav: { label: 'Dashboard', group: 'main' },
60
+ }),
61
+ ]
62
+ const groups = buildPortalNav({ routes, orgSlug: 'my-org', grantedFeatures: [] })
63
+ expect(groups).toHaveLength(1)
64
+ expect(groups[0].id).toBe('main')
65
+ expect(groups[0].items.map((i) => i.label)).toEqual(['Dashboard'])
66
+ })
67
+
68
+ it('skips pages the user lacks required features for', () => {
69
+ const routes: FrontendRouteManifestEntry[] = [
70
+ makeRoute({
71
+ pattern: '/[orgSlug]/portal/orders',
72
+ requireCustomerFeatures: ['portal.orders.view'],
73
+ nav: { label: 'Orders', group: 'main' },
74
+ }),
75
+ makeRoute({
76
+ pattern: '/[orgSlug]/portal/dashboard',
77
+ nav: { label: 'Dashboard', group: 'main' },
78
+ }),
79
+ ]
80
+
81
+ const groups = buildPortalNav({ routes, orgSlug: 'my-org', grantedFeatures: [] })
82
+ expect(groups).toEqual([
83
+ { id: 'main', items: [expect.objectContaining({ label: 'Dashboard' })] },
84
+ ])
85
+ })
86
+
87
+ it('matches wildcard grants like portal.*', () => {
88
+ const routes: FrontendRouteManifestEntry[] = [
89
+ makeRoute({
90
+ pattern: '/[orgSlug]/portal/orders',
91
+ requireCustomerFeatures: ['portal.orders.view'],
92
+ nav: { label: 'Orders', group: 'main' },
93
+ }),
94
+ ]
95
+
96
+ const groups = buildPortalNav({ routes, orgSlug: 'my-org', grantedFeatures: ['portal.*'] })
97
+ expect(groups).toEqual([{ id: 'main', items: [expect.objectContaining({ label: 'Orders' })] }])
98
+ })
99
+
100
+ it('bypasses feature checks when isPortalAdmin is true', () => {
101
+ const routes: FrontendRouteManifestEntry[] = [
102
+ makeRoute({
103
+ pattern: '/[orgSlug]/portal/orders',
104
+ requireCustomerFeatures: ['portal.orders.view'],
105
+ nav: { label: 'Orders', group: 'main' },
106
+ }),
107
+ ]
108
+
109
+ const groups = buildPortalNav({ routes, orgSlug: 'my-org', grantedFeatures: [], isPortalAdmin: true })
110
+ expect(groups[0].items[0].label).toBe('Orders')
111
+ })
112
+
113
+ it('ignores navHidden pages even when nav is declared', () => {
114
+ const routes: FrontendRouteManifestEntry[] = [
115
+ makeRoute({
116
+ pattern: '/[orgSlug]/portal/secret',
117
+ navHidden: true,
118
+ nav: { label: 'Secret', group: 'main' },
119
+ }),
120
+ ]
121
+ expect(buildPortalNav({ routes, orgSlug: 'my-org', grantedFeatures: [] })).toEqual([])
122
+ })
123
+
124
+ it('skips non-portal routes and dynamic patterns with unresolved params', () => {
125
+ const routes: FrontendRouteManifestEntry[] = [
126
+ makeRoute({
127
+ pattern: '/[orgSlug]/portal/orders/[id]',
128
+ nav: { label: 'Order Detail', group: 'main' },
129
+ }),
130
+ makeRoute({
131
+ pattern: '/[orgSlug]/checkout',
132
+ nav: { label: 'Checkout', group: 'main' },
133
+ }),
134
+ ]
135
+ expect(buildPortalNav({ routes, orgSlug: 'my-org', grantedFeatures: [] })).toEqual([])
136
+ })
137
+
138
+ it('sorts items by order then label', () => {
139
+ const routes: FrontendRouteManifestEntry[] = [
140
+ makeRoute({
141
+ pattern: '/[orgSlug]/portal/b',
142
+ nav: { label: 'B', group: 'main', order: 20 },
143
+ }),
144
+ makeRoute({
145
+ pattern: '/[orgSlug]/portal/a',
146
+ nav: { label: 'A', group: 'main', order: 10 },
147
+ }),
148
+ makeRoute({
149
+ pattern: '/[orgSlug]/portal/aa',
150
+ nav: { label: 'Aa', group: 'main', order: 10 },
151
+ }),
152
+ ]
153
+ const main = buildPortalNav({ routes, orgSlug: 'my-org', grantedFeatures: [] })[0]
154
+ expect(main.items.map((i) => i.label)).toEqual(['A', 'Aa', 'B'])
155
+ })
156
+ })
157
+
158
+ describe('mergePortalSidebarGroupsWithInjected', () => {
159
+ it('dedupes injected items by id', () => {
160
+ const result = mergePortalSidebarGroupsWithInjected(
161
+ [
162
+ {
163
+ id: 'main',
164
+ items: [
165
+ { id: 'portal-nav:/[orgSlug]/portal/dashboard', label: 'Dashboard', href: '/x/portal/dashboard', order: 10 },
166
+ ],
167
+ },
168
+ ],
169
+ {
170
+ main: [
171
+ { id: 'portal-nav:/[orgSlug]/portal/dashboard', label: 'Dashboard (injected)', href: '/x/portal/dashboard' } as any,
172
+ { id: 'orders-external', label: 'External', href: 'https://external' } as any,
173
+ ],
174
+ account: [],
175
+ },
176
+ )
177
+ expect(result.main).toHaveLength(2)
178
+ expect(result.main[0]).toEqual(expect.objectContaining({ label: 'Dashboard' }))
179
+ expect(result.main[1]).toEqual(expect.objectContaining({ id: 'orders-external' }))
180
+ })
181
+
182
+ it('dedupes injected items by href', () => {
183
+ const result = mergePortalSidebarGroupsWithInjected(
184
+ [
185
+ {
186
+ id: 'main',
187
+ items: [
188
+ { id: 'portal-nav:/[orgSlug]/portal/profile', label: 'Profile', href: '/x/portal/profile', order: 10 },
189
+ ],
190
+ },
191
+ ],
192
+ {
193
+ main: [{ id: 'different-id', label: 'Profile', href: '/x/portal/profile' } as any],
194
+ account: [],
195
+ },
196
+ )
197
+ expect(result.main).toHaveLength(1)
198
+ })
199
+ })