@open-mercato/ui 0.6.7-develop.6860.1.f796b22e80 → 0.6.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +2 -4
- package/agentic/standalone-guide.md +1 -1
- package/build.mjs +54 -2
- package/dist/ai/AiChat.js +2 -21
- package/dist/ai/AiChat.js.map +2 -2
- package/dist/backend/AppShell.js +4 -43
- package/dist/backend/AppShell.js.map +2 -2
- package/dist/backend/AuthSessionGuard.js +0 -14
- package/dist/backend/AuthSessionGuard.js.map +2 -2
- package/dist/backend/BackendChromeProvider.js +1 -6
- package/dist/backend/BackendChromeProvider.js.map +2 -2
- package/dist/backend/CrudForm.js +4 -6
- package/dist/backend/CrudForm.js.map +2 -2
- package/dist/backend/DataTable.js +18 -20
- package/dist/backend/DataTable.js.map +2 -2
- package/dist/backend/JsonBuilder.js +3 -3
- package/dist/backend/JsonBuilder.js.map +2 -2
- package/dist/backend/NextStepCallout.js +4 -4
- package/dist/backend/NextStepCallout.js.map +1 -1
- package/dist/backend/ProfileDropdown.js +1 -2
- package/dist/backend/ProfileDropdown.js.map +2 -2
- package/dist/backend/RowActions.js +2 -2
- package/dist/backend/RowActions.js.map +2 -2
- package/dist/backend/confirm-dialog/ConfirmDialog.js +1 -13
- package/dist/backend/confirm-dialog/ConfirmDialog.js.map +2 -2
- package/dist/backend/crud/useGroupOrder.js +21 -14
- package/dist/backend/crud/useGroupOrder.js.map +2 -2
- package/dist/backend/detail/AddressTiles.js +5 -4
- package/dist/backend/detail/AddressTiles.js.map +2 -2
- package/dist/backend/detail/AttachmentDeleteDialog.js +1 -1
- package/dist/backend/detail/AttachmentDeleteDialog.js.map +2 -2
- package/dist/backend/detail/AttachmentMetadataDialog.js.map +1 -1
- package/dist/backend/detail/AttachmentsSection.js +1 -1
- package/dist/backend/detail/AttachmentsSection.js.map +2 -2
- package/dist/backend/detail/NotesSection.js +1 -1
- package/dist/backend/detail/NotesSection.js.map +2 -2
- package/dist/backend/detail/TagsSection.js +1 -1
- package/dist/backend/detail/TagsSection.js.map +2 -2
- package/dist/backend/forms/FormHeader.js +2 -1
- package/dist/backend/forms/FormHeader.js.map +2 -2
- package/dist/backend/icons/lucideRegistry.generated.js +25 -1
- package/dist/backend/icons/lucideRegistry.generated.js.map +2 -2
- package/dist/backend/icons/lucideRegistry.js +3 -3
- package/dist/backend/icons/lucideRegistry.js.map +2 -2
- package/dist/backend/injection/spotIds.js +18 -23
- package/dist/backend/injection/spotIds.js.map +2 -2
- package/dist/backend/schedule/ScheduleAgenda.js +3 -3
- package/dist/backend/schedule/ScheduleAgenda.js.map +1 -1
- package/dist/backend/schedule/ScheduleGrid.js +3 -3
- package/dist/backend/schedule/ScheduleGrid.js.map +1 -1
- package/dist/backend/schedule/ScheduleToolbar.js +70 -87
- package/dist/backend/schedule/ScheduleToolbar.js.map +2 -2
- package/dist/backend/sidebar/SidebarCustomizationEditor.js +2 -30
- package/dist/backend/sidebar/SidebarCustomizationEditor.js.map +2 -2
- package/dist/backend/sidebar/customization-helpers.js +0 -30
- package/dist/backend/sidebar/customization-helpers.js.map +2 -2
- package/dist/backend/utils/api.js +13 -3
- package/dist/backend/utils/api.js.map +2 -2
- package/dist/backend/utils/apiCall.js +1 -21
- package/dist/backend/utils/apiCall.js.map +2 -2
- package/dist/backend/utils/customFieldDefs.js.map +2 -2
- package/dist/backend/utils/customFieldForms.js +9 -10
- package/dist/backend/utils/customFieldForms.js.map +2 -2
- package/dist/backend/utils/nav.js +2 -5
- package/dist/backend/utils/nav.js.map +2 -2
- package/dist/frontend/LanguageSwitcher.js +1 -2
- package/dist/frontend/LanguageSwitcher.js.map +2 -2
- package/dist/portal/PortalContext.js +1 -1
- package/dist/portal/PortalContext.js.map +2 -2
- package/dist/portal/hooks/usePortalEventBridge.js +0 -6
- package/dist/portal/hooks/usePortalEventBridge.js.map +2 -2
- package/dist/portal/hooks/usePortalNotifications.js +4 -42
- package/dist/portal/hooks/usePortalNotifications.js.map +2 -2
- package/dist/portal/utils/nav.js +3 -2
- package/dist/portal/utils/nav.js.map +2 -2
- package/dist/primitives/button.js +4 -9
- package/dist/primitives/button.js.map +2 -2
- package/jest.config.cjs +1 -2
- package/package.json +18 -21
- package/src/ai/AiChat.tsx +1 -30
- package/src/ai/__tests__/AiChat.test.tsx +0 -31
- package/src/backend/AppShell.tsx +4 -55
- package/src/backend/AuthSessionGuard.tsx +0 -29
- package/src/backend/BackendChromeProvider.tsx +1 -19
- package/src/backend/CrudForm.tsx +4 -18
- package/src/backend/DataTable.tsx +19 -36
- package/src/backend/JsonBuilder.tsx +3 -3
- package/src/backend/NextStepCallout.tsx +4 -4
- package/src/backend/ProfileDropdown.tsx +0 -1
- package/src/backend/RowActions.tsx +2 -2
- package/src/backend/__tests__/AppShell.test.tsx +7 -65
- package/src/backend/__tests__/AuthSessionGuard.test.tsx +0 -52
- package/src/backend/__tests__/BackendChromeProvider.test.tsx +2 -98
- package/src/backend/__tests__/DataTable.perspectiveStorage.test.ts +1 -62
- package/src/backend/__tests__/nav-utils.test.ts +0 -139
- package/src/backend/confirm-dialog/ConfirmDialog.tsx +1 -16
- package/src/backend/confirm-dialog/__tests__/ConfirmDialog.test.tsx +1 -68
- package/src/backend/crud/__tests__/useGroupOrder.test.ts +1 -39
- package/src/backend/crud/useGroupOrder.ts +25 -30
- package/src/backend/detail/AddressTiles.tsx +6 -3
- package/src/backend/detail/AttachmentDeleteDialog.tsx +1 -1
- package/src/backend/detail/AttachmentMetadataDialog.tsx +1 -1
- package/src/backend/detail/AttachmentsSection.tsx +1 -1
- package/src/backend/detail/NotesSection.tsx +1 -1
- package/src/backend/detail/TagsSection.tsx +1 -1
- package/src/backend/detail/__tests__/AttachmentAssignmentsEditor.test.tsx +0 -21
- package/src/backend/forms/FormHeader.tsx +2 -1
- package/src/backend/icons/lucideRegistry.generated.tsx +37 -3
- package/src/backend/icons/lucideRegistry.ts +2 -2
- package/src/backend/injection/spotIds.ts +18 -23
- package/src/backend/schedule/ScheduleAgenda.tsx +3 -3
- package/src/backend/schedule/ScheduleGrid.tsx +3 -3
- package/src/backend/schedule/ScheduleToolbar.tsx +68 -89
- package/src/backend/sidebar/SidebarCustomizationEditor.tsx +3 -42
- package/src/backend/sidebar/customization-helpers.ts +0 -47
- package/src/backend/utils/__tests__/api.test.ts +27 -0
- package/src/backend/utils/__tests__/apiCall.test.ts +0 -61
- package/src/backend/utils/api.ts +20 -3
- package/src/backend/utils/apiCall.ts +1 -29
- package/src/backend/utils/customFieldDefs.ts +0 -1
- package/src/backend/utils/customFieldForms.ts +11 -15
- package/src/backend/utils/nav.ts +2 -22
- package/src/frontend/LanguageSwitcher.tsx +0 -1
- package/src/portal/PortalContext.tsx +1 -1
- package/src/portal/hooks/usePortalEventBridge.ts +0 -6
- package/src/portal/hooks/usePortalNotifications.ts +12 -55
- package/src/portal/utils/__tests__/nav.test.ts +2 -2
- package/src/portal/utils/nav.ts +4 -3
- package/src/primitives/button.tsx +4 -10
- package/dist/backend/icons/lucideRegistryRuntime.js +0 -28
- package/dist/backend/icons/lucideRegistryRuntime.js.map +0 -7
- package/dist/backend/perspectiveState.js +0 -34
- package/dist/backend/perspectiveState.js.map +0 -7
- package/dist/backend/utils/fetchAllPages.js +0 -57
- package/dist/backend/utils/fetchAllPages.js.map +0 -7
- package/dist/portal/hooks/portalBridgeStatus.js +0 -25
- package/dist/portal/hooks/portalBridgeStatus.js.map +0 -7
- package/figma/alert.figma.tsx +0 -35
- package/figma/badge.figma.tsx +0 -33
- package/figma/button.figma.tsx +0 -33
- package/figma/checkbox.figma.tsx +0 -28
- package/figma/drawer.figma.tsx +0 -40
- package/figma/input-variants.figma.tsx +0 -58
- package/figma/radio.figma.tsx +0 -16
- package/figma/select.figma.tsx +0 -24
- package/figma/status-badge.figma.tsx +0 -24
- package/figma/switch.figma.tsx +0 -28
- package/figma/tabs.figma.tsx +0 -22
- package/figma/tag.figma.tsx +0 -27
- package/figma.config.json +0 -6
- package/scripts/lucideRegistrySource.cjs +0 -133
- package/src/backend/__tests__/CrudForm.fieldsetBinding.test.tsx +0 -158
- package/src/backend/__tests__/CrudForm.groupInjectionColumn.test.tsx +0 -98
- package/src/backend/__tests__/DataTable.metaHiddenLateColumns.test.tsx +0 -105
- package/src/backend/icons/__tests__/lucideRegistryGenerator.test.ts +0 -177
- package/src/backend/icons/lucideRegistryRuntime.tsx +0 -36
- package/src/backend/injection/__tests__/spotIds.extension-points.test.ts +0 -38
- package/src/backend/perspectiveState.ts +0 -44
- package/src/backend/sidebar/__tests__/group-visibility.test.ts +0 -131
- package/src/backend/utils/__tests__/customFieldForms.optionsPaging.test.ts +0 -95
- package/src/backend/utils/__tests__/fetchAllPages.test.ts +0 -103
- package/src/backend/utils/fetchAllPages.ts +0 -93
- package/src/portal/hooks/__tests__/usePortalEventBridge.test.tsx +0 -66
- package/src/portal/hooks/__tests__/usePortalNotifications.test.tsx +0 -155
- package/src/portal/hooks/portalBridgeStatus.ts +0 -29
- package/src/primitives/__tests__/button.test.tsx +0 -70
package/src/backend/utils/api.ts
CHANGED
|
@@ -47,6 +47,18 @@ export async function withScopedApiHeaders<T>(headers: Record<string, string>, r
|
|
|
47
47
|
return scopedHeaders.withScopedHeaders(headers, run)
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
function readPathname(): string {
|
|
51
|
+
return typeof window !== 'undefined' ? window.location?.pathname ?? '' : ''
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function isLoginPathname(pathname: string): boolean {
|
|
55
|
+
return pathname.startsWith('/login')
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function isPortalPathname(pathname: string): boolean {
|
|
59
|
+
return /\/[^/]+\/portal(\/|$)/.test(pathname)
|
|
60
|
+
}
|
|
61
|
+
|
|
50
62
|
export class UnauthorizedError extends Error {
|
|
51
63
|
readonly status = 401
|
|
52
64
|
constructor(message = 'Unauthorized') {
|
|
@@ -166,10 +178,15 @@ export async function apiFetch(input: RequestInfo | URL, init?: RequestInit): Pr
|
|
|
166
178
|
const requestHeaders = new Headers(mergedInit?.headers)
|
|
167
179
|
const disableUnauthorizedRedirect = readRedirectOverride(requestHeaders, 'x-om-unauthorized-redirect')
|
|
168
180
|
const disableForbiddenRedirect = readRedirectOverride(requestHeaders, 'x-om-forbidden-redirect')
|
|
181
|
+
// Snapshot the pathname BEFORE the request is sent. A 401 for a request that
|
|
182
|
+
// started on the login page must stay silent even when the response lands after
|
|
183
|
+
// the post-login client-side navigation to /backend — otherwise the stale
|
|
184
|
+
// pre-auth 401 raises a bogus "Session expired" banner right after signing in.
|
|
185
|
+
const requestPathname = readPathname()
|
|
169
186
|
const res = await baseFetch(input, mergedInit)
|
|
170
|
-
const
|
|
171
|
-
const onLoginPage =
|
|
172
|
-
const onPortalRoute =
|
|
187
|
+
const responsePathname = readPathname()
|
|
188
|
+
const onLoginPage = isLoginPathname(requestPathname) || isLoginPathname(responsePathname)
|
|
189
|
+
const onPortalRoute = isPortalPathname(requestPathname) || isPortalPathname(responsePathname)
|
|
173
190
|
if (res.status === 401) {
|
|
174
191
|
// Trigger same redirect flow as protected pages
|
|
175
192
|
// Skip for staff login page and all portal routes (portal has its own auth)
|
|
@@ -19,27 +19,6 @@ export type ApiCallResult<TReturn> = {
|
|
|
19
19
|
|
|
20
20
|
const scopedRequestHeaders = createScopedHeaderStack()
|
|
21
21
|
|
|
22
|
-
function isAbortError(error: unknown): boolean {
|
|
23
|
-
return typeof error === 'object'
|
|
24
|
-
&& error !== null
|
|
25
|
-
&& (error as { name?: unknown }).name === 'AbortError'
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function createAbortError(): Error {
|
|
29
|
-
if (typeof DOMException === 'function') {
|
|
30
|
-
return new DOMException('[internal] The operation was aborted.', 'AbortError')
|
|
31
|
-
}
|
|
32
|
-
const error = new Error('[internal] The operation was aborted.')
|
|
33
|
-
error.name = 'AbortError'
|
|
34
|
-
return error
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function resolveAbortSignal(input: RequestInfo | URL, init?: RequestInit): AbortSignal | null {
|
|
38
|
-
if (init?.signal) return init.signal
|
|
39
|
-
if (typeof Request !== 'undefined' && input instanceof Request) return input.signal ?? null
|
|
40
|
-
return null
|
|
41
|
-
}
|
|
42
|
-
|
|
43
22
|
function mergeHeaders(base: HeadersInit | undefined, extra: Record<string, string>): HeadersInit {
|
|
44
23
|
if (!base) return extra
|
|
45
24
|
if (typeof Headers !== 'undefined' && base instanceof Headers) {
|
|
@@ -84,16 +63,9 @@ export async function apiCall<TReturn = Record<string, unknown>>(
|
|
|
84
63
|
: response
|
|
85
64
|
if (parser) result = await parser(source)
|
|
86
65
|
else result = await readJsonSafe<TReturn>(source, fallback)
|
|
87
|
-
} catch
|
|
88
|
-
if (isAbortError(err)) throw err
|
|
66
|
+
} catch {
|
|
89
67
|
result = fallback
|
|
90
68
|
}
|
|
91
|
-
// `readJsonSafe` swallows the abort that cancels an in-flight body read, so a
|
|
92
|
-
// request aborted after its response headers arrived would otherwise look like
|
|
93
|
-
// a successful call that returned an empty payload.
|
|
94
|
-
if (result == null && resolveAbortSignal(input, init)?.aborted) {
|
|
95
|
-
throw createAbortError()
|
|
96
|
-
}
|
|
97
69
|
return {
|
|
98
70
|
ok: response.ok,
|
|
99
71
|
status: response.status,
|
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
fetchCustomFieldDefinitionsPayload,
|
|
10
10
|
} from './customFieldDefs'
|
|
11
11
|
import { FieldRegistry, loadGeneratedFieldRegistrations } from '../fields/registry'
|
|
12
|
-
import {
|
|
12
|
+
import { apiCall } from './apiCall'
|
|
13
13
|
import { normalizeCustomFieldOptions } from '@open-mercato/shared/modules/entities/options'
|
|
14
14
|
import { CURRENCY_OPTIONS_URL } from '@open-mercato/shared/modules/entities/kinds'
|
|
15
15
|
|
|
@@ -42,22 +42,18 @@ function buildOptionsUrl(base: string, query?: string): string {
|
|
|
42
42
|
}
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
type OptionsResponse = { items?: unknown[] }
|
|
46
|
+
|
|
45
47
|
async function loadRemoteOptions(url: string): Promise<Array<{ value: string; label: string }>> {
|
|
46
48
|
try {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
const record = item && typeof item === 'object' ? (item as Record<string, unknown>) : null
|
|
56
|
-
return {
|
|
57
|
-
value: String(record?.value ?? item),
|
|
58
|
-
label: String(record?.label ?? record?.value ?? item),
|
|
59
|
-
}
|
|
60
|
-
})
|
|
49
|
+
const call = await apiCall<OptionsResponse>(url, undefined, { fallback: { items: [] } })
|
|
50
|
+
if (!call.ok) return []
|
|
51
|
+
const payload = call.result ?? { items: [] }
|
|
52
|
+
const items = Array.isArray(payload?.items) ? payload.items : []
|
|
53
|
+
return items.map((it: any) => ({
|
|
54
|
+
value: String(it?.value ?? it),
|
|
55
|
+
label: String(it?.label ?? it?.value ?? it),
|
|
56
|
+
}))
|
|
61
57
|
} catch {
|
|
62
58
|
return []
|
|
63
59
|
}
|
package/src/backend/utils/nav.ts
CHANGED
|
@@ -100,26 +100,6 @@ export type SettingsSectionItem = {
|
|
|
100
100
|
children?: SettingsSectionItem[]
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
-
/**
|
|
104
|
-
* Slug of a rendered group label, used as the pre-#4843 settings section id.
|
|
105
|
-
*
|
|
106
|
-
* `sectionOrder` used to be keyed by these slugs, which made ordering depend on the active locale.
|
|
107
|
-
* Kept only as a lookup fallback so third-party callers passing a legacy map keep their weights.
|
|
108
|
-
*
|
|
109
|
-
* @deprecated Key `sectionOrder` by the untranslated group id (`AdminNavItem.groupId`) instead.
|
|
110
|
-
*/
|
|
111
|
-
function legacySettingsSectionSlug(groupLabel: string): string {
|
|
112
|
-
return groupLabel.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '')
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
/**
|
|
116
|
-
* Groups the settings-context nav entries into ordered sections.
|
|
117
|
-
*
|
|
118
|
-
* `sectionOrder` is keyed by the untranslated group id (`AdminNavItem.groupId`, i.e. the page's
|
|
119
|
-
* `pageGroupKey` when it declares one) — the same convention the main sidebar's `defaultGroupOrder`
|
|
120
|
-
* follows. Keying off the rendered label instead made every non-English deployment miss its weights
|
|
121
|
-
* and fall back to the catch-all bucket (#4843).
|
|
122
|
-
*/
|
|
123
103
|
export function buildSettingsSections(
|
|
124
104
|
entries: AdminNavItem[],
|
|
125
105
|
sectionOrder: Record<string, number>
|
|
@@ -143,8 +123,8 @@ export function buildSettingsSections(
|
|
|
143
123
|
}
|
|
144
124
|
|
|
145
125
|
for (const item of settingsItems) {
|
|
146
|
-
const sectionId = item.
|
|
147
|
-
const order = sectionOrder[sectionId] ??
|
|
126
|
+
const sectionId = item.group.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '')
|
|
127
|
+
const order = sectionOrder[sectionId] ?? 999
|
|
148
128
|
|
|
149
129
|
if (!sectionMap.has(sectionId)) {
|
|
150
130
|
sectionMap.set(sectionId, {
|
|
@@ -24,7 +24,6 @@ export function LanguageSwitcher() {
|
|
|
24
24
|
pl: t('common.languages.polish', 'Polski'),
|
|
25
25
|
es: t('common.languages.spanish', 'Español'),
|
|
26
26
|
de: t('common.languages.german', 'Deutsch'),
|
|
27
|
-
ko: t('common.languages.korean', '한국어'),
|
|
28
27
|
}
|
|
29
28
|
|
|
30
29
|
async function setLocale(locale: Locale) {
|
|
@@ -91,7 +91,7 @@ export function PortalProvider({ orgSlug, children, initialAuth, initialTenant }
|
|
|
91
91
|
},
|
|
92
92
|
roles: [],
|
|
93
93
|
resolvedFeatures: initialAuth.resolvedFeatures,
|
|
94
|
-
isPortalAdmin: initialAuth.
|
|
94
|
+
isPortalAdmin: initialAuth.resolvedFeatures.some((f) => f === 'portal.*'),
|
|
95
95
|
loading: false,
|
|
96
96
|
error: null,
|
|
97
97
|
}
|
|
@@ -3,7 +3,6 @@ import { useEffect, useRef } from 'react'
|
|
|
3
3
|
import type { AppEventPayload } from '@open-mercato/shared/modules/widgets/injection'
|
|
4
4
|
import { PORTAL_EVENT_DOM_NAME } from './usePortalAppEvent'
|
|
5
5
|
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
6
|
-
import { publishPortalBridgeHealth } from './portalBridgeStatus'
|
|
7
6
|
|
|
8
7
|
const logger = createLogger('ui').child({ component: 'PortalEventBridge' })
|
|
9
8
|
|
|
@@ -63,7 +62,6 @@ export function usePortalEventBridge(): void {
|
|
|
63
62
|
if (heartbeatTimer.current) clearTimeout(heartbeatTimer.current)
|
|
64
63
|
heartbeatTimer.current = setTimeout(() => {
|
|
65
64
|
logger.warn('Heartbeat timeout — reconnecting')
|
|
66
|
-
publishPortalBridgeHealth(false)
|
|
67
65
|
disconnect()
|
|
68
66
|
scheduleReconnect()
|
|
69
67
|
}, HEARTBEAT_TIMEOUT)
|
|
@@ -82,7 +80,6 @@ export function usePortalEventBridge(): void {
|
|
|
82
80
|
hasEverConnected.current = true
|
|
83
81
|
reconnectPending.current = false
|
|
84
82
|
reconnectAttempts.current = 0
|
|
85
|
-
publishPortalBridgeHealth(true)
|
|
86
83
|
resetHeartbeatTimer()
|
|
87
84
|
if (shouldEmitReconnect) {
|
|
88
85
|
window.dispatchEvent(
|
|
@@ -119,7 +116,6 @@ export function usePortalEventBridge(): void {
|
|
|
119
116
|
if (hasEverConnected.current) {
|
|
120
117
|
reconnectPending.current = true
|
|
121
118
|
}
|
|
122
|
-
publishPortalBridgeHealth(false)
|
|
123
119
|
disconnect()
|
|
124
120
|
if (mounted) scheduleReconnect()
|
|
125
121
|
}
|
|
@@ -127,7 +123,6 @@ export function usePortalEventBridge(): void {
|
|
|
127
123
|
if (hasEverConnected.current) {
|
|
128
124
|
reconnectPending.current = true
|
|
129
125
|
}
|
|
130
|
-
publishPortalBridgeHealth(false)
|
|
131
126
|
if (mounted) scheduleReconnect()
|
|
132
127
|
}
|
|
133
128
|
}
|
|
@@ -160,7 +155,6 @@ export function usePortalEventBridge(): void {
|
|
|
160
155
|
|
|
161
156
|
return () => {
|
|
162
157
|
mounted = false
|
|
163
|
-
publishPortalBridgeHealth(false)
|
|
164
158
|
disconnect()
|
|
165
159
|
if (reconnectTimer.current) {
|
|
166
160
|
clearTimeout(reconnectTimer.current)
|
|
@@ -2,11 +2,6 @@
|
|
|
2
2
|
import * as React from 'react'
|
|
3
3
|
import type { NotificationDto } from '@open-mercato/shared/modules/notifications/types'
|
|
4
4
|
import { apiCall } from '../../backend/utils/apiCall'
|
|
5
|
-
import {
|
|
6
|
-
PORTAL_BRIDGE_STATUS_DOM_NAME,
|
|
7
|
-
readPortalBridgeHealth,
|
|
8
|
-
type PortalBridgeStatusDetail,
|
|
9
|
-
} from './portalBridgeStatus'
|
|
10
5
|
|
|
11
6
|
export type UsePortalNotificationsResult = {
|
|
12
7
|
notifications: NotificationDto[]
|
|
@@ -33,11 +28,13 @@ async function fetchJson<T>(url: string, init?: RequestInit): Promise<T | null>
|
|
|
33
28
|
}
|
|
34
29
|
|
|
35
30
|
/**
|
|
36
|
-
* Portal notification hook
|
|
31
|
+
* Portal notification hook — polls customer notification endpoints.
|
|
37
32
|
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
33
|
+
* Fetches notifications from `/api/customer_accounts/portal/notifications`
|
|
34
|
+
* and unread count from `.../unread-count`. Polls every 8 seconds.
|
|
35
|
+
*
|
|
36
|
+
* Also listens for portal SSE events (`notifications.notification.created`)
|
|
37
|
+
* to trigger immediate refresh.
|
|
41
38
|
*/
|
|
42
39
|
export function usePortalNotifications(): UsePortalNotificationsResult {
|
|
43
40
|
const [notifications, setNotifications] = React.useState<NotificationDto[]>([])
|
|
@@ -69,50 +66,18 @@ export function usePortalNotifications(): UsePortalNotificationsResult {
|
|
|
69
66
|
setIsLoading(false)
|
|
70
67
|
}, [])
|
|
71
68
|
|
|
72
|
-
|
|
73
|
-
if (typeof window === 'undefined' || !('EventSource' in window)) {
|
|
74
|
-
return true
|
|
75
|
-
}
|
|
76
|
-
return readPortalBridgeHealth() !== true
|
|
77
|
-
})
|
|
78
|
-
|
|
79
|
-
React.useEffect(() => {
|
|
80
|
-
if (typeof window === 'undefined' || !('EventSource' in window)) {
|
|
81
|
-
return
|
|
82
|
-
}
|
|
83
|
-
const handleStatusChange = (event: Event) => {
|
|
84
|
-
const detail = (event as CustomEvent<PortalBridgeStatusDetail>).detail
|
|
85
|
-
if (detail && typeof detail.healthy === 'boolean') {
|
|
86
|
-
setUsePolling(!detail.healthy)
|
|
87
|
-
if (!detail.healthy) {
|
|
88
|
-
fetchAll()
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
window.addEventListener(PORTAL_BRIDGE_STATUS_DOM_NAME, handleStatusChange)
|
|
93
|
-
return () => {
|
|
94
|
-
window.removeEventListener(PORTAL_BRIDGE_STATUS_DOM_NAME, handleStatusChange)
|
|
95
|
-
}
|
|
96
|
-
}, [fetchAll])
|
|
97
|
-
|
|
69
|
+
// Poll
|
|
98
70
|
React.useEffect(() => {
|
|
99
71
|
fetchAll()
|
|
100
|
-
}, [fetchAll])
|
|
101
|
-
|
|
102
|
-
React.useEffect(() => {
|
|
103
|
-
if (!usePolling) return
|
|
104
72
|
const interval = setInterval(fetchAll, POLL_INTERVAL)
|
|
105
73
|
return () => clearInterval(interval)
|
|
106
|
-
}, [fetchAll
|
|
74
|
+
}, [fetchAll])
|
|
107
75
|
|
|
76
|
+
// Listen for portal SSE notification events
|
|
108
77
|
React.useEffect(() => {
|
|
109
|
-
const handler = (
|
|
110
|
-
const detail = (
|
|
111
|
-
if (
|
|
112
|
-
detail?.id === 'notifications.notification.created' ||
|
|
113
|
-
detail?.id === 'notifications.notification.batch_created' ||
|
|
114
|
-
detail?.id === 'om:portal-bridge:reconnected'
|
|
115
|
-
) {
|
|
78
|
+
const handler = (e: Event) => {
|
|
79
|
+
const detail = (e as CustomEvent).detail
|
|
80
|
+
if (detail?.id === 'notifications.notification.created' || detail?.id === 'notifications.notification.batch_created') {
|
|
116
81
|
fetchAll()
|
|
117
82
|
}
|
|
118
83
|
}
|
|
@@ -120,14 +85,6 @@ export function usePortalNotifications(): UsePortalNotificationsResult {
|
|
|
120
85
|
return () => window.removeEventListener('om:portal-event', handler)
|
|
121
86
|
}, [fetchAll])
|
|
122
87
|
|
|
123
|
-
React.useEffect(() => {
|
|
124
|
-
const onFocus = () => {
|
|
125
|
-
fetchAll()
|
|
126
|
-
}
|
|
127
|
-
window.addEventListener('focus', onFocus)
|
|
128
|
-
return () => window.removeEventListener('focus', onFocus)
|
|
129
|
-
}, [fetchAll])
|
|
130
|
-
|
|
131
88
|
const markAsRead = React.useCallback(async (id: string) => {
|
|
132
89
|
await fetchJson(`${BASE}/${id}/read`, { method: 'PUT' })
|
|
133
90
|
setNotifications((prev) =>
|
|
@@ -97,7 +97,7 @@ describe('buildPortalNav', () => {
|
|
|
97
97
|
expect(groups).toEqual([{ id: 'main', items: [expect.objectContaining({ label: 'Orders' })] }])
|
|
98
98
|
})
|
|
99
99
|
|
|
100
|
-
it('
|
|
100
|
+
it('bypasses feature checks when isPortalAdmin is true', () => {
|
|
101
101
|
const routes: FrontendRouteManifestEntry[] = [
|
|
102
102
|
makeRoute({
|
|
103
103
|
pattern: '/[orgSlug]/portal/orders',
|
|
@@ -107,7 +107,7 @@ describe('buildPortalNav', () => {
|
|
|
107
107
|
]
|
|
108
108
|
|
|
109
109
|
const groups = buildPortalNav({ routes, orgSlug: 'my-org', grantedFeatures: [], isPortalAdmin: true })
|
|
110
|
-
expect(groups
|
|
110
|
+
expect(groups[0].items[0].label).toBe('Orders')
|
|
111
111
|
})
|
|
112
112
|
|
|
113
113
|
it('ignores navHidden pages even when nav is declared', () => {
|
package/src/portal/utils/nav.ts
CHANGED
|
@@ -22,9 +22,9 @@ export type BuildPortalNavOptions = {
|
|
|
22
22
|
routes: readonly FrontendRouteManifestEntry[]
|
|
23
23
|
/** Current customer org slug — substituted into `[orgSlug]` patterns. */
|
|
24
24
|
orgSlug: string
|
|
25
|
-
/**
|
|
25
|
+
/** Feature strings granted to the current customer (may include wildcards). */
|
|
26
26
|
grantedFeatures: readonly string[]
|
|
27
|
-
/**
|
|
27
|
+
/** If true, bypass feature checks (portal admin). Defaults to false. */
|
|
28
28
|
isPortalAdmin?: boolean
|
|
29
29
|
}
|
|
30
30
|
|
|
@@ -75,6 +75,7 @@ export function buildPortalNav({
|
|
|
75
75
|
routes,
|
|
76
76
|
orgSlug,
|
|
77
77
|
grantedFeatures,
|
|
78
|
+
isPortalAdmin = false,
|
|
78
79
|
}: BuildPortalNavOptions): PortalNavGroup[] {
|
|
79
80
|
const mainItems: PortalNavItem[] = []
|
|
80
81
|
const accountItems: PortalNavItem[] = []
|
|
@@ -95,7 +96,7 @@ export function buildPortalNav({
|
|
|
95
96
|
const nav = route.nav!
|
|
96
97
|
|
|
97
98
|
const requireFeatures = route.requireCustomerFeatures ?? []
|
|
98
|
-
if (requireFeatures.length) {
|
|
99
|
+
if (!isPortalAdmin && requireFeatures.length) {
|
|
99
100
|
if (!hasAllFeatures(grantedFeatures as string[], requireFeatures as string[])) continue
|
|
100
101
|
}
|
|
101
102
|
|
|
@@ -9,20 +9,14 @@ const buttonVariants = cva(
|
|
|
9
9
|
variants: {
|
|
10
10
|
variant: {
|
|
11
11
|
default: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary-hover',
|
|
12
|
-
/* Destructive is quiet by design: red text on a calm surface. A wall
|
|
13
|
-
of solid red buttons trains users to ignore red. The filled form is
|
|
14
|
-
`destructive-solid`, reserved for the single point-of-no-return
|
|
15
|
-
confirmation inside a dialog — never for the button that opens it. */
|
|
16
12
|
destructive:
|
|
17
|
-
'
|
|
18
|
-
'destructive-solid':
|
|
19
|
-
'bg-destructive text-white shadow-xs hover:bg-destructive/90 aria-invalid:ring-destructive dark:aria-invalid:ring-destructive',
|
|
13
|
+
'bg-destructive text-white shadow-xs hover:bg-destructive/90 aria-invalid:ring-destructive dark:aria-invalid:ring-destructive dark:bg-destructive/10',
|
|
20
14
|
'destructive-outline':
|
|
21
|
-
'border border-destructive bg-background text-destructive shadow-xs hover:bg-destructive/10',
|
|
15
|
+
'border border-destructive/30 bg-background text-destructive shadow-xs hover:bg-destructive/10 dark:border-destructive/40 dark:hover:bg-destructive/15',
|
|
22
16
|
'destructive-soft':
|
|
23
|
-
'bg-destructive/10 text-destructive hover:bg-destructive/15',
|
|
17
|
+
'bg-destructive/10 text-destructive hover:bg-destructive/15 dark:bg-destructive/15 dark:hover:bg-destructive/20',
|
|
24
18
|
'destructive-ghost':
|
|
25
|
-
'text-destructive hover:bg-destructive/10',
|
|
19
|
+
'text-destructive hover:bg-destructive/10 dark:hover:bg-destructive/15',
|
|
26
20
|
outline:
|
|
27
21
|
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
|
|
28
22
|
secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
|
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
import { jsx } from "react/jsx-runtime";
|
|
2
|
-
import { LUCIDE_ICON_REGISTRY } from "./lucideRegistry.generated.js";
|
|
3
|
-
function normalizeKebabIconName(input) {
|
|
4
|
-
const trimmed = input.trim();
|
|
5
|
-
if (!trimmed) return "";
|
|
6
|
-
const withoutPrefix = trimmed.startsWith("lucide:") ? trimmed.slice("lucide:".length) : trimmed;
|
|
7
|
-
if (!withoutPrefix) return "";
|
|
8
|
-
if (!withoutPrefix.includes("-") && !withoutPrefix.includes("_") && !withoutPrefix.includes(" ") && /[A-Z]/.test(withoutPrefix)) {
|
|
9
|
-
return withoutPrefix.replace(/([a-z0-9])([A-Z])/g, "$1-$2").replace(/([A-Z])([A-Z][a-z])/g, "$1-$2").toLowerCase();
|
|
10
|
-
}
|
|
11
|
-
return withoutPrefix.replace(/[_\s]+/g, "-").replace(/-+/g, "-").toLowerCase();
|
|
12
|
-
}
|
|
13
|
-
function resolveRegisteredLucideIcon(name) {
|
|
14
|
-
if (!name) return null;
|
|
15
|
-
const normalized = normalizeKebabIconName(name);
|
|
16
|
-
if (!normalized) return null;
|
|
17
|
-
return LUCIDE_ICON_REGISTRY[normalized] ?? null;
|
|
18
|
-
}
|
|
19
|
-
function resolveRegisteredLucideIconNode(name, className) {
|
|
20
|
-
const Icon = resolveRegisteredLucideIcon(name);
|
|
21
|
-
if (!Icon) return null;
|
|
22
|
-
return /* @__PURE__ */ jsx(Icon, { className });
|
|
23
|
-
}
|
|
24
|
-
export {
|
|
25
|
-
resolveRegisteredLucideIcon,
|
|
26
|
-
resolveRegisteredLucideIconNode
|
|
27
|
-
};
|
|
28
|
-
//# sourceMappingURL=lucideRegistryRuntime.js.map
|
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"version": 3,
|
|
3
|
-
"sources": ["../../../src/backend/icons/lucideRegistryRuntime.tsx"],
|
|
4
|
-
"sourcesContent": ["import type * as React from 'react'\nimport type { LucideIcon } from 'lucide-react'\nimport { LUCIDE_ICON_REGISTRY } from './lucideRegistry.generated'\n\nfunction normalizeKebabIconName(input: string): string {\n const trimmed = input.trim()\n if (!trimmed) return ''\n const withoutPrefix = trimmed.startsWith('lucide:') ? trimmed.slice('lucide:'.length) : trimmed\n if (!withoutPrefix) return ''\n if (!withoutPrefix.includes('-') && !withoutPrefix.includes('_') && !withoutPrefix.includes(' ') && /[A-Z]/.test(withoutPrefix)) {\n return withoutPrefix\n .replace(/([a-z0-9])([A-Z])/g, '$1-$2')\n .replace(/([A-Z])([A-Z][a-z])/g, '$1-$2')\n .toLowerCase()\n }\n return withoutPrefix\n .replace(/[_\\s]+/g, '-')\n .replace(/-+/g, '-')\n .toLowerCase()\n}\n\nexport function resolveRegisteredLucideIcon(name: string | undefined): LucideIcon | null {\n if (!name) return null\n const normalized = normalizeKebabIconName(name)\n if (!normalized) return null\n return (LUCIDE_ICON_REGISTRY as Record<string, LucideIcon | undefined>)[normalized] ?? null\n}\n\nexport function resolveRegisteredLucideIconNode(\n name: string | undefined,\n className: string\n): React.ReactNode | null {\n const Icon = resolveRegisteredLucideIcon(name)\n if (!Icon) return null\n return <Icon className={className} />\n}\n"],
|
|
5
|
-
"mappings": "AAkCS;AAhCT,SAAS,4BAA4B;AAErC,SAAS,uBAAuB,OAAuB;AACrD,QAAM,UAAU,MAAM,KAAK;AAC3B,MAAI,CAAC,QAAS,QAAO;AACrB,QAAM,gBAAgB,QAAQ,WAAW,SAAS,IAAI,QAAQ,MAAM,UAAU,MAAM,IAAI;AACxF,MAAI,CAAC,cAAe,QAAO;AAC3B,MAAI,CAAC,cAAc,SAAS,GAAG,KAAK,CAAC,cAAc,SAAS,GAAG,KAAK,CAAC,cAAc,SAAS,GAAG,KAAK,QAAQ,KAAK,aAAa,GAAG;AAC/H,WAAO,cACJ,QAAQ,sBAAsB,OAAO,EACrC,QAAQ,wBAAwB,OAAO,EACvC,YAAY;AAAA,EACjB;AACA,SAAO,cACJ,QAAQ,WAAW,GAAG,EACtB,QAAQ,OAAO,GAAG,EAClB,YAAY;AACjB;AAEO,SAAS,4BAA4B,MAA6C;AACvF,MAAI,CAAC,KAAM,QAAO;AAClB,QAAM,aAAa,uBAAuB,IAAI;AAC9C,MAAI,CAAC,WAAY,QAAO;AACxB,SAAQ,qBAAgE,UAAU,KAAK;AACzF;AAEO,SAAS,gCACd,MACA,WACwB;AACxB,QAAM,OAAO,4BAA4B,IAAI;AAC7C,MAAI,CAAC,KAAM,QAAO;AAClB,SAAO,oBAAC,QAAK,WAAsB;AACrC;",
|
|
6
|
-
"names": []
|
|
7
|
-
}
|
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
const PERSPECTIVE_COOKIE_PREFIX = "om_table_perspective";
|
|
2
|
-
const PERSPECTIVE_STORAGE_PREFIX = "om_table_perspective_snapshot";
|
|
3
|
-
function clearAllPerspectiveState() {
|
|
4
|
-
if (typeof window !== "undefined") {
|
|
5
|
-
try {
|
|
6
|
-
const storage = window.localStorage;
|
|
7
|
-
const staleKeys = [];
|
|
8
|
-
for (let index = 0; index < storage.length; index += 1) {
|
|
9
|
-
const key = storage.key(index);
|
|
10
|
-
if (key && key.startsWith(`${PERSPECTIVE_STORAGE_PREFIX}:`)) staleKeys.push(key);
|
|
11
|
-
}
|
|
12
|
-
for (const key of staleKeys) storage.removeItem(key);
|
|
13
|
-
} catch {
|
|
14
|
-
}
|
|
15
|
-
}
|
|
16
|
-
if (typeof document !== "undefined") {
|
|
17
|
-
try {
|
|
18
|
-
const cookies = document.cookie ? document.cookie.split(";") : [];
|
|
19
|
-
for (const cookie of cookies) {
|
|
20
|
-
const name = cookie.split("=")[0]?.trim();
|
|
21
|
-
if (name && name.startsWith(`${PERSPECTIVE_COOKIE_PREFIX}:`)) {
|
|
22
|
-
document.cookie = `${name}=; Path=/; Max-Age=0; SameSite=Lax`;
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
} catch {
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
export {
|
|
30
|
-
PERSPECTIVE_COOKIE_PREFIX,
|
|
31
|
-
PERSPECTIVE_STORAGE_PREFIX,
|
|
32
|
-
clearAllPerspectiveState
|
|
33
|
-
};
|
|
34
|
-
//# sourceMappingURL=perspectiveState.js.map
|
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"version": 3,
|
|
3
|
-
"sources": ["../../src/backend/perspectiveState.ts"],
|
|
4
|
-
"sourcesContent": ["export const PERSPECTIVE_COOKIE_PREFIX = 'om_table_perspective'\nexport const PERSPECTIVE_STORAGE_PREFIX = 'om_table_perspective_snapshot'\n\n/**\n * Purge every browser-local DataTable perspective snapshot and active-view cookie\n * across all tables in this origin. Snapshots persist the unsaved/live table state\n * (column widths, order, visibility) keyed only by `tableId`, so they are shared by\n * every account that signs in on the same browser profile. Call this at the auth\n * identity boundary so one user's unsaved layout never carries over to a different\n * user \u2014 including a different tenant \u2014 reusing the same browser tab (#4185).\n *\n * Lives here rather than in DataTable so the backend shell can call it without\n * pulling the whole table implementation into the bundle of every backend page.\n * `@open-mercato/ui/backend/DataTable` re-exports it, which is the published\n * import path and must stay stable.\n */\nexport function clearAllPerspectiveState(): void {\n if (typeof window !== 'undefined') {\n try {\n const storage = window.localStorage\n const staleKeys: string[] = []\n for (let index = 0; index < storage.length; index += 1) {\n const key = storage.key(index)\n if (key && key.startsWith(`${PERSPECTIVE_STORAGE_PREFIX}:`)) staleKeys.push(key)\n }\n for (const key of staleKeys) storage.removeItem(key)\n } catch {\n // private mode / quota errors are non-fatal\n }\n }\n if (typeof document !== 'undefined') {\n try {\n const cookies = document.cookie ? document.cookie.split(';') : []\n for (const cookie of cookies) {\n const name = cookie.split('=')[0]?.trim()\n if (name && name.startsWith(`${PERSPECTIVE_COOKIE_PREFIX}:`)) {\n document.cookie = `${name}=; Path=/; Max-Age=0; SameSite=Lax`\n }\n }\n } catch {\n // ignore \u2014 cookie access can throw in sandboxed contexts\n }\n }\n}\n"],
|
|
5
|
-
"mappings": "AAAO,MAAM,4BAA4B;AAClC,MAAM,6BAA6B;AAenC,SAAS,2BAAiC;AAC/C,MAAI,OAAO,WAAW,aAAa;AACjC,QAAI;AACF,YAAM,UAAU,OAAO;AACvB,YAAM,YAAsB,CAAC;AAC7B,eAAS,QAAQ,GAAG,QAAQ,QAAQ,QAAQ,SAAS,GAAG;AACtD,cAAM,MAAM,QAAQ,IAAI,KAAK;AAC7B,YAAI,OAAO,IAAI,WAAW,GAAG,0BAA0B,GAAG,EAAG,WAAU,KAAK,GAAG;AAAA,MACjF;AACA,iBAAW,OAAO,UAAW,SAAQ,WAAW,GAAG;AAAA,IACrD,QAAQ;AAAA,IAER;AAAA,EACF;AACA,MAAI,OAAO,aAAa,aAAa;AACnC,QAAI;AACF,YAAM,UAAU,SAAS,SAAS,SAAS,OAAO,MAAM,GAAG,IAAI,CAAC;AAChE,iBAAW,UAAU,SAAS;AAC5B,cAAM,OAAO,OAAO,MAAM,GAAG,EAAE,CAAC,GAAG,KAAK;AACxC,YAAI,QAAQ,KAAK,WAAW,GAAG,yBAAyB,GAAG,GAAG;AAC5D,mBAAS,SAAS,GAAG,IAAI;AAAA,QAC3B;AAAA,MACF;AAAA,IACF,QAAQ;AAAA,IAER;AAAA,EACF;AACF;",
|
|
6
|
-
"names": []
|
|
7
|
-
}
|
|
@@ -1,57 +0,0 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
import { apiCall } from "./apiCall.js";
|
|
3
|
-
const MAX_OFFSET_PAGES = 50;
|
|
4
|
-
const ABSOLUTE_URL_PATTERN = /^([a-z][a-z\d+\-.]*:)?\/\//i;
|
|
5
|
-
function parseUrl(value) {
|
|
6
|
-
try {
|
|
7
|
-
const absolute = ABSOLUTE_URL_PATTERN.test(value);
|
|
8
|
-
const origin = typeof window !== "undefined" ? window.location.origin : "http://localhost";
|
|
9
|
-
return { url: absolute ? new URL(value) : new URL(value, origin), absolute };
|
|
10
|
-
} catch {
|
|
11
|
-
return null;
|
|
12
|
-
}
|
|
13
|
-
}
|
|
14
|
-
function formatUrl(parsed) {
|
|
15
|
-
return parsed.absolute ? parsed.url.toString() : `${parsed.url.pathname}${parsed.url.search}`;
|
|
16
|
-
}
|
|
17
|
-
function readOffset(value) {
|
|
18
|
-
const parsed = parseUrl(value);
|
|
19
|
-
if (!parsed) return 0;
|
|
20
|
-
const offset = Number.parseInt(parsed.url.searchParams.get("offset") ?? "", 10);
|
|
21
|
-
return Number.isFinite(offset) && offset > 0 ? offset : 0;
|
|
22
|
-
}
|
|
23
|
-
function withOffset(value, offset) {
|
|
24
|
-
const parsed = parseUrl(value);
|
|
25
|
-
if (!parsed) {
|
|
26
|
-
const separator = value.includes("?") ? "&" : "?";
|
|
27
|
-
return `${value}${separator}offset=${offset}`;
|
|
28
|
-
}
|
|
29
|
-
parsed.url.searchParams.set("offset", String(offset));
|
|
30
|
-
return formatUrl(parsed);
|
|
31
|
-
}
|
|
32
|
-
async function fetchAllOffsetPages(url, options = {}) {
|
|
33
|
-
const maxPages = options.maxPages ?? MAX_OFFSET_PAGES;
|
|
34
|
-
const baseOffset = readOffset(url);
|
|
35
|
-
const items = [];
|
|
36
|
-
let firstPayload = {};
|
|
37
|
-
let pageCount = 0;
|
|
38
|
-
while (pageCount < maxPages) {
|
|
39
|
-
const pageUrl = pageCount === 0 ? url : withOffset(url, baseOffset + items.length);
|
|
40
|
-
const call = await apiCall(pageUrl, void 0, { fallback: {} });
|
|
41
|
-
const payload = call.result ?? {};
|
|
42
|
-
if (!call.ok) {
|
|
43
|
-
return { ok: false, items, pageCount, firstPayload, errorPayload: payload };
|
|
44
|
-
}
|
|
45
|
-
pageCount += 1;
|
|
46
|
-
if (pageCount === 1) firstPayload = payload;
|
|
47
|
-
const pageItems = Array.isArray(payload.items) ? payload.items : [];
|
|
48
|
-
items.push(...pageItems);
|
|
49
|
-
if (payload.hasMore !== true || pageItems.length === 0) break;
|
|
50
|
-
}
|
|
51
|
-
return { ok: true, items, pageCount, firstPayload, errorPayload: null };
|
|
52
|
-
}
|
|
53
|
-
export {
|
|
54
|
-
MAX_OFFSET_PAGES,
|
|
55
|
-
fetchAllOffsetPages
|
|
56
|
-
};
|
|
57
|
-
//# sourceMappingURL=fetchAllPages.js.map
|
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"version": 3,
|
|
3
|
-
"sources": ["../../../src/backend/utils/fetchAllPages.ts"],
|
|
4
|
-
"sourcesContent": ["\"use client\"\n\nimport { apiCall } from './apiCall'\n\n// Offset-paged list routes cap how many rows one response may carry, so a\n// caller that needs the whole collection has to walk the pages. Bound the walk\n// so a route reporting hasMore forever cannot hang the caller; at the 500-row\n// dictionary ceiling this still covers 25k rows.\nexport const MAX_OFFSET_PAGES = 50\n\nexport type OffsetPagedPayload = Record<string, unknown>\n\nexport type OffsetPagedResult = {\n ok: boolean\n items: unknown[]\n pageCount: number\n firstPayload: OffsetPagedPayload\n errorPayload: OffsetPagedPayload | null\n}\n\nconst ABSOLUTE_URL_PATTERN = /^([a-z][a-z\\d+\\-.]*:)?\\/\\//i\n\ntype ParsedUrl = { url: URL; absolute: boolean }\n\nfunction parseUrl(value: string): ParsedUrl | null {\n try {\n const absolute = ABSOLUTE_URL_PATTERN.test(value)\n const origin = typeof window !== 'undefined' ? window.location.origin : 'http://localhost'\n return { url: absolute ? new URL(value) : new URL(value, origin), absolute }\n } catch {\n return null\n }\n}\n\nfunction formatUrl(parsed: ParsedUrl): string {\n return parsed.absolute ? parsed.url.toString() : `${parsed.url.pathname}${parsed.url.search}`\n}\n\nfunction readOffset(value: string): number {\n const parsed = parseUrl(value)\n if (!parsed) return 0\n const offset = Number.parseInt(parsed.url.searchParams.get('offset') ?? '', 10)\n return Number.isFinite(offset) && offset > 0 ? offset : 0\n}\n\nfunction withOffset(value: string, offset: number): string {\n const parsed = parseUrl(value)\n if (!parsed) {\n const separator = value.includes('?') ? '&' : '?'\n return `${value}${separator}offset=${offset}`\n }\n parsed.url.searchParams.set('offset', String(offset))\n return formatUrl(parsed)\n}\n\n/**\n * Walk every page of an offset-paged list route and return the assembled items.\n *\n * A route that does not report `hasMore` resolves after a single request, so\n * this is a drop-in replacement for a one-shot `apiCall` against any list\n * endpoint. Ordering is preserved as the route returned it, page after page \u2014\n * callers that need a global order across page boundaries must re-sort.\n */\nexport async function fetchAllOffsetPages(\n url: string,\n options: { maxPages?: number } = {},\n): Promise<OffsetPagedResult> {\n const maxPages = options.maxPages ?? MAX_OFFSET_PAGES\n const baseOffset = readOffset(url)\n const items: unknown[] = []\n let firstPayload: OffsetPagedPayload = {}\n let pageCount = 0\n\n while (pageCount < maxPages) {\n // The first request goes out untouched so a route that caches only its\n // default page still serves that page from cache.\n const pageUrl = pageCount === 0 ? url : withOffset(url, baseOffset + items.length)\n const call = await apiCall<OffsetPagedPayload>(pageUrl, undefined, { fallback: {} })\n const payload = call.result ?? {}\n if (!call.ok) {\n return { ok: false, items, pageCount, firstPayload, errorPayload: payload }\n }\n pageCount += 1\n if (pageCount === 1) firstPayload = payload\n const pageItems = Array.isArray(payload.items) ? payload.items : []\n items.push(...pageItems)\n // An empty page also stops the walk: without it a route that keeps\n // reporting hasMore while returning nothing would spin to the page cap.\n if (payload.hasMore !== true || pageItems.length === 0) break\n }\n\n return { ok: true, items, pageCount, firstPayload, errorPayload: null }\n}\n"],
|
|
5
|
-
"mappings": ";AAEA,SAAS,eAAe;AAMjB,MAAM,mBAAmB;AAYhC,MAAM,uBAAuB;AAI7B,SAAS,SAAS,OAAiC;AACjD,MAAI;AACF,UAAM,WAAW,qBAAqB,KAAK,KAAK;AAChD,UAAM,SAAS,OAAO,WAAW,cAAc,OAAO,SAAS,SAAS;AACxE,WAAO,EAAE,KAAK,WAAW,IAAI,IAAI,KAAK,IAAI,IAAI,IAAI,OAAO,MAAM,GAAG,SAAS;AAAA,EAC7E,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAEA,SAAS,UAAU,QAA2B;AAC5C,SAAO,OAAO,WAAW,OAAO,IAAI,SAAS,IAAI,GAAG,OAAO,IAAI,QAAQ,GAAG,OAAO,IAAI,MAAM;AAC7F;AAEA,SAAS,WAAW,OAAuB;AACzC,QAAM,SAAS,SAAS,KAAK;AAC7B,MAAI,CAAC,OAAQ,QAAO;AACpB,QAAM,SAAS,OAAO,SAAS,OAAO,IAAI,aAAa,IAAI,QAAQ,KAAK,IAAI,EAAE;AAC9E,SAAO,OAAO,SAAS,MAAM,KAAK,SAAS,IAAI,SAAS;AAC1D;AAEA,SAAS,WAAW,OAAe,QAAwB;AACzD,QAAM,SAAS,SAAS,KAAK;AAC7B,MAAI,CAAC,QAAQ;AACX,UAAM,YAAY,MAAM,SAAS,GAAG,IAAI,MAAM;AAC9C,WAAO,GAAG,KAAK,GAAG,SAAS,UAAU,MAAM;AAAA,EAC7C;AACA,SAAO,IAAI,aAAa,IAAI,UAAU,OAAO,MAAM,CAAC;AACpD,SAAO,UAAU,MAAM;AACzB;AAUA,eAAsB,oBACpB,KACA,UAAiC,CAAC,GACN;AAC5B,QAAM,WAAW,QAAQ,YAAY;AACrC,QAAM,aAAa,WAAW,GAAG;AACjC,QAAM,QAAmB,CAAC;AAC1B,MAAI,eAAmC,CAAC;AACxC,MAAI,YAAY;AAEhB,SAAO,YAAY,UAAU;AAG3B,UAAM,UAAU,cAAc,IAAI,MAAM,WAAW,KAAK,aAAa,MAAM,MAAM;AACjF,UAAM,OAAO,MAAM,QAA4B,SAAS,QAAW,EAAE,UAAU,CAAC,EAAE,CAAC;AACnF,UAAM,UAAU,KAAK,UAAU,CAAC;AAChC,QAAI,CAAC,KAAK,IAAI;AACZ,aAAO,EAAE,IAAI,OAAO,OAAO,WAAW,cAAc,cAAc,QAAQ;AAAA,IAC5E;AACA,iBAAa;AACb,QAAI,cAAc,EAAG,gBAAe;AACpC,UAAM,YAAY,MAAM,QAAQ,QAAQ,KAAK,IAAI,QAAQ,QAAQ,CAAC;AAClE,UAAM,KAAK,GAAG,SAAS;AAGvB,QAAI,QAAQ,YAAY,QAAQ,UAAU,WAAW,EAAG;AAAA,EAC1D;AAEA,SAAO,EAAE,IAAI,MAAM,OAAO,WAAW,cAAc,cAAc,KAAK;AACxE;",
|
|
6
|
-
"names": []
|
|
7
|
-
}
|
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
const PORTAL_BRIDGE_STATUS_DOM_NAME = "om:portal-bridge:status";
|
|
2
|
-
function readPortalBridgeHealth() {
|
|
3
|
-
if (typeof window === "undefined") return void 0;
|
|
4
|
-
return window.__portalBridgeHealthy;
|
|
5
|
-
}
|
|
6
|
-
function publishPortalBridgeHealth(healthy) {
|
|
7
|
-
if (typeof window === "undefined") return;
|
|
8
|
-
window.__portalBridgeHealthy = healthy;
|
|
9
|
-
window.dispatchEvent(
|
|
10
|
-
new CustomEvent(PORTAL_BRIDGE_STATUS_DOM_NAME, {
|
|
11
|
-
detail: { healthy }
|
|
12
|
-
})
|
|
13
|
-
);
|
|
14
|
-
}
|
|
15
|
-
function clearPortalBridgeHealth() {
|
|
16
|
-
if (typeof window === "undefined") return;
|
|
17
|
-
delete window.__portalBridgeHealthy;
|
|
18
|
-
}
|
|
19
|
-
export {
|
|
20
|
-
PORTAL_BRIDGE_STATUS_DOM_NAME,
|
|
21
|
-
clearPortalBridgeHealth,
|
|
22
|
-
publishPortalBridgeHealth,
|
|
23
|
-
readPortalBridgeHealth
|
|
24
|
-
};
|
|
25
|
-
//# sourceMappingURL=portalBridgeStatus.js.map
|
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"version": 3,
|
|
3
|
-
"sources": ["../../../src/portal/hooks/portalBridgeStatus.ts"],
|
|
4
|
-
"sourcesContent": ["export const PORTAL_BRIDGE_STATUS_DOM_NAME = 'om:portal-bridge:status'\n\nexport type PortalBridgeStatusDetail = {\n healthy: boolean\n}\n\ntype PortalBridgeWindow = Window & {\n __portalBridgeHealthy?: boolean\n}\n\nexport function readPortalBridgeHealth(): boolean | undefined {\n if (typeof window === 'undefined') return undefined\n return (window as PortalBridgeWindow).__portalBridgeHealthy\n}\n\nexport function publishPortalBridgeHealth(healthy: boolean): void {\n if (typeof window === 'undefined') return\n ;(window as PortalBridgeWindow).__portalBridgeHealthy = healthy\n window.dispatchEvent(\n new CustomEvent<PortalBridgeStatusDetail>(PORTAL_BRIDGE_STATUS_DOM_NAME, {\n detail: { healthy },\n }),\n )\n}\n\nexport function clearPortalBridgeHealth(): void {\n if (typeof window === 'undefined') return\n delete (window as PortalBridgeWindow).__portalBridgeHealthy\n}\n"],
|
|
5
|
-
"mappings": "AAAO,MAAM,gCAAgC;AAUtC,SAAS,yBAA8C;AAC5D,MAAI,OAAO,WAAW,YAAa,QAAO;AAC1C,SAAQ,OAA8B;AACxC;AAEO,SAAS,0BAA0B,SAAwB;AAChE,MAAI,OAAO,WAAW,YAAa;AAClC,EAAC,OAA8B,wBAAwB;AACxD,SAAO;AAAA,IACL,IAAI,YAAsC,+BAA+B;AAAA,MACvE,QAAQ,EAAE,QAAQ;AAAA,IACpB,CAAC;AAAA,EACH;AACF;AAEO,SAAS,0BAAgC;AAC9C,MAAI,OAAO,WAAW,YAAa;AACnC,SAAQ,OAA8B;AACxC;",
|
|
6
|
-
"names": []
|
|
7
|
-
}
|