@open-mercato/ui 0.5.1-develop.2652.0276e72e45 → 0.5.1-develop.2657.a01847a9fa

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.
@@ -1,3 +1,3 @@
1
1
  Generated lucide registry with 127 icons -> /home/runner/work/open-mercato/open-mercato/packages/ui/src/backend/icons/lucideRegistry.generated.tsx
2
- Found 256 entry points
2
+ Found 257 entry points
3
3
  ui built successfully
package/AGENTS.md CHANGED
@@ -249,19 +249,43 @@ function MyPage({ orgSlug }) {
249
249
  | `section:portal:sidebar` | Navigation sidebar |
250
250
  | `section:portal:user-menu` | User dropdown |
251
251
 
252
- ### Declarative Customer Auth in Page Metadata
252
+ ### Portal Page Metadata (REQUIRED)
253
253
 
254
- Portal pages can declare `requireCustomerAuth: true` and `requireCustomerFeatures` in their page metadata:
254
+ Every portal page (any page under `frontend/[orgSlug]/portal/...`) MUST ship a sibling `page.meta.ts`. The `(frontend)` catch-all server-side enforces `requireCustomerAuth` and `requireCustomerFeatures` from the route manifest, so omitting metadata silently disables access control on a page that should be guarded.
255
+
256
+ Authoring checklist for each portal page:
257
+ - Public pages (`login`, `signup`, `verify`, anonymous landing): set `navHidden: true`. Do not set `requireCustomerAuth`.
258
+ - Authenticated pages: set `requireCustomerAuth: true`.
259
+ - Pages that need feature gating: add `requireCustomerFeatures: ['portal.<feature>']`. Wildcard grants like `portal.*` are honored by the shared matcher.
260
+ - Pages that should appear in the portal sidebar: add a `nav` block (label + group). Pages without `nav` are routable but not auto-listed (correct for detail/edit pages).
255
261
 
256
262
  ```typescript
257
263
  // frontend/[orgSlug]/portal/orders/page.meta.ts
258
- export const metadata = {
264
+ import type { PageMetadata } from '@open-mercato/shared/modules/registry'
265
+
266
+ export const metadata: PageMetadata = {
259
267
  requireCustomerAuth: true,
260
268
  requireCustomerFeatures: ['portal.orders.view'],
261
- navHidden: true,
269
+ titleKey: 'orders.nav.title',
270
+ title: 'Orders',
271
+ nav: {
272
+ label: 'Orders',
273
+ labelKey: 'orders.nav.title',
274
+ group: 'main', // 'main' | 'account'
275
+ order: 20,
276
+ icon: 'shopping-bag',
277
+ },
262
278
  }
279
+
280
+ export default metadata
263
281
  ```
264
282
 
283
+ The portal sidebar is built from these `nav` declarations by `/api/customer_accounts/portal/nav`, filtered by `CustomerRbacService` against the same `requireCustomerFeatures` that gates access. Granting the feature to a customer role is sufficient for the entry to appear — no separate menu-injection widget required.
284
+
285
+ For external links or items without a backing portal page, keep using `usePortalInjectedMenuItems` widgets.
286
+
287
+ Reference: see `packages/core/src/modules/portal/frontend/[orgSlug]/portal/{dashboard,profile,login,signup,verify}/page.meta.ts` for examples.
288
+
265
289
  ### Declarative Customer Role Features in setup.ts
266
290
 
267
291
  Modules can declare features to be merged into customer role ACLs:
@@ -206,6 +206,103 @@ import {
206
206
  | `PortalNotificationBell` | `t` | Header bell icon with unread badge |
207
207
  | `PortalNotificationPanel` | — | Notification dropdown panel |
208
208
 
209
+ ### Portal Page Structure
210
+
211
+ Every portal page is two files under `frontend/[orgSlug]/portal/<path>/`:
212
+
213
+ ```
214
+ page.tsx # Client component ("use client")
215
+ page.meta.ts # PageMetadata — access control + sidebar nav
216
+ ```
217
+
218
+ Minimal page:
219
+
220
+ ```tsx
221
+ "use client"
222
+ import { usePortalContext } from '@open-mercato/ui/portal/PortalContext'
223
+ import { PortalPageHeader } from '@open-mercato/ui/portal/components'
224
+
225
+ export default function MyPortalPage({ params }: { params: { orgSlug: string } }) {
226
+ const { auth } = usePortalContext()
227
+ const { user, resolvedFeatures } = auth
228
+ return <PortalPageHeader title="Orders" />
229
+ }
230
+ ```
231
+
232
+ Prefer `usePortalContext()` inside pages wrapped by `PortalLayoutShell` — it reads server-hydrated auth and avoids client loading flashes. Reach for `useCustomerAuth(orgSlug)` only when the server wrapper is unavailable.
233
+
234
+ Minimal `page.meta.ts`:
235
+
236
+ ```ts
237
+ import type { PageMetadata } from '@open-mercato/shared/modules/registry'
238
+
239
+ export const metadata: PageMetadata = {
240
+ requireCustomerAuth: true,
241
+ requireCustomerFeatures: ['portal.orders.view'],
242
+ titleKey: 'portal.orders.title',
243
+ title: 'Orders',
244
+ nav: { label: 'Orders', labelKey: 'portal.nav.orders', group: 'main', order: 20 },
245
+ }
246
+
247
+ export default metadata
248
+ ```
249
+
250
+ - Public pages (login, signup, verify, forgot/reset-password): omit `requireCustomerAuth`; set `navHidden: true`.
251
+ - Authenticated pages without sidebar presence (detail/create/edit): set `requireCustomerAuth: true`, **omit** `nav`.
252
+ - Sidebar-visible pages: include a `nav` block. Feature-gated pages are automatically hidden when the user lacks grants.
253
+
254
+ Reference: `packages/core/src/modules/portal/frontend/[orgSlug]/portal/{dashboard,profile}/page.{tsx,meta.ts}`.
255
+
256
+ ### Portal Feature-Gating Contract
257
+
258
+ Single source of truth: `requireCustomerFeatures` in `page.meta.ts`. The same list is enforced in three layers:
259
+
260
+ | Layer | Where | Effect |
261
+ |---|---|---|
262
+ | Page access | `apps/mercato/src/app/(frontend)/[...slug]/page.tsx` | Server-side gate via `CustomerRbacService.userHasAllFeatures()` — missing feature blocks render |
263
+ | Sidebar entry | `/api/customer_accounts/portal/nav` → `buildPortalNav()` at `packages/ui/src/portal/utils/nav.ts` | Same check — missing feature omits the entry |
264
+ | Injection widgets | `usePortalInjectedMenuItems` / `usePortalDashboardWidgets` | `/api/customer_accounts/portal/feature-check` + `hasAllFeatures()` — missing feature filters the widget |
265
+
266
+ Granting a customer role a feature (e.g. `portal.orders.view`) is sufficient to (a) reach the page, (b) see the sidebar entry, (c) see widgets gated by that feature. No separate menu-injection widget is required for sidebar presence when the page is backed by `page.meta.ts` with a `nav` block.
267
+
268
+ **MUST** resolve features via `hasAllFeatures` / `matchFeature` from `@open-mercato/shared/security/features`. Raw `Array.includes()` or `Set.has()` on feature arrays misses wildcards (`portal.*`) and is a bug.
269
+
270
+ Declare features in `acl.ts`; ship defaults per role via `defaultCustomerRoleFeatures` in `setup.ts`. Never rely on client-side checks alone as the access gate.
271
+
272
+ ### Portal SPA CSRF Posture
273
+
274
+ Dual cookies set by login (`packages/core/src/modules/customer_accounts/api/login.ts`):
275
+
276
+ | Cookie | Contents | TTL | Flags |
277
+ |---|---|---|---|
278
+ | `customer_auth_token` | Short-lived JWT | 8h | `httpOnly`, `sameSite: 'lax'`, `secure` in prod, `path: '/'` |
279
+ | `customer_session_token` | Raw session token (hashed at rest) | 30d (env: `CUSTOMER_SESSION_TTL_DAYS`) | same as above |
280
+
281
+ Primary CSRF defense: `SameSite=lax` + same-origin deployment. No explicit CSRF token — the browser blocks cross-origin POSTs.
282
+
283
+ Rules:
284
+ - Use `apiCall` for every write — it uses `credentials: 'same-origin'` and sets JSON headers.
285
+ - Never expose either cookie to JS. `httpOnly` is load-bearing; do not add companion cookies that mirror session state.
286
+ - Never accept cross-origin POSTs on portal routes. Cross-origin use cases are explicit exceptions: per-tenant origin allowlist + CSRF token + re-auth.
287
+ - `sameSite: 'lax'` lets GET navigations carry cookies — keep all state-changing side effects behind POST/PUT/PATCH/DELETE.
288
+ - Logout (`api/portal/logout.ts`) clears both cookies with `maxAge: 0`. Mirror this shape for any new logout-style endpoints.
289
+
290
+ Concurrent sessions are capped at `MAX_CUSTOMER_SESSIONS_PER_USER` (default 5) in `customerSessionService.createSession()`. New sessions above the cap soft-delete the oldest active session.
291
+
292
+ ### Portal XSS Discipline (Injected Widgets)
293
+
294
+ Third-party widgets render inside the authenticated portal and inherit user cookies. Enforce stricter discipline than first-party code because widgets load from arbitrary modules.
295
+
296
+ - **Forbidden**: `dangerouslySetInnerHTML` anywhere in portal injection widgets. Render structured data, not raw HTML.
297
+ - **Labels and user-facing text**: always through `useT()`; render as text children, never as HTML.
298
+ - **Icons**: Lucide components (`lucide-react`). No inline `<svg>` composed from user-controlled strings.
299
+ - **Asset URLs** (`src`, `href`, `action`, `srcDoc`): must not be user-controlled unless validated server-side against an allowlist.
300
+ - **No `eval`, `new Function`, `setTimeout(string)`**, or similar dynamic code paths.
301
+ - **Event-handler payloads** from SSE: validate shape (`isPortalBroadcastEvent` guards dispatch; never trust `event.data` to be well-formed without schema validation).
302
+ - **Styles**: no user-controlled strings in `style` props, CSS variables, or `className` built from untrusted input.
303
+
304
+ Prefer components that accept structured props over ones accepting `children` / `innerHTML` — the host keeps control of escaping.
305
+
209
306
  ### PortalShell Usage
210
307
 
211
308
  ```tsx
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
  import { jsx, jsxs } from "react/jsx-runtime";
3
- import { useState, useCallback, useMemo } from "react";
3
+ import { useEffect, useState, useCallback, useMemo } from "react";
4
4
  import Image from "next/image";
5
5
  import Link from "next/link";
6
6
  import { usePathname } from "next/navigation";
@@ -12,6 +12,7 @@ import { usePortalEventBridge } from "./hooks/usePortalEventBridge.js";
12
12
  import { mergeMenuItems } from "../backend/injection/mergeMenuItems.js";
13
13
  import { PortalNotificationBell } from "./components/PortalNotificationBell.js";
14
14
  import { usePortalContext } from "./PortalContext.js";
15
+ import { apiCall } from "../backend/utils/apiCall.js";
15
16
  const PORTAL_SHELL_HANDLE = "page:portal:layout";
16
17
  const PORTAL_HEADER_HANDLE = "section:portal:header";
17
18
  const PORTAL_FOOTER_HANDLE = "section:portal:footer";
@@ -101,24 +102,53 @@ function PortalShell({
101
102
  const portalHome = orgSlug ? `/${orgSlug}/portal` : "/portal";
102
103
  const loginHref = orgSlug ? `/${orgSlug}/portal/login` : "/portal/login";
103
104
  const signupHref = orgSlug ? `/${orgSlug}/portal/signup` : "/portal/signup";
104
- const dashboardHref = orgSlug ? `/${orgSlug}/portal/dashboard` : "/portal/dashboard";
105
- const profileHref = orgSlug ? `/${orgSlug}/portal/profile` : "/portal/profile";
106
105
  const headerTitle = orgName || t("portal.title", "Customer Portal");
107
106
  const closeMobile = useCallback(() => setMobileOpen(false), []);
107
+ const [autoNavGroups, setAutoNavGroups] = useState([]);
108
+ useEffect(() => {
109
+ if (!authenticated) {
110
+ setAutoNavGroups([]);
111
+ return;
112
+ }
113
+ let cancelled = false;
114
+ const load = async () => {
115
+ try {
116
+ const { ok, result } = await apiCall(
117
+ "/api/customer_accounts/portal/nav"
118
+ );
119
+ if (cancelled || !ok || !result?.ok) return;
120
+ setAutoNavGroups(Array.isArray(result.groups) ? result.groups : []);
121
+ } catch {
122
+ if (!cancelled) setAutoNavGroups([]);
123
+ }
124
+ };
125
+ void load();
126
+ return () => {
127
+ cancelled = true;
128
+ };
129
+ }, [authenticated]);
108
130
  const mergedNavItems = useMemo(() => {
109
131
  if (!authenticated) return [];
110
- const builtIn = [
111
- { id: "portal-dashboard", labelKey: "portal.nav.dashboard", href: dashboardHref }
112
- ];
132
+ const discovered = autoNavGroups.find((g) => g.id === "main")?.items ?? [];
133
+ const builtIn = discovered.map((item) => ({
134
+ id: item.id,
135
+ labelKey: item.labelKey,
136
+ label: item.label,
137
+ href: item.href
138
+ }));
113
139
  return mergeMenuItems(builtIn, injectedMainItems);
114
- }, [authenticated, dashboardHref, injectedMainItems]);
140
+ }, [authenticated, autoNavGroups, injectedMainItems]);
115
141
  const mergedAccountItems = useMemo(() => {
116
142
  if (!authenticated) return [];
117
- const builtIn = [
118
- { id: "portal-profile", labelKey: "portal.nav.profile", href: profileHref }
119
- ];
143
+ const discovered = autoNavGroups.find((g) => g.id === "account")?.items ?? [];
144
+ const builtIn = discovered.map((item) => ({
145
+ id: item.id,
146
+ labelKey: item.labelKey,
147
+ label: item.label,
148
+ href: item.href
149
+ }));
120
150
  return mergeMenuItems(builtIn, injectedAccountItems);
121
- }, [authenticated, profileHref, injectedAccountItems]);
151
+ }, [authenticated, autoNavGroups, injectedAccountItems]);
122
152
  if (!authenticated) {
123
153
  return /* @__PURE__ */ jsxs("div", { className: "flex min-h-svh flex-col bg-background", "data-portal-handle": PORTAL_SHELL_HANDLE, children: [
124
154
  /* @__PURE__ */ jsx("header", { className: "sticky top-0 z-40 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80", "data-portal-handle": PORTAL_HEADER_HANDLE, children: /* @__PURE__ */ jsxs("div", { className: "mx-auto flex h-16 w-full max-w-screen-lg items-center justify-between px-6", children: [
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/portal/PortalShell.tsx"],
4
- "sourcesContent": ["\"use client\"\nimport { type ReactNode, useState, useCallback, useMemo, useContext } from 'react'\nimport Image from 'next/image'\nimport Link from 'next/link'\nimport { usePathname } from 'next/navigation'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { Button } from '../primitives/button'\nimport { IconButton } from '../primitives/icon-button'\nimport { usePortalInjectedMenuItems } from './hooks/usePortalInjectedMenuItems'\nimport { usePortalEventBridge } from './hooks/usePortalEventBridge'\nimport { mergeMenuItems } from '../backend/injection/mergeMenuItems'\nimport type { MergedMenuItem } from '../backend/injection/mergeMenuItems'\nimport { PortalNotificationBell } from './components/PortalNotificationBell'\nimport { usePortalContext } from './PortalContext'\n\n// Component replacement handle IDs (FROZEN once shipped)\nexport const PORTAL_SHELL_HANDLE = 'page:portal:layout'\nexport const PORTAL_HEADER_HANDLE = 'section:portal:header'\nexport const PORTAL_FOOTER_HANDLE = 'section:portal:footer'\nexport const PORTAL_SIDEBAR_HANDLE = 'section:portal:sidebar'\nexport const PORTAL_USER_MENU_HANDLE = 'section:portal:user-menu'\n\nexport type PortalShellProps = {\n children: ReactNode\n /** Override orgSlug (used on public pages without context) */\n orgSlug?: string\n /** Override organization name (used on public pages without context) */\n organizationName?: string\n /** Whether to show authenticated layout. Auto-detected from context when omitted. */\n authenticated?: boolean\n /** Logout handler. Auto-provided from context when omitted. */\n onLogout?: () => void\n enableEventBridge?: boolean\n /** Override user name. Auto-read from context when omitted. */\n userName?: string\n /** Override user email. Auto-read from context when omitted. */\n userEmail?: string\n}\n\nfunction PortalEventBridgeMount() {\n usePortalEventBridge()\n return null\n}\n\n/* ---- Inline SVG icons ---- */\n\nfunction MenuIcon({ className }: { className?: string }) {\n return (\n <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" strokeLinejoin=\"round\" className={className}>\n <line x1=\"4\" x2=\"20\" y1=\"12\" y2=\"12\" /><line x1=\"4\" x2=\"20\" y1=\"6\" y2=\"6\" /><line x1=\"4\" x2=\"20\" y1=\"18\" y2=\"18\" />\n </svg>\n )\n}\n\nfunction XIcon({ className }: { className?: string }) {\n return (\n <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" strokeLinejoin=\"round\" className={className}>\n <path d=\"M18 6 6 18\" /><path d=\"m6 6 12 12\" />\n </svg>\n )\n}\n\nfunction LogOutIcon({ className }: { className?: string }) {\n return (\n <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" strokeLinejoin=\"round\" className={className}>\n <path d=\"M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4\" /><polyline points=\"16 17 21 12 16 7\" /><line x1=\"21\" x2=\"9\" y1=\"12\" y2=\"12\" />\n </svg>\n )\n}\n\n/* ---- Sidebar nav item ---- */\n\nfunction SidebarNavItem({\n item,\n active,\n t,\n onClick,\n}: {\n item: MergedMenuItem\n active: boolean\n t: (key: string, fallback?: string) => string\n onClick?: () => void\n}) {\n const label = item.labelKey ? t(item.labelKey, item.label) : item.label\n if (!label) return null\n\n const cls = [\n 'flex items-center gap-2.5 rounded-lg px-3 py-2 text-[13px] font-medium transition-colors',\n active\n ? 'bg-foreground text-background'\n : 'text-muted-foreground hover:bg-muted hover:text-foreground',\n ].join(' ')\n\n if (item.href) {\n return (\n <Link href={item.href} className={cls} data-menu-item-id={item.id} onClick={onClick}>\n {label}\n </Link>\n )\n }\n if (item.onClick) {\n return (\n <button type=\"button\" className={cls} data-menu-item-id={item.id} onClick={() => { item.onClick?.(); onClick?.() }}>\n {label}\n </button>\n )\n }\n return null\n}\n\n/* ---- User initials avatar ---- */\n\nfunction UserAvatar({ name, className }: { name?: string; className?: string }) {\n const initials = name\n ? name.split(' ').map((w) => w[0]).slice(0, 2).join('').toUpperCase()\n : '?'\n return (\n <div className={`flex items-center justify-center rounded-full bg-foreground text-[11px] font-semibold text-background ${className ?? 'size-8'}`}>\n {initials}\n </div>\n )\n}\n\n/* ---- Try reading from PortalContext ---- */\n\nfunction useOptionalPortalContext() {\n try {\n return usePortalContext()\n } catch {\n return null\n }\n}\n\n/* ================================================================== */\n/* PortalShell */\n/* ================================================================== */\n\n/**\n * Portal layout shell.\n *\n * When a `PortalProvider` is mounted in a parent layout, PortalShell reads\n * auth/tenant state from context \u2014 no re-fetching on navigation. Props are\n * used as overrides or for public pages that don't have a context.\n */\nexport function PortalShell({\n children,\n orgSlug: orgSlugProp,\n organizationName: orgNameProp,\n authenticated: authenticatedProp,\n onLogout: onLogoutProp,\n enableEventBridge = false,\n userName: userNameProp,\n userEmail: userEmailProp,\n}: PortalShellProps) {\n const t = useT()\n const pathname = usePathname()\n const [mobileOpen, setMobileOpen] = useState(false)\n\n // Read from context when available (persists across navigations)\n const portalCtx = useOptionalPortalContext()\n\n // Resolve values: context takes priority, props are fallback/override\n const orgSlug = portalCtx?.orgSlug ?? orgSlugProp\n const orgName = portalCtx?.tenant.organizationName ?? orgNameProp\n const user = portalCtx?.auth.user ?? null\n const authenticated = authenticatedProp ?? !!user\n const onLogout = onLogoutProp ?? portalCtx?.auth.logout\n const userName = userNameProp ?? user?.displayName\n const userEmail = userEmailProp ?? user?.email\n\n const { items: injectedMainItems } = usePortalInjectedMenuItems('menu:portal:sidebar:main')\n const { items: injectedAccountItems } = usePortalInjectedMenuItems('menu:portal:sidebar:account')\n\n const portalHome = orgSlug ? `/${orgSlug}/portal` : '/portal'\n const loginHref = orgSlug ? `/${orgSlug}/portal/login` : '/portal/login'\n const signupHref = orgSlug ? `/${orgSlug}/portal/signup` : '/portal/signup'\n const dashboardHref = orgSlug ? `/${orgSlug}/portal/dashboard` : '/portal/dashboard'\n const profileHref = orgSlug ? `/${orgSlug}/portal/profile` : '/portal/profile'\n // Always use the resolved organization name from the database.\n // Fall back to the generic portal title \u2014 never display the raw slug.\n const headerTitle = orgName || t('portal.title', 'Customer Portal')\n\n const closeMobile = useCallback(() => setMobileOpen(false), [])\n\n const mergedNavItems = useMemo(() => {\n if (!authenticated) return []\n const builtIn = [\n { id: 'portal-dashboard', labelKey: 'portal.nav.dashboard', href: dashboardHref },\n ]\n return mergeMenuItems(builtIn, injectedMainItems)\n }, [authenticated, dashboardHref, injectedMainItems])\n\n const mergedAccountItems = useMemo(() => {\n if (!authenticated) return []\n const builtIn = [\n { id: 'portal-profile', labelKey: 'portal.nav.profile', href: profileHref },\n ]\n return mergeMenuItems(builtIn, injectedAccountItems)\n }, [authenticated, profileHref, injectedAccountItems])\n\n /* ---- PUBLIC LAYOUT ---- */\n if (!authenticated) {\n return (\n <div className=\"flex min-h-svh flex-col bg-background\" data-portal-handle={PORTAL_SHELL_HANDLE}>\n <header className=\"sticky top-0 z-40 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80\" data-portal-handle={PORTAL_HEADER_HANDLE}>\n <div className=\"mx-auto flex h-16 w-full max-w-screen-lg items-center justify-between px-6\">\n <Link href={portalHome} className=\"flex items-center gap-2.5 text-foreground transition hover:opacity-80\" aria-label={headerTitle}>\n <Image src=\"/open-mercato.svg\" alt=\"\" width={28} height={28} className=\"\" priority />\n <span className=\"text-[15px] font-semibold tracking-tight\">{headerTitle}</span>\n </Link>\n <nav aria-label=\"Primary\" className=\"flex items-center gap-1\">\n <Button asChild variant=\"ghost\" size=\"sm\" className=\"text-[13px]\">\n <Link href={loginHref}>{t('portal.nav.login', 'Log In')}</Link>\n </Button>\n <Button asChild size=\"sm\" className=\"rounded-lg text-[13px]\">\n <Link href={signupHref}>{t('portal.nav.signup', 'Sign Up')}</Link>\n </Button>\n </nav>\n </div>\n </header>\n\n <main className=\"flex-1\">\n <div className=\"mx-auto flex w-full max-w-screen-lg flex-col gap-8 px-6 py-12 sm:py-20\">\n {children}\n </div>\n </main>\n\n <footer className=\"border-t\" data-portal-handle={PORTAL_FOOTER_HANDLE}>\n <div className=\"mx-auto flex w-full max-w-screen-lg items-center justify-between px-6 py-6\">\n <Link href={portalHome} className=\"flex items-center gap-2 text-muted-foreground transition hover:text-foreground\">\n <Image src=\"/open-mercato.svg\" alt=\"\" width={20} height={20} className=\"\" />\n <span className=\"text-sm font-medium text-foreground\">{headerTitle}</span>\n </Link>\n <p className=\"text-xs text-muted-foreground/60\">\n {t('portal.footer.copyright', '\\u00A9 {year} All rights reserved.', { year: new Date().getFullYear() })}\n </p>\n </div>\n </footer>\n </div>\n )\n }\n\n /* ---- AUTHENTICATED LAYOUT ---- */\n\n const sidebarContent = (\n <div className=\"flex h-full flex-col\" data-portal-handle={PORTAL_SIDEBAR_HANDLE}>\n <div className=\"flex h-16 items-center gap-2.5 border-b px-5\">\n <Link href={portalHome} className=\"flex items-center gap-2.5 text-foreground transition hover:opacity-80\" aria-label={headerTitle}>\n <Image src=\"/open-mercato.svg\" alt=\"\" width={22} height={22} className=\"\" />\n <span className=\"text-[14px] font-semibold tracking-tight truncate\">{headerTitle}</span>\n </Link>\n </div>\n\n <nav aria-label=\"Portal navigation\" className=\"flex-1 overflow-y-auto px-3 py-5\">\n <p className=\"mb-2 px-3 text-[10px] font-semibold uppercase tracking-[0.12em] text-muted-foreground/50\">\n {t('portal.nav.home', 'Portal')}\n </p>\n <div className=\"flex flex-col gap-0.5\">\n {mergedNavItems.map((item) => (\n <SidebarNavItem\n key={item.id}\n item={item}\n active={!!item.href && pathname.startsWith(item.href)}\n t={t}\n onClick={closeMobile}\n />\n ))}\n </div>\n\n {mergedAccountItems.length > 0 ? (\n <div className=\"mt-8\">\n <p className=\"mb-2 px-3 text-[10px] font-semibold uppercase tracking-[0.12em] text-muted-foreground/50\">\n {t('portal.nav.account', 'Account')}\n </p>\n <div className=\"flex flex-col gap-0.5\">\n {mergedAccountItems.map((item) => (\n <SidebarNavItem\n key={item.id}\n item={item}\n active={!!item.href && pathname.startsWith(item.href)}\n t={t}\n onClick={closeMobile}\n />\n ))}\n </div>\n </div>\n ) : null}\n </nav>\n\n <div className=\"border-t px-3 py-3\">\n <div className=\"flex items-center gap-2.5 rounded-lg px-3 py-2\">\n <UserAvatar name={userName} className=\"size-8\" />\n <div className=\"min-w-0 flex-1\">\n {userName ? (\n <p className=\"truncate text-[13px] font-medium leading-tight\">{userName}</p>\n ) : (\n <div className=\"h-4 w-24 animate-pulse rounded bg-muted\" />\n )}\n {userEmail ? (\n <p className=\"truncate text-[11px] text-muted-foreground\">{userEmail}</p>\n ) : (\n <div className=\"mt-1 h-3 w-32 animate-pulse rounded bg-muted\" />\n )}\n </div>\n </div>\n <button\n type=\"button\"\n onClick={onLogout}\n className=\"mt-0.5 flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-[13px] text-muted-foreground transition-colors hover:bg-muted hover:text-foreground\"\n data-portal-handle={PORTAL_USER_MENU_HANDLE}\n data-menu-item-id=\"portal-logout\"\n >\n <LogOutIcon className=\"size-4\" />\n {t('portal.nav.logout', 'Log Out')}\n </button>\n </div>\n </div>\n )\n\n return (\n <div className=\"flex min-h-svh bg-background\" data-portal-handle={PORTAL_SHELL_HANDLE}>\n {enableEventBridge ? <PortalEventBridgeMount /> : null}\n\n <aside className=\"hidden w-[240px] shrink-0 border-r lg:block\">\n {sidebarContent}\n </aside>\n\n {mobileOpen ? (\n <div className=\"fixed inset-0 z-50 lg:hidden\">\n <div className=\"absolute inset-0 bg-black/30 backdrop-blur-sm\" onClick={closeMobile} />\n <aside className=\"relative z-10 h-full w-[280px] bg-background shadow-2xl\">\n <div className=\"absolute right-3 top-4 z-20\">\n <IconButton variant=\"ghost\" size=\"sm\" type=\"button\" onClick={closeMobile} aria-label=\"Close menu\">\n <XIcon className=\"size-4\" />\n </IconButton>\n </div>\n {sidebarContent}\n </aside>\n </div>\n ) : null}\n\n <div className=\"flex min-w-0 flex-1 flex-col\">\n <header className=\"flex h-16 items-center justify-between border-b px-4 lg:px-8\" data-portal-handle={PORTAL_HEADER_HANDLE}>\n <div className=\"flex items-center gap-3\">\n <IconButton variant=\"ghost\" size=\"sm\" type=\"button\" onClick={() => setMobileOpen(true)} className=\"lg:hidden\" aria-label=\"Open menu\">\n <MenuIcon className=\"size-5\" />\n </IconButton>\n </div>\n <div className=\"flex items-center gap-3\">\n <PortalNotificationBell t={t} />\n </div>\n </header>\n\n <main className=\"flex-1 overflow-y-auto\">\n <div className=\"w-full px-4 py-6 lg:px-8 lg:py-8\">\n {children}\n </div>\n </main>\n\n <footer className=\"border-t px-4 py-4 lg:px-8\" data-portal-handle={PORTAL_FOOTER_HANDLE}>\n <p className=\"text-[11px] text-muted-foreground/50\">\n {t('portal.footer.copyright', '\\u00A9 {year} All rights reserved.', { year: new Date().getFullYear() })}\n </p>\n </footer>\n </div>\n </div>\n )\n}\n\nexport default PortalShell\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["\"use client\"\nimport { type ReactNode, useEffect, useState, useCallback, useMemo, useContext } from 'react'\nimport Image from 'next/image'\nimport Link from 'next/link'\nimport { usePathname } from 'next/navigation'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { Button } from '../primitives/button'\nimport { IconButton } from '../primitives/icon-button'\nimport { usePortalInjectedMenuItems } from './hooks/usePortalInjectedMenuItems'\nimport { usePortalEventBridge } from './hooks/usePortalEventBridge'\nimport { mergeMenuItems } from '../backend/injection/mergeMenuItems'\nimport type { MergedMenuItem } from '../backend/injection/mergeMenuItems'\nimport { PortalNotificationBell } from './components/PortalNotificationBell'\nimport { usePortalContext } from './PortalContext'\nimport { apiCall } from '../backend/utils/apiCall'\nimport type { PortalNavGroup } from './utils/nav'\n\n// Component replacement handle IDs (FROZEN once shipped)\nexport const PORTAL_SHELL_HANDLE = 'page:portal:layout'\nexport const PORTAL_HEADER_HANDLE = 'section:portal:header'\nexport const PORTAL_FOOTER_HANDLE = 'section:portal:footer'\nexport const PORTAL_SIDEBAR_HANDLE = 'section:portal:sidebar'\nexport const PORTAL_USER_MENU_HANDLE = 'section:portal:user-menu'\n\nexport type PortalShellProps = {\n children: ReactNode\n /** Override orgSlug (used on public pages without context) */\n orgSlug?: string\n /** Override organization name (used on public pages without context) */\n organizationName?: string\n /** Whether to show authenticated layout. Auto-detected from context when omitted. */\n authenticated?: boolean\n /** Logout handler. Auto-provided from context when omitted. */\n onLogout?: () => void\n enableEventBridge?: boolean\n /** Override user name. Auto-read from context when omitted. */\n userName?: string\n /** Override user email. Auto-read from context when omitted. */\n userEmail?: string\n}\n\nfunction PortalEventBridgeMount() {\n usePortalEventBridge()\n return null\n}\n\n/* ---- Inline SVG icons ---- */\n\nfunction MenuIcon({ className }: { className?: string }) {\n return (\n <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" strokeLinejoin=\"round\" className={className}>\n <line x1=\"4\" x2=\"20\" y1=\"12\" y2=\"12\" /><line x1=\"4\" x2=\"20\" y1=\"6\" y2=\"6\" /><line x1=\"4\" x2=\"20\" y1=\"18\" y2=\"18\" />\n </svg>\n )\n}\n\nfunction XIcon({ className }: { className?: string }) {\n return (\n <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" strokeLinejoin=\"round\" className={className}>\n <path d=\"M18 6 6 18\" /><path d=\"m6 6 12 12\" />\n </svg>\n )\n}\n\nfunction LogOutIcon({ className }: { className?: string }) {\n return (\n <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" strokeLinejoin=\"round\" className={className}>\n <path d=\"M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4\" /><polyline points=\"16 17 21 12 16 7\" /><line x1=\"21\" x2=\"9\" y1=\"12\" y2=\"12\" />\n </svg>\n )\n}\n\n/* ---- Sidebar nav item ---- */\n\nfunction SidebarNavItem({\n item,\n active,\n t,\n onClick,\n}: {\n item: MergedMenuItem\n active: boolean\n t: (key: string, fallback?: string) => string\n onClick?: () => void\n}) {\n const label = item.labelKey ? t(item.labelKey, item.label) : item.label\n if (!label) return null\n\n const cls = [\n 'flex items-center gap-2.5 rounded-lg px-3 py-2 text-[13px] font-medium transition-colors',\n active\n ? 'bg-foreground text-background'\n : 'text-muted-foreground hover:bg-muted hover:text-foreground',\n ].join(' ')\n\n if (item.href) {\n return (\n <Link href={item.href} className={cls} data-menu-item-id={item.id} onClick={onClick}>\n {label}\n </Link>\n )\n }\n if (item.onClick) {\n return (\n <button type=\"button\" className={cls} data-menu-item-id={item.id} onClick={() => { item.onClick?.(); onClick?.() }}>\n {label}\n </button>\n )\n }\n return null\n}\n\n/* ---- User initials avatar ---- */\n\nfunction UserAvatar({ name, className }: { name?: string; className?: string }) {\n const initials = name\n ? name.split(' ').map((w) => w[0]).slice(0, 2).join('').toUpperCase()\n : '?'\n return (\n <div className={`flex items-center justify-center rounded-full bg-foreground text-[11px] font-semibold text-background ${className ?? 'size-8'}`}>\n {initials}\n </div>\n )\n}\n\n/* ---- Try reading from PortalContext ---- */\n\nfunction useOptionalPortalContext() {\n try {\n return usePortalContext()\n } catch {\n return null\n }\n}\n\n/* ================================================================== */\n/* PortalShell */\n/* ================================================================== */\n\n/**\n * Portal layout shell.\n *\n * When a `PortalProvider` is mounted in a parent layout, PortalShell reads\n * auth/tenant state from context \u2014 no re-fetching on navigation. Props are\n * used as overrides or for public pages that don't have a context.\n */\nexport function PortalShell({\n children,\n orgSlug: orgSlugProp,\n organizationName: orgNameProp,\n authenticated: authenticatedProp,\n onLogout: onLogoutProp,\n enableEventBridge = false,\n userName: userNameProp,\n userEmail: userEmailProp,\n}: PortalShellProps) {\n const t = useT()\n const pathname = usePathname()\n const [mobileOpen, setMobileOpen] = useState(false)\n\n // Read from context when available (persists across navigations)\n const portalCtx = useOptionalPortalContext()\n\n // Resolve values: context takes priority, props are fallback/override\n const orgSlug = portalCtx?.orgSlug ?? orgSlugProp\n const orgName = portalCtx?.tenant.organizationName ?? orgNameProp\n const user = portalCtx?.auth.user ?? null\n const authenticated = authenticatedProp ?? !!user\n const onLogout = onLogoutProp ?? portalCtx?.auth.logout\n const userName = userNameProp ?? user?.displayName\n const userEmail = userEmailProp ?? user?.email\n\n const { items: injectedMainItems } = usePortalInjectedMenuItems('menu:portal:sidebar:main')\n const { items: injectedAccountItems } = usePortalInjectedMenuItems('menu:portal:sidebar:account')\n\n const portalHome = orgSlug ? `/${orgSlug}/portal` : '/portal'\n const loginHref = orgSlug ? `/${orgSlug}/portal/login` : '/portal/login'\n const signupHref = orgSlug ? `/${orgSlug}/portal/signup` : '/portal/signup'\n // Always use the resolved organization name from the database.\n // Fall back to the generic portal title \u2014 never display the raw slug.\n const headerTitle = orgName || t('portal.title', 'Customer Portal')\n\n const closeMobile = useCallback(() => setMobileOpen(false), [])\n\n const [autoNavGroups, setAutoNavGroups] = useState<PortalNavGroup[]>([])\n useEffect(() => {\n if (!authenticated) {\n setAutoNavGroups([])\n return\n }\n let cancelled = false\n const load = async () => {\n try {\n const { ok, result } = await apiCall<{ ok: boolean; groups?: PortalNavGroup[] }>(\n '/api/customer_accounts/portal/nav',\n )\n if (cancelled || !ok || !result?.ok) return\n setAutoNavGroups(Array.isArray(result.groups) ? result.groups : [])\n } catch {\n if (!cancelled) setAutoNavGroups([])\n }\n }\n void load()\n return () => {\n cancelled = true\n }\n }, [authenticated])\n\n const mergedNavItems = useMemo(() => {\n if (!authenticated) return []\n const discovered = autoNavGroups.find((g) => g.id === 'main')?.items ?? []\n const builtIn = discovered.map((item) => ({\n id: item.id,\n labelKey: item.labelKey,\n label: item.label,\n href: item.href,\n }))\n return mergeMenuItems(builtIn, injectedMainItems)\n }, [authenticated, autoNavGroups, injectedMainItems])\n\n const mergedAccountItems = useMemo(() => {\n if (!authenticated) return []\n const discovered = autoNavGroups.find((g) => g.id === 'account')?.items ?? []\n const builtIn = discovered.map((item) => ({\n id: item.id,\n labelKey: item.labelKey,\n label: item.label,\n href: item.href,\n }))\n return mergeMenuItems(builtIn, injectedAccountItems)\n }, [authenticated, autoNavGroups, injectedAccountItems])\n\n /* ---- PUBLIC LAYOUT ---- */\n if (!authenticated) {\n return (\n <div className=\"flex min-h-svh flex-col bg-background\" data-portal-handle={PORTAL_SHELL_HANDLE}>\n <header className=\"sticky top-0 z-40 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80\" data-portal-handle={PORTAL_HEADER_HANDLE}>\n <div className=\"mx-auto flex h-16 w-full max-w-screen-lg items-center justify-between px-6\">\n <Link href={portalHome} className=\"flex items-center gap-2.5 text-foreground transition hover:opacity-80\" aria-label={headerTitle}>\n <Image src=\"/open-mercato.svg\" alt=\"\" width={28} height={28} className=\"\" priority />\n <span className=\"text-[15px] font-semibold tracking-tight\">{headerTitle}</span>\n </Link>\n <nav aria-label=\"Primary\" className=\"flex items-center gap-1\">\n <Button asChild variant=\"ghost\" size=\"sm\" className=\"text-[13px]\">\n <Link href={loginHref}>{t('portal.nav.login', 'Log In')}</Link>\n </Button>\n <Button asChild size=\"sm\" className=\"rounded-lg text-[13px]\">\n <Link href={signupHref}>{t('portal.nav.signup', 'Sign Up')}</Link>\n </Button>\n </nav>\n </div>\n </header>\n\n <main className=\"flex-1\">\n <div className=\"mx-auto flex w-full max-w-screen-lg flex-col gap-8 px-6 py-12 sm:py-20\">\n {children}\n </div>\n </main>\n\n <footer className=\"border-t\" data-portal-handle={PORTAL_FOOTER_HANDLE}>\n <div className=\"mx-auto flex w-full max-w-screen-lg items-center justify-between px-6 py-6\">\n <Link href={portalHome} className=\"flex items-center gap-2 text-muted-foreground transition hover:text-foreground\">\n <Image src=\"/open-mercato.svg\" alt=\"\" width={20} height={20} className=\"\" />\n <span className=\"text-sm font-medium text-foreground\">{headerTitle}</span>\n </Link>\n <p className=\"text-xs text-muted-foreground/60\">\n {t('portal.footer.copyright', '\\u00A9 {year} All rights reserved.', { year: new Date().getFullYear() })}\n </p>\n </div>\n </footer>\n </div>\n )\n }\n\n /* ---- AUTHENTICATED LAYOUT ---- */\n\n const sidebarContent = (\n <div className=\"flex h-full flex-col\" data-portal-handle={PORTAL_SIDEBAR_HANDLE}>\n <div className=\"flex h-16 items-center gap-2.5 border-b px-5\">\n <Link href={portalHome} className=\"flex items-center gap-2.5 text-foreground transition hover:opacity-80\" aria-label={headerTitle}>\n <Image src=\"/open-mercato.svg\" alt=\"\" width={22} height={22} className=\"\" />\n <span className=\"text-[14px] font-semibold tracking-tight truncate\">{headerTitle}</span>\n </Link>\n </div>\n\n <nav aria-label=\"Portal navigation\" className=\"flex-1 overflow-y-auto px-3 py-5\">\n <p className=\"mb-2 px-3 text-[10px] font-semibold uppercase tracking-[0.12em] text-muted-foreground/50\">\n {t('portal.nav.home', 'Portal')}\n </p>\n <div className=\"flex flex-col gap-0.5\">\n {mergedNavItems.map((item) => (\n <SidebarNavItem\n key={item.id}\n item={item}\n active={!!item.href && pathname.startsWith(item.href)}\n t={t}\n onClick={closeMobile}\n />\n ))}\n </div>\n\n {mergedAccountItems.length > 0 ? (\n <div className=\"mt-8\">\n <p className=\"mb-2 px-3 text-[10px] font-semibold uppercase tracking-[0.12em] text-muted-foreground/50\">\n {t('portal.nav.account', 'Account')}\n </p>\n <div className=\"flex flex-col gap-0.5\">\n {mergedAccountItems.map((item) => (\n <SidebarNavItem\n key={item.id}\n item={item}\n active={!!item.href && pathname.startsWith(item.href)}\n t={t}\n onClick={closeMobile}\n />\n ))}\n </div>\n </div>\n ) : null}\n </nav>\n\n <div className=\"border-t px-3 py-3\">\n <div className=\"flex items-center gap-2.5 rounded-lg px-3 py-2\">\n <UserAvatar name={userName} className=\"size-8\" />\n <div className=\"min-w-0 flex-1\">\n {userName ? (\n <p className=\"truncate text-[13px] font-medium leading-tight\">{userName}</p>\n ) : (\n <div className=\"h-4 w-24 animate-pulse rounded bg-muted\" />\n )}\n {userEmail ? (\n <p className=\"truncate text-[11px] text-muted-foreground\">{userEmail}</p>\n ) : (\n <div className=\"mt-1 h-3 w-32 animate-pulse rounded bg-muted\" />\n )}\n </div>\n </div>\n <button\n type=\"button\"\n onClick={onLogout}\n className=\"mt-0.5 flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-[13px] text-muted-foreground transition-colors hover:bg-muted hover:text-foreground\"\n data-portal-handle={PORTAL_USER_MENU_HANDLE}\n data-menu-item-id=\"portal-logout\"\n >\n <LogOutIcon className=\"size-4\" />\n {t('portal.nav.logout', 'Log Out')}\n </button>\n </div>\n </div>\n )\n\n return (\n <div className=\"flex min-h-svh bg-background\" data-portal-handle={PORTAL_SHELL_HANDLE}>\n {enableEventBridge ? <PortalEventBridgeMount /> : null}\n\n <aside className=\"hidden w-[240px] shrink-0 border-r lg:block\">\n {sidebarContent}\n </aside>\n\n {mobileOpen ? (\n <div className=\"fixed inset-0 z-50 lg:hidden\">\n <div className=\"absolute inset-0 bg-black/30 backdrop-blur-sm\" onClick={closeMobile} />\n <aside className=\"relative z-10 h-full w-[280px] bg-background shadow-2xl\">\n <div className=\"absolute right-3 top-4 z-20\">\n <IconButton variant=\"ghost\" size=\"sm\" type=\"button\" onClick={closeMobile} aria-label=\"Close menu\">\n <XIcon className=\"size-4\" />\n </IconButton>\n </div>\n {sidebarContent}\n </aside>\n </div>\n ) : null}\n\n <div className=\"flex min-w-0 flex-1 flex-col\">\n <header className=\"flex h-16 items-center justify-between border-b px-4 lg:px-8\" data-portal-handle={PORTAL_HEADER_HANDLE}>\n <div className=\"flex items-center gap-3\">\n <IconButton variant=\"ghost\" size=\"sm\" type=\"button\" onClick={() => setMobileOpen(true)} className=\"lg:hidden\" aria-label=\"Open menu\">\n <MenuIcon className=\"size-5\" />\n </IconButton>\n </div>\n <div className=\"flex items-center gap-3\">\n <PortalNotificationBell t={t} />\n </div>\n </header>\n\n <main className=\"flex-1 overflow-y-auto\">\n <div className=\"w-full px-4 py-6 lg:px-8 lg:py-8\">\n {children}\n </div>\n </main>\n\n <footer className=\"border-t px-4 py-4 lg:px-8\" data-portal-handle={PORTAL_FOOTER_HANDLE}>\n <p className=\"text-[11px] text-muted-foreground/50\">\n {t('portal.footer.copyright', '\\u00A9 {year} All rights reserved.', { year: new Date().getFullYear() })}\n </p>\n </footer>\n </div>\n </div>\n )\n}\n\nexport default PortalShell\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -1,10 +1,37 @@
1
1
  "use client";
2
2
  import * as React from "react";
3
3
  import { loadInjectionWidgetsForSpot } from "@open-mercato/shared/modules/widgets/injection-loader";
4
+ import { hasAllFeatures } from "@open-mercato/shared/security/features";
5
+ import { apiCall } from "../../backend/utils/apiCall.js";
6
+ function collectRequiredFeatures(widgets) {
7
+ const set = /* @__PURE__ */ new Set();
8
+ for (const widget of widgets) {
9
+ for (const feature of widget.metadata.features ?? []) {
10
+ if (!feature || feature.trim().length === 0) continue;
11
+ set.add(feature);
12
+ }
13
+ }
14
+ return Array.from(set);
15
+ }
16
+ async function readPortalGrantedFeatures(features) {
17
+ if (features.length === 0) return /* @__PURE__ */ new Set();
18
+ try {
19
+ const { ok, result: data } = await apiCall("/api/customer_accounts/portal/feature-check", {
20
+ method: "POST",
21
+ headers: { "content-type": "application/json" },
22
+ body: JSON.stringify({ features })
23
+ });
24
+ if (!ok || !data?.ok) return /* @__PURE__ */ new Set();
25
+ return new Set(data.granted ?? []);
26
+ } catch {
27
+ return /* @__PURE__ */ new Set();
28
+ }
29
+ }
4
30
  function usePortalDashboardWidgets(spotId) {
5
31
  const [widgets, setWidgets] = React.useState([]);
6
32
  const [isLoading, setIsLoading] = React.useState(true);
7
33
  const [error, setError] = React.useState(null);
34
+ const [grantedFeatures, setGrantedFeatures] = React.useState(/* @__PURE__ */ new Set());
8
35
  React.useEffect(() => {
9
36
  let mounted = true;
10
37
  const load = async () => {
@@ -15,6 +42,10 @@ function usePortalDashboardWidgets(spotId) {
15
42
  if (!mounted) return;
16
43
  const uiWidgets = loaded.filter((w) => typeof w.Widget === "function");
17
44
  setWidgets(uiWidgets);
45
+ const required = collectRequiredFeatures(uiWidgets);
46
+ const granted = await readPortalGrantedFeatures(required);
47
+ if (!mounted) return;
48
+ setGrantedFeatures(granted);
18
49
  } catch (loadError) {
19
50
  if (!mounted) return;
20
51
  console.error(`[usePortalDashboardWidgets] Failed to load widgets for spot ${spotId}:`, loadError);
@@ -29,7 +60,15 @@ function usePortalDashboardWidgets(spotId) {
29
60
  mounted = false;
30
61
  };
31
62
  }, [spotId]);
32
- return { widgets, isLoading, error };
63
+ const grantedFeatureList = React.useMemo(() => Array.from(grantedFeatures), [grantedFeatures]);
64
+ const visibleWidgets = React.useMemo(
65
+ () => widgets.filter((widget) => {
66
+ const required = widget.metadata.features ?? [];
67
+ return required.length === 0 || hasAllFeatures(grantedFeatureList, required);
68
+ }),
69
+ [widgets, grantedFeatureList]
70
+ );
71
+ return { widgets: visibleWidgets, isLoading, error };
33
72
  }
34
73
  export {
35
74
  usePortalDashboardWidgets
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/portal/hooks/usePortalDashboardWidgets.ts"],
4
- "sourcesContent": ["'use client'\n\nimport * as React from 'react'\nimport type { InjectionSpotId } from '@open-mercato/shared/modules/widgets/injection'\nimport { loadInjectionWidgetsForSpot, type LoadedInjectionWidget } from '@open-mercato/shared/modules/widgets/injection-loader'\n\n/**\n * Loads UI injection widgets (with Widget component) for a portal spot.\n *\n * Unlike `useInjectionDataWidgets` which loads data-only widgets (columns, fields, menuItems),\n * this hook loads widgets that export a `Widget` React component \u2014 suitable for\n * portal dashboard sections and other UI injection spots.\n */\nexport function usePortalDashboardWidgets(spotId: InjectionSpotId): {\n widgets: LoadedInjectionWidget[]\n isLoading: boolean\n error: string | null\n} {\n const [widgets, setWidgets] = React.useState<LoadedInjectionWidget[]>([])\n const [isLoading, setIsLoading] = React.useState(true)\n const [error, setError] = React.useState<string | null>(null)\n\n React.useEffect(() => {\n let mounted = true\n const load = async () => {\n try {\n setIsLoading(true)\n setError(null)\n const loaded = await loadInjectionWidgetsForSpot(spotId)\n if (!mounted) return\n // Only keep widgets that have a Widget component\n const uiWidgets = loaded.filter((w) => typeof w.Widget === 'function')\n setWidgets(uiWidgets)\n } catch (loadError) {\n if (!mounted) return\n console.error(`[usePortalDashboardWidgets] Failed to load widgets for spot ${spotId}:`, loadError)\n setError(loadError instanceof Error ? loadError.message : String(loadError))\n setWidgets([])\n } finally {\n if (mounted) setIsLoading(false)\n }\n }\n void load()\n return () => {\n mounted = false\n }\n }, [spotId])\n\n return { widgets, isLoading, error }\n}\n"],
5
- "mappings": ";AAEA,YAAY,WAAW;AAEvB,SAAS,mCAA+D;AASjE,SAAS,0BAA0B,QAIxC;AACA,QAAM,CAAC,SAAS,UAAU,IAAI,MAAM,SAAkC,CAAC,CAAC;AACxE,QAAM,CAAC,WAAW,YAAY,IAAI,MAAM,SAAS,IAAI;AACrD,QAAM,CAAC,OAAO,QAAQ,IAAI,MAAM,SAAwB,IAAI;AAE5D,QAAM,UAAU,MAAM;AACpB,QAAI,UAAU;AACd,UAAM,OAAO,YAAY;AACvB,UAAI;AACF,qBAAa,IAAI;AACjB,iBAAS,IAAI;AACb,cAAM,SAAS,MAAM,4BAA4B,MAAM;AACvD,YAAI,CAAC,QAAS;AAEd,cAAM,YAAY,OAAO,OAAO,CAAC,MAAM,OAAO,EAAE,WAAW,UAAU;AACrE,mBAAW,SAAS;AAAA,MACtB,SAAS,WAAW;AAClB,YAAI,CAAC,QAAS;AACd,gBAAQ,MAAM,+DAA+D,MAAM,KAAK,SAAS;AACjG,iBAAS,qBAAqB,QAAQ,UAAU,UAAU,OAAO,SAAS,CAAC;AAC3E,mBAAW,CAAC,CAAC;AAAA,MACf,UAAE;AACA,YAAI,QAAS,cAAa,KAAK;AAAA,MACjC;AAAA,IACF;AACA,SAAK,KAAK;AACV,WAAO,MAAM;AACX,gBAAU;AAAA,IACZ;AAAA,EACF,GAAG,CAAC,MAAM,CAAC;AAEX,SAAO,EAAE,SAAS,WAAW,MAAM;AACrC;",
4
+ "sourcesContent": ["'use client'\n\nimport * as React from 'react'\nimport type { InjectionSpotId } from '@open-mercato/shared/modules/widgets/injection'\nimport { loadInjectionWidgetsForSpot, type LoadedInjectionWidget } from '@open-mercato/shared/modules/widgets/injection-loader'\nimport { hasAllFeatures } from '@open-mercato/shared/security/features'\nimport { apiCall } from '../../backend/utils/apiCall'\n\ntype PortalFeatureCheckResponse = {\n ok: boolean\n granted?: string[]\n}\n\nfunction collectRequiredFeatures(widgets: LoadedInjectionWidget[]): string[] {\n const set = new Set<string>()\n for (const widget of widgets) {\n for (const feature of widget.metadata.features ?? []) {\n if (!feature || feature.trim().length === 0) continue\n set.add(feature)\n }\n }\n return Array.from(set)\n}\n\nasync function readPortalGrantedFeatures(features: string[]): Promise<Set<string>> {\n if (features.length === 0) return new Set()\n try {\n const { ok, result: data } = await apiCall<PortalFeatureCheckResponse>('/api/customer_accounts/portal/feature-check', {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ features }),\n })\n if (!ok || !data?.ok) return new Set()\n return new Set(data.granted ?? [])\n } catch {\n return new Set()\n }\n}\n\n/**\n * Loads UI injection widgets (with Widget component) for a portal spot.\n *\n * Unlike `useInjectionDataWidgets` which loads data-only widgets (columns, fields, menuItems),\n * this hook loads widgets that export a `Widget` React component \u2014 suitable for\n * portal dashboard sections and other UI injection spots.\n *\n * Feature gating: widgets declaring `metadata.features` are filtered against the\n * authenticated customer's grants resolved via\n * `/api/customer_accounts/portal/feature-check`. Wildcard grants (`portal.*`) resolve\n * through the shared matcher.\n */\nexport function usePortalDashboardWidgets(spotId: InjectionSpotId): {\n widgets: LoadedInjectionWidget[]\n isLoading: boolean\n error: string | null\n} {\n const [widgets, setWidgets] = React.useState<LoadedInjectionWidget[]>([])\n const [isLoading, setIsLoading] = React.useState(true)\n const [error, setError] = React.useState<string | null>(null)\n const [grantedFeatures, setGrantedFeatures] = React.useState<Set<string>>(new Set())\n\n React.useEffect(() => {\n let mounted = true\n const load = async () => {\n try {\n setIsLoading(true)\n setError(null)\n const loaded = await loadInjectionWidgetsForSpot(spotId)\n if (!mounted) return\n // Only keep widgets that have a Widget component\n const uiWidgets = loaded.filter((w) => typeof w.Widget === 'function')\n setWidgets(uiWidgets)\n const required = collectRequiredFeatures(uiWidgets)\n const granted = await readPortalGrantedFeatures(required)\n if (!mounted) return\n setGrantedFeatures(granted)\n } catch (loadError) {\n if (!mounted) return\n console.error(`[usePortalDashboardWidgets] Failed to load widgets for spot ${spotId}:`, loadError)\n setError(loadError instanceof Error ? loadError.message : String(loadError))\n setWidgets([])\n } finally {\n if (mounted) setIsLoading(false)\n }\n }\n void load()\n return () => {\n mounted = false\n }\n }, [spotId])\n\n const grantedFeatureList = React.useMemo(() => Array.from(grantedFeatures), [grantedFeatures])\n\n const visibleWidgets = React.useMemo(\n () =>\n widgets.filter((widget) => {\n const required = widget.metadata.features ?? []\n return required.length === 0 || hasAllFeatures(grantedFeatureList, required)\n }),\n [widgets, grantedFeatureList],\n )\n\n return { widgets: visibleWidgets, isLoading, error }\n}\n"],
5
+ "mappings": ";AAEA,YAAY,WAAW;AAEvB,SAAS,mCAA+D;AACxE,SAAS,sBAAsB;AAC/B,SAAS,eAAe;AAOxB,SAAS,wBAAwB,SAA4C;AAC3E,QAAM,MAAM,oBAAI,IAAY;AAC5B,aAAW,UAAU,SAAS;AAC5B,eAAW,WAAW,OAAO,SAAS,YAAY,CAAC,GAAG;AACpD,UAAI,CAAC,WAAW,QAAQ,KAAK,EAAE,WAAW,EAAG;AAC7C,UAAI,IAAI,OAAO;AAAA,IACjB;AAAA,EACF;AACA,SAAO,MAAM,KAAK,GAAG;AACvB;AAEA,eAAe,0BAA0B,UAA0C;AACjF,MAAI,SAAS,WAAW,EAAG,QAAO,oBAAI,IAAI;AAC1C,MAAI;AACF,UAAM,EAAE,IAAI,QAAQ,KAAK,IAAI,MAAM,QAAoC,+CAA+C;AAAA,MACpH,QAAQ;AAAA,MACR,SAAS,EAAE,gBAAgB,mBAAmB;AAAA,MAC9C,MAAM,KAAK,UAAU,EAAE,SAAS,CAAC;AAAA,IACnC,CAAC;AACD,QAAI,CAAC,MAAM,CAAC,MAAM,GAAI,QAAO,oBAAI,IAAI;AACrC,WAAO,IAAI,IAAI,KAAK,WAAW,CAAC,CAAC;AAAA,EACnC,QAAQ;AACN,WAAO,oBAAI,IAAI;AAAA,EACjB;AACF;AAcO,SAAS,0BAA0B,QAIxC;AACA,QAAM,CAAC,SAAS,UAAU,IAAI,MAAM,SAAkC,CAAC,CAAC;AACxE,QAAM,CAAC,WAAW,YAAY,IAAI,MAAM,SAAS,IAAI;AACrD,QAAM,CAAC,OAAO,QAAQ,IAAI,MAAM,SAAwB,IAAI;AAC5D,QAAM,CAAC,iBAAiB,kBAAkB,IAAI,MAAM,SAAsB,oBAAI,IAAI,CAAC;AAEnF,QAAM,UAAU,MAAM;AACpB,QAAI,UAAU;AACd,UAAM,OAAO,YAAY;AACvB,UAAI;AACF,qBAAa,IAAI;AACjB,iBAAS,IAAI;AACb,cAAM,SAAS,MAAM,4BAA4B,MAAM;AACvD,YAAI,CAAC,QAAS;AAEd,cAAM,YAAY,OAAO,OAAO,CAAC,MAAM,OAAO,EAAE,WAAW,UAAU;AACrE,mBAAW,SAAS;AACpB,cAAM,WAAW,wBAAwB,SAAS;AAClD,cAAM,UAAU,MAAM,0BAA0B,QAAQ;AACxD,YAAI,CAAC,QAAS;AACd,2BAAmB,OAAO;AAAA,MAC5B,SAAS,WAAW;AAClB,YAAI,CAAC,QAAS;AACd,gBAAQ,MAAM,+DAA+D,MAAM,KAAK,SAAS;AACjG,iBAAS,qBAAqB,QAAQ,UAAU,UAAU,OAAO,SAAS,CAAC;AAC3E,mBAAW,CAAC,CAAC;AAAA,MACf,UAAE;AACA,YAAI,QAAS,cAAa,KAAK;AAAA,MACjC;AAAA,IACF;AACA,SAAK,KAAK;AACV,WAAO,MAAM;AACX,gBAAU;AAAA,IACZ;AAAA,EACF,GAAG,CAAC,MAAM,CAAC;AAEX,QAAM,qBAAqB,MAAM,QAAQ,MAAM,MAAM,KAAK,eAAe,GAAG,CAAC,eAAe,CAAC;AAE7F,QAAM,iBAAiB,MAAM;AAAA,IAC3B,MACE,QAAQ,OAAO,CAAC,WAAW;AACzB,YAAM,WAAW,OAAO,SAAS,YAAY,CAAC;AAC9C,aAAO,SAAS,WAAW,KAAK,eAAe,oBAAoB,QAAQ;AAAA,IAC7E,CAAC;AAAA,IACH,CAAC,SAAS,kBAAkB;AAAA,EAC9B;AAEA,SAAO,EAAE,SAAS,gBAAgB,WAAW,MAAM;AACrD;",
6
6
  "names": []
7
7
  }
@@ -0,0 +1,84 @@
1
+ import { hasAllFeatures } from "@open-mercato/shared/security/features";
2
+ function isPortalPattern(pattern) {
3
+ if (!pattern) return false;
4
+ return pattern.startsWith("/[orgSlug]/portal/") || pattern === "/[orgSlug]/portal";
5
+ }
6
+ function hasNoUnresolvedParams(href) {
7
+ return !href.includes("[");
8
+ }
9
+ function resolveHref(pattern, orgSlug) {
10
+ return pattern.replace("[orgSlug]", orgSlug);
11
+ }
12
+ function pickGroup(group) {
13
+ if (group === "main" || group === "account") return group;
14
+ return "main";
15
+ }
16
+ function buildPortalNav({
17
+ routes,
18
+ orgSlug,
19
+ grantedFeatures,
20
+ isPortalAdmin = false
21
+ }) {
22
+ const mainItems = [];
23
+ const accountItems = [];
24
+ for (const route of routes) {
25
+ const pattern = route.pattern ?? route.path;
26
+ if (!isPortalPattern(pattern)) continue;
27
+ if (route.navHidden) continue;
28
+ const nav = route.nav;
29
+ if (!nav || typeof nav.label !== "string" || nav.label.length === 0) continue;
30
+ const requireFeatures = route.requireCustomerFeatures ?? [];
31
+ if (!isPortalAdmin && requireFeatures.length) {
32
+ if (!hasAllFeatures(grantedFeatures, requireFeatures)) continue;
33
+ }
34
+ const href = resolveHref(pattern, orgSlug);
35
+ if (!hasNoUnresolvedParams(href)) continue;
36
+ const group = pickGroup(nav.group);
37
+ const item = {
38
+ id: `portal-nav:${pattern}`,
39
+ label: nav.label,
40
+ labelKey: nav.labelKey,
41
+ href,
42
+ icon: nav.icon,
43
+ order: typeof nav.order === "number" ? nav.order : 100
44
+ };
45
+ if (group === "account") accountItems.push(item);
46
+ else mainItems.push(item);
47
+ }
48
+ const sortItems = (items) => items.sort((a, b) => {
49
+ if (a.order !== b.order) return a.order - b.order;
50
+ return a.label.localeCompare(b.label);
51
+ });
52
+ sortItems(mainItems);
53
+ sortItems(accountItems);
54
+ const groups = [];
55
+ if (mainItems.length) groups.push({ id: "main", items: mainItems });
56
+ if (accountItems.length) groups.push({ id: "account", items: accountItems });
57
+ return groups;
58
+ }
59
+ function mergePortalSidebarGroupsWithInjected(discovered, injected) {
60
+ const mergeGroup = (base, extra) => {
61
+ const knownIds = new Set(base.map((item) => item.id));
62
+ const knownHrefs = new Set(base.map((item) => item.href).filter((href) => Boolean(href)));
63
+ const merged = [...base];
64
+ for (const item of extra) {
65
+ if (knownIds.has(item.id)) continue;
66
+ if (item.href && knownHrefs.has(item.href)) continue;
67
+ merged.push(item);
68
+ knownIds.add(item.id);
69
+ if (item.href) knownHrefs.add(item.href);
70
+ }
71
+ return merged;
72
+ };
73
+ const mainBase = discovered.find((g) => g.id === "main")?.items ?? [];
74
+ const accountBase = discovered.find((g) => g.id === "account")?.items ?? [];
75
+ return {
76
+ main: mergeGroup(mainBase, injected.main),
77
+ account: mergeGroup(accountBase, injected.account)
78
+ };
79
+ }
80
+ export {
81
+ buildPortalNav,
82
+ mergePortalSidebarGroupsWithInjected
83
+ };
84
+ //# sourceMappingURL=nav.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 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 /** 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\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 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\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 as string, 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
+ "names": []
7
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/ui",
3
- "version": "0.5.1-develop.2652.0276e72e45",
3
+ "version": "0.5.1-develop.2657.a01847a9fa",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "scripts": {
@@ -132,12 +132,12 @@
132
132
  "recharts": "^3.8.1"
133
133
  },
134
134
  "peerDependencies": {
135
- "@open-mercato/shared": "0.5.1-develop.2652.0276e72e45",
135
+ "@open-mercato/shared": "0.5.1-develop.2657.a01847a9fa",
136
136
  "react": ">=18.0.0",
137
137
  "react-dom": ">=18.0.0"
138
138
  },
139
139
  "devDependencies": {
140
- "@open-mercato/shared": "0.5.1-develop.2652.0276e72e45",
140
+ "@open-mercato/shared": "0.5.1-develop.2657.a01847a9fa",
141
141
  "@testing-library/dom": "^10.4.1",
142
142
  "@testing-library/jest-dom": "^6.9.1",
143
143
  "@testing-library/react": "^16.3.1",
@@ -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
+ })
@@ -0,0 +1,150 @@
1
+ import type { FrontendRouteManifestEntry } from '@open-mercato/shared/modules/registry'
2
+ import { hasAllFeatures } from '@open-mercato/shared/security/features'
3
+
4
+ export type PortalNavGroupId = 'main' | 'account'
5
+
6
+ export type PortalNavItem = {
7
+ id: string
8
+ label: string
9
+ labelKey?: string
10
+ href: string
11
+ icon?: string
12
+ order: number
13
+ }
14
+
15
+ export type PortalNavGroup = {
16
+ id: PortalNavGroupId
17
+ items: PortalNavItem[]
18
+ }
19
+
20
+ export type BuildPortalNavOptions = {
21
+ /** Route manifest to inspect (typically `getFrontendRouteManifests()`). */
22
+ routes: readonly FrontendRouteManifestEntry[]
23
+ /** Current customer org slug — substituted into `[orgSlug]` patterns. */
24
+ orgSlug: string
25
+ /** Feature strings granted to the current customer (may include wildcards). */
26
+ grantedFeatures: readonly string[]
27
+ /** If true, bypass feature checks (portal admin). Defaults to false. */
28
+ isPortalAdmin?: boolean
29
+ }
30
+
31
+ function isPortalPattern(pattern: string | undefined): pattern is string {
32
+ if (!pattern) return false
33
+ return pattern.startsWith('/[orgSlug]/portal/') || pattern === '/[orgSlug]/portal'
34
+ }
35
+
36
+ function hasNoUnresolvedParams(href: string): boolean {
37
+ return !href.includes('[')
38
+ }
39
+
40
+ function resolveHref(pattern: string, orgSlug: string): string {
41
+ return pattern.replace('[orgSlug]', orgSlug)
42
+ }
43
+
44
+ function pickGroup(group: unknown): PortalNavGroupId {
45
+ if (group === 'main' || group === 'account') return group
46
+ return 'main'
47
+ }
48
+
49
+ /**
50
+ * Build the portal sidebar from the frontend route manifest.
51
+ *
52
+ * Mirrors `buildAdminNav()` for the portal surface: selects routes under
53
+ * `/[orgSlug]/portal/*` that declare a `nav` block, applies
54
+ * `requireCustomerFeatures` against the caller's grants (wildcards honored),
55
+ * and returns ordered sidebar groups.
56
+ *
57
+ * Absence of `nav` on a metadata file means the page is routable but not
58
+ * auto-listed — useful for detail/create pages.
59
+ */
60
+ export function buildPortalNav({
61
+ routes,
62
+ orgSlug,
63
+ grantedFeatures,
64
+ isPortalAdmin = false,
65
+ }: BuildPortalNavOptions): PortalNavGroup[] {
66
+ const mainItems: PortalNavItem[] = []
67
+ const accountItems: PortalNavItem[] = []
68
+
69
+ for (const route of routes) {
70
+ const pattern = route.pattern ?? route.path
71
+ if (!isPortalPattern(pattern)) continue
72
+ if (route.navHidden) continue
73
+ const nav = route.nav
74
+ if (!nav || typeof nav.label !== 'string' || nav.label.length === 0) continue
75
+
76
+ const requireFeatures = route.requireCustomerFeatures ?? []
77
+ if (!isPortalAdmin && requireFeatures.length) {
78
+ if (!hasAllFeatures(grantedFeatures as string[], requireFeatures as string[])) continue
79
+ }
80
+
81
+ const href = resolveHref(pattern as string, orgSlug)
82
+ if (!hasNoUnresolvedParams(href)) continue
83
+
84
+ const group = pickGroup(nav.group)
85
+ const item: PortalNavItem = {
86
+ id: `portal-nav:${pattern}`,
87
+ label: nav.label,
88
+ labelKey: nav.labelKey,
89
+ href,
90
+ icon: nav.icon,
91
+ order: typeof nav.order === 'number' ? nav.order : 100,
92
+ }
93
+ if (group === 'account') accountItems.push(item)
94
+ else mainItems.push(item)
95
+ }
96
+
97
+ const sortItems = (items: PortalNavItem[]) =>
98
+ items.sort((a, b) => {
99
+ if (a.order !== b.order) return a.order - b.order
100
+ return a.label.localeCompare(b.label)
101
+ })
102
+
103
+ sortItems(mainItems)
104
+ sortItems(accountItems)
105
+
106
+ const groups: PortalNavGroup[] = []
107
+ if (mainItems.length) groups.push({ id: 'main', items: mainItems })
108
+ if (accountItems.length) groups.push({ id: 'account', items: accountItems })
109
+ return groups
110
+ }
111
+
112
+ /**
113
+ * Merge sidebar groups from the portal nav endpoint with items contributed via
114
+ * `usePortalInjectedMenuItems`. Auto-discovered entries take precedence —
115
+ * injected items with matching `id` or `href` are dropped as duplicates.
116
+ */
117
+ export function mergePortalSidebarGroupsWithInjected<TInjected extends { id: string; href?: string }>(
118
+ discovered: readonly PortalNavGroup[],
119
+ injected: {
120
+ main: readonly TInjected[]
121
+ account: readonly TInjected[]
122
+ },
123
+ ): {
124
+ main: Array<PortalNavItem | TInjected>
125
+ account: Array<PortalNavItem | TInjected>
126
+ } {
127
+ const mergeGroup = <T extends PortalNavItem | TInjected>(
128
+ base: readonly PortalNavItem[],
129
+ extra: readonly TInjected[],
130
+ ): Array<PortalNavItem | TInjected> => {
131
+ const knownIds = new Set(base.map((item) => item.id))
132
+ const knownHrefs = new Set(base.map((item) => item.href).filter((href): href is string => Boolean(href)))
133
+ const merged: Array<PortalNavItem | TInjected> = [...base]
134
+ for (const item of extra) {
135
+ if (knownIds.has(item.id)) continue
136
+ if (item.href && knownHrefs.has(item.href)) continue
137
+ merged.push(item)
138
+ knownIds.add(item.id)
139
+ if (item.href) knownHrefs.add(item.href)
140
+ }
141
+ return merged
142
+ }
143
+
144
+ const mainBase = discovered.find((g) => g.id === 'main')?.items ?? []
145
+ const accountBase = discovered.find((g) => g.id === 'account')?.items ?? []
146
+ return {
147
+ main: mergeGroup(mainBase, injected.main),
148
+ account: mergeGroup(accountBase, injected.account),
149
+ }
150
+ }