@open-mercato/ui 0.4.2-canary-c02407ff85
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/build.mjs +62 -0
- package/dist/backend/AppShell.js +902 -0
- package/dist/backend/AppShell.js.map +7 -0
- package/dist/backend/ConfirmDialog.js +17 -0
- package/dist/backend/ConfirmDialog.js.map +7 -0
- package/dist/backend/ContextHelp.js +31 -0
- package/dist/backend/ContextHelp.js.map +7 -0
- package/dist/backend/CrudForm.js +2028 -0
- package/dist/backend/CrudForm.js.map +7 -0
- package/dist/backend/DataTable.js +1363 -0
- package/dist/backend/DataTable.js.map +7 -0
- package/dist/backend/EmptyState.js +52 -0
- package/dist/backend/EmptyState.js.map +7 -0
- package/dist/backend/FilterBar.js +140 -0
- package/dist/backend/FilterBar.js.map +7 -0
- package/dist/backend/FilterOverlay.js +279 -0
- package/dist/backend/FilterOverlay.js.map +7 -0
- package/dist/backend/FlashMessages.js +66 -0
- package/dist/backend/FlashMessages.js.map +7 -0
- package/dist/backend/JsonBuilder.js +322 -0
- package/dist/backend/JsonBuilder.js.map +7 -0
- package/dist/backend/JsonDisplay.js +203 -0
- package/dist/backend/JsonDisplay.js.map +7 -0
- package/dist/backend/Page.js +27 -0
- package/dist/backend/Page.js.map +7 -0
- package/dist/backend/PerspectiveSidebar.js +282 -0
- package/dist/backend/PerspectiveSidebar.js.map +7 -0
- package/dist/backend/RowActions.js +148 -0
- package/dist/backend/RowActions.js.map +7 -0
- package/dist/backend/TruncatedCell.js +92 -0
- package/dist/backend/TruncatedCell.js.map +7 -0
- package/dist/backend/UserMenu.js +107 -0
- package/dist/backend/UserMenu.js.map +7 -0
- package/dist/backend/ValueIcons.js +34 -0
- package/dist/backend/ValueIcons.js.map +7 -0
- package/dist/backend/custom-fields/FieldDefinitionsEditor.js +1264 -0
- package/dist/backend/custom-fields/FieldDefinitionsEditor.js.map +7 -0
- package/dist/backend/custom-fields/FieldDefinitionsManager.js +332 -0
- package/dist/backend/custom-fields/FieldDefinitionsManager.js.map +7 -0
- package/dist/backend/dashboard/DashboardScreen.js +578 -0
- package/dist/backend/dashboard/DashboardScreen.js.map +7 -0
- package/dist/backend/dashboard/index.js +5 -0
- package/dist/backend/dashboard/index.js.map +7 -0
- package/dist/backend/dashboard/widgetRegistry.js +55 -0
- package/dist/backend/dashboard/widgetRegistry.js.map +7 -0
- package/dist/backend/detail/ActivitiesSection.js +962 -0
- package/dist/backend/detail/ActivitiesSection.js.map +7 -0
- package/dist/backend/detail/AddressEditor.js +413 -0
- package/dist/backend/detail/AddressEditor.js.map +7 -0
- package/dist/backend/detail/AddressTiles.js +437 -0
- package/dist/backend/detail/AddressTiles.js.map +7 -0
- package/dist/backend/detail/AddressesSection.js +264 -0
- package/dist/backend/detail/AddressesSection.js.map +7 -0
- package/dist/backend/detail/AttachmentDeleteDialog.js +41 -0
- package/dist/backend/detail/AttachmentDeleteDialog.js.map +7 -0
- package/dist/backend/detail/AttachmentMetadataDialog.js +517 -0
- package/dist/backend/detail/AttachmentMetadataDialog.js.map +7 -0
- package/dist/backend/detail/AttachmentsSection.js +367 -0
- package/dist/backend/detail/AttachmentsSection.js.map +7 -0
- package/dist/backend/detail/CustomDataSection.js +433 -0
- package/dist/backend/detail/CustomDataSection.js.map +7 -0
- package/dist/backend/detail/DetailFieldsSection.js +75 -0
- package/dist/backend/detail/DetailFieldsSection.js.map +7 -0
- package/dist/backend/detail/ErrorMessage.js +28 -0
- package/dist/backend/detail/ErrorMessage.js.map +7 -0
- package/dist/backend/detail/InlineEditors.js +681 -0
- package/dist/backend/detail/InlineEditors.js.map +7 -0
- package/dist/backend/detail/LoadingMessage.js +14 -0
- package/dist/backend/detail/LoadingMessage.js.map +7 -0
- package/dist/backend/detail/NotesSection.js +1032 -0
- package/dist/backend/detail/NotesSection.js.map +7 -0
- package/dist/backend/detail/TabEmptyState.js +25 -0
- package/dist/backend/detail/TabEmptyState.js.map +7 -0
- package/dist/backend/detail/TagsSection.js +254 -0
- package/dist/backend/detail/TagsSection.js.map +7 -0
- package/dist/backend/detail/addressFormat.js +77 -0
- package/dist/backend/detail/addressFormat.js.map +7 -0
- package/dist/backend/detail/index.js +34 -0
- package/dist/backend/detail/index.js.map +7 -0
- package/dist/backend/fields/registry.generated.js +8 -0
- package/dist/backend/fields/registry.generated.js.map +7 -0
- package/dist/backend/fields/registry.js +29 -0
- package/dist/backend/fields/registry.js.map +7 -0
- package/dist/backend/indexes/PartialIndexBanner.js +58 -0
- package/dist/backend/indexes/PartialIndexBanner.js.map +7 -0
- package/dist/backend/indexes/store.js +62 -0
- package/dist/backend/indexes/store.js.map +7 -0
- package/dist/backend/injection/InjectionSpot.js +179 -0
- package/dist/backend/injection/InjectionSpot.js.map +7 -0
- package/dist/backend/injection/PageInjectionBoundary.js +26 -0
- package/dist/backend/injection/PageInjectionBoundary.js.map +7 -0
- package/dist/backend/injection/helpers.js +26 -0
- package/dist/backend/injection/helpers.js.map +7 -0
- package/dist/backend/injection/widgetRegistry.js +55 -0
- package/dist/backend/injection/widgetRegistry.js.map +7 -0
- package/dist/backend/inputs/ComboboxInput.js +225 -0
- package/dist/backend/inputs/ComboboxInput.js.map +7 -0
- package/dist/backend/inputs/LookupSelect.js +191 -0
- package/dist/backend/inputs/LookupSelect.js.map +7 -0
- package/dist/backend/inputs/PhoneNumberField.js +100 -0
- package/dist/backend/inputs/PhoneNumberField.js.map +7 -0
- package/dist/backend/inputs/SwitchableMarkdownInput.js +92 -0
- package/dist/backend/inputs/SwitchableMarkdownInput.js.map +7 -0
- package/dist/backend/inputs/TagsInput.js +222 -0
- package/dist/backend/inputs/TagsInput.js.map +7 -0
- package/dist/backend/inputs/index.js +6 -0
- package/dist/backend/inputs/index.js.map +7 -0
- package/dist/backend/operations/LastOperationBanner.js +80 -0
- package/dist/backend/operations/LastOperationBanner.js.map +7 -0
- package/dist/backend/operations/store.js +183 -0
- package/dist/backend/operations/store.js.map +7 -0
- package/dist/backend/schedule/ScheduleAgenda.js +107 -0
- package/dist/backend/schedule/ScheduleAgenda.js.map +7 -0
- package/dist/backend/schedule/ScheduleGrid.js +107 -0
- package/dist/backend/schedule/ScheduleGrid.js.map +7 -0
- package/dist/backend/schedule/ScheduleToolbar.js +166 -0
- package/dist/backend/schedule/ScheduleToolbar.js.map +7 -0
- package/dist/backend/schedule/ScheduleView.js +165 -0
- package/dist/backend/schedule/ScheduleView.js.map +7 -0
- package/dist/backend/schedule/index.js +6 -0
- package/dist/backend/schedule/index.js.map +7 -0
- package/dist/backend/schedule/recurrence.js +83 -0
- package/dist/backend/schedule/recurrence.js.map +7 -0
- package/dist/backend/schedule/types.js +1 -0
- package/dist/backend/schedule/types.js.map +7 -0
- package/dist/backend/upgrades/UpgradeActionBanner.js +91 -0
- package/dist/backend/upgrades/UpgradeActionBanner.js.map +7 -0
- package/dist/backend/utils/api.js +127 -0
- package/dist/backend/utils/api.js.map +7 -0
- package/dist/backend/utils/apiCall.js +48 -0
- package/dist/backend/utils/apiCall.js.map +7 -0
- package/dist/backend/utils/crud.js +126 -0
- package/dist/backend/utils/crud.js.map +7 -0
- package/dist/backend/utils/customFieldColumns.js +56 -0
- package/dist/backend/utils/customFieldColumns.js.map +7 -0
- package/dist/backend/utils/customFieldDefs.js +143 -0
- package/dist/backend/utils/customFieldDefs.js.map +7 -0
- package/dist/backend/utils/customFieldFilters.js +126 -0
- package/dist/backend/utils/customFieldFilters.js.map +7 -0
- package/dist/backend/utils/customFieldForms.js +162 -0
- package/dist/backend/utils/customFieldForms.js.map +7 -0
- package/dist/backend/utils/customFieldValues.js +26 -0
- package/dist/backend/utils/customFieldValues.js.map +7 -0
- package/dist/backend/utils/flash.js +16 -0
- package/dist/backend/utils/flash.js.map +7 -0
- package/dist/backend/utils/nav.js +185 -0
- package/dist/backend/utils/nav.js.map +7 -0
- package/dist/backend/utils/serverErrors.js +230 -0
- package/dist/backend/utils/serverErrors.js.map +7 -0
- package/dist/frontend/AuthFooter.js +23 -0
- package/dist/frontend/AuthFooter.js.map +7 -0
- package/dist/frontend/LanguageSwitcher.js +57 -0
- package/dist/frontend/LanguageSwitcher.js.map +7 -0
- package/dist/frontend/Layout.js +14 -0
- package/dist/frontend/Layout.js.map +7 -0
- package/dist/index.js +32 -0
- package/dist/index.js.map +7 -0
- package/dist/primitives/DataLoader.js +67 -0
- package/dist/primitives/DataLoader.js.map +7 -0
- package/dist/primitives/ErrorNotice.js +20 -0
- package/dist/primitives/ErrorNotice.js.map +7 -0
- package/dist/primitives/alert.js +38 -0
- package/dist/primitives/alert.js.map +7 -0
- package/dist/primitives/badge.js +28 -0
- package/dist/primitives/badge.js.map +7 -0
- package/dist/primitives/button.js +44 -0
- package/dist/primitives/button.js.map +7 -0
- package/dist/primitives/card.js +91 -0
- package/dist/primitives/card.js.map +7 -0
- package/dist/primitives/checkbox.js +28 -0
- package/dist/primitives/checkbox.js.map +7 -0
- package/dist/primitives/dialog.js +90 -0
- package/dist/primitives/dialog.js.map +7 -0
- package/dist/primitives/input.js +22 -0
- package/dist/primitives/input.js.map +7 -0
- package/dist/primitives/label.js +21 -0
- package/dist/primitives/label.js.map +7 -0
- package/dist/primitives/separator.js +9 -0
- package/dist/primitives/separator.js.map +7 -0
- package/dist/primitives/spinner.js +24 -0
- package/dist/primitives/spinner.js.map +7 -0
- package/dist/primitives/switch.js +80 -0
- package/dist/primitives/switch.js.map +7 -0
- package/dist/primitives/table.js +29 -0
- package/dist/primitives/table.js.map +7 -0
- package/dist/primitives/tabs.js +87 -0
- package/dist/primitives/tabs.js.map +7 -0
- package/dist/primitives/textarea.js +21 -0
- package/dist/primitives/textarea.js.map +7 -0
- package/dist/primitives/tooltip.js +60 -0
- package/dist/primitives/tooltip.js.map +7 -0
- package/dist/theme/QueryProvider.js +44 -0
- package/dist/theme/QueryProvider.js.map +7 -0
- package/dist/theme/ThemeProvider.js +95 -0
- package/dist/theme/ThemeProvider.js.map +7 -0
- package/dist/theme/ThemeToggle.js +88 -0
- package/dist/theme/ThemeToggle.js.map +7 -0
- package/dist/theme/index.js +10 -0
- package/dist/theme/index.js.map +7 -0
- package/dist/types/react-big-calendar.d.js +1 -0
- package/dist/types/react-big-calendar.d.js.map +7 -0
- package/jest.config.cjs +23 -0
- package/jest.setup.ts +55 -0
- package/package.json +105 -0
- package/src/backend/AppShell.tsx +1096 -0
- package/src/backend/ConfirmDialog.tsx +19 -0
- package/src/backend/ContextHelp.tsx +38 -0
- package/src/backend/CrudForm.tsx +2503 -0
- package/src/backend/DataTable.tsx +1730 -0
- package/src/backend/EmptyState.tsx +65 -0
- package/src/backend/FilterBar.tsx +161 -0
- package/src/backend/FilterOverlay.tsx +328 -0
- package/src/backend/FlashMessages.tsx +82 -0
- package/src/backend/JsonBuilder.tsx +362 -0
- package/src/backend/JsonDisplay.tsx +254 -0
- package/src/backend/Page.tsx +30 -0
- package/src/backend/PerspectiveSidebar.tsx +337 -0
- package/src/backend/RowActions.tsx +151 -0
- package/src/backend/TruncatedCell.tsx +133 -0
- package/src/backend/UserMenu.tsx +118 -0
- package/src/backend/ValueIcons.tsx +48 -0
- package/src/backend/__tests__/AppShell.test.tsx +115 -0
- package/src/backend/__tests__/CrudForm.render.test.tsx +30 -0
- package/src/backend/__tests__/DataTable.render.test.tsx +48 -0
- package/src/backend/__tests__/custom-field-filters.test.ts +72 -0
- package/src/backend/__tests__/custom-field-forms.test.ts +54 -0
- package/src/backend/__tests__/serverErrors.test.ts +83 -0
- package/src/backend/custom-fields/FieldDefinitionsEditor.tsx +1292 -0
- package/src/backend/custom-fields/FieldDefinitionsManager.tsx +381 -0
- package/src/backend/dashboard/DashboardScreen.tsx +684 -0
- package/src/backend/dashboard/__tests__/DashboardScreen.test.tsx +112 -0
- package/src/backend/dashboard/index.ts +1 -0
- package/src/backend/dashboard/widgetRegistry.ts +68 -0
- package/src/backend/detail/ActivitiesSection.tsx +1284 -0
- package/src/backend/detail/AddressEditor.tsx +472 -0
- package/src/backend/detail/AddressTiles.tsx +587 -0
- package/src/backend/detail/AddressesSection.tsx +346 -0
- package/src/backend/detail/AttachmentDeleteDialog.tsx +56 -0
- package/src/backend/detail/AttachmentMetadataDialog.tsx +672 -0
- package/src/backend/detail/AttachmentsSection.tsx +414 -0
- package/src/backend/detail/CustomDataSection.tsx +530 -0
- package/src/backend/detail/DetailFieldsSection.tsx +147 -0
- package/src/backend/detail/ErrorMessage.tsx +32 -0
- package/src/backend/detail/InlineEditors.tsx +877 -0
- package/src/backend/detail/LoadingMessage.tsx +14 -0
- package/src/backend/detail/NotesSection.tsx +1275 -0
- package/src/backend/detail/TabEmptyState.tsx +48 -0
- package/src/backend/detail/TagsSection.tsx +314 -0
- package/src/backend/detail/addressFormat.tsx +121 -0
- package/src/backend/detail/index.ts +44 -0
- package/src/backend/fields/registry.generated.ts +8 -0
- package/src/backend/fields/registry.ts +38 -0
- package/src/backend/indexes/PartialIndexBanner.tsx +68 -0
- package/src/backend/indexes/store.ts +88 -0
- package/src/backend/injection/InjectionSpot.tsx +236 -0
- package/src/backend/injection/PageInjectionBoundary.tsx +31 -0
- package/src/backend/injection/helpers.ts +35 -0
- package/src/backend/injection/widgetRegistry.ts +68 -0
- package/src/backend/inputs/ComboboxInput.tsx +269 -0
- package/src/backend/inputs/LookupSelect.tsx +247 -0
- package/src/backend/inputs/PhoneNumberField.tsx +129 -0
- package/src/backend/inputs/SwitchableMarkdownInput.tsx +128 -0
- package/src/backend/inputs/TagsInput.tsx +259 -0
- package/src/backend/inputs/index.ts +5 -0
- package/src/backend/operations/LastOperationBanner.tsx +85 -0
- package/src/backend/operations/__tests__/LastOperationBanner.test.tsx +99 -0
- package/src/backend/operations/store.ts +230 -0
- package/src/backend/schedule/ScheduleAgenda.tsx +136 -0
- package/src/backend/schedule/ScheduleGrid.tsx +136 -0
- package/src/backend/schedule/ScheduleToolbar.tsx +178 -0
- package/src/backend/schedule/ScheduleView.tsx +198 -0
- package/src/backend/schedule/index.ts +5 -0
- package/src/backend/schedule/recurrence.ts +99 -0
- package/src/backend/schedule/types.ts +26 -0
- package/src/backend/upgrades/UpgradeActionBanner.tsx +128 -0
- package/src/backend/utils/__tests__/apiCall.test.ts +109 -0
- package/src/backend/utils/__tests__/crud.test.ts +87 -0
- package/src/backend/utils/__tests__/customFieldDefs.test.ts +25 -0
- package/src/backend/utils/__tests__/customFieldValues.test.ts +35 -0
- package/src/backend/utils/api.ts +149 -0
- package/src/backend/utils/apiCall.ts +96 -0
- package/src/backend/utils/crud.ts +174 -0
- package/src/backend/utils/customFieldColumns.ts +71 -0
- package/src/backend/utils/customFieldDefs.ts +245 -0
- package/src/backend/utils/customFieldFilters.ts +145 -0
- package/src/backend/utils/customFieldForms.ts +196 -0
- package/src/backend/utils/customFieldValues.ts +41 -0
- package/src/backend/utils/flash.ts +17 -0
- package/src/backend/utils/nav.ts +238 -0
- package/src/backend/utils/serverErrors.ts +302 -0
- package/src/frontend/AuthFooter.tsx +29 -0
- package/src/frontend/LanguageSwitcher.tsx +66 -0
- package/src/frontend/Layout.tsx +13 -0
- package/src/index.ts +32 -0
- package/src/primitives/DataLoader.tsx +92 -0
- package/src/primitives/ErrorNotice.tsx +26 -0
- package/src/primitives/alert.tsx +52 -0
- package/src/primitives/badge.tsx +31 -0
- package/src/primitives/button.tsx +47 -0
- package/src/primitives/card.tsx +92 -0
- package/src/primitives/checkbox.tsx +28 -0
- package/src/primitives/dialog.tsx +110 -0
- package/src/primitives/input.tsx +20 -0
- package/src/primitives/label.tsx +18 -0
- package/src/primitives/separator.tsx +7 -0
- package/src/primitives/spinner.tsx +27 -0
- package/src/primitives/switch.tsx +86 -0
- package/src/primitives/table.tsx +27 -0
- package/src/primitives/tabs.tsx +128 -0
- package/src/primitives/textarea.tsx +20 -0
- package/src/primitives/tooltip.tsx +85 -0
- package/src/theme/QueryProvider.tsx +46 -0
- package/src/theme/ThemeProvider.tsx +120 -0
- package/src/theme/ThemeToggle.tsx +88 -0
- package/src/theme/index.ts +3 -0
- package/src/types/react-big-calendar.d.ts +16 -0
- package/tsconfig.build.json +11 -0
- package/tsconfig.json +9 -0
- package/watch.mjs +6 -0
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import React from 'react'
|
|
3
|
+
|
|
4
|
+
export type AdminNavItem = {
|
|
5
|
+
group: string
|
|
6
|
+
groupId: string
|
|
7
|
+
groupKey?: string
|
|
8
|
+
groupDefaultName: string
|
|
9
|
+
title: string
|
|
10
|
+
defaultTitle: string
|
|
11
|
+
titleKey?: string
|
|
12
|
+
href: string
|
|
13
|
+
enabled: boolean
|
|
14
|
+
hidden?: boolean
|
|
15
|
+
order?: number
|
|
16
|
+
priority?: number
|
|
17
|
+
icon?: ReactNode
|
|
18
|
+
children?: AdminNavItem[]
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type AdminNavFeatureChecker = (features: string[]) => Promise<Iterable<string> | null | undefined>
|
|
22
|
+
|
|
23
|
+
export type BuildAdminNavOptions = {
|
|
24
|
+
checkFeatures?: AdminNavFeatureChecker
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @deprecated The internal fetch-based feature check will be removed.
|
|
29
|
+
* Provide `options.checkFeatures` so buildAdminNav can reuse your RBAC context.
|
|
30
|
+
*/
|
|
31
|
+
async function fetchFeatureGrants(requestFeatures: string[]): Promise<Set<string>> {
|
|
32
|
+
const granted = new Set<string>()
|
|
33
|
+
if (!requestFeatures.length) return granted
|
|
34
|
+
let url = '/api/auth/feature-check'
|
|
35
|
+
let headersInit: Record<string, string> | undefined
|
|
36
|
+
if (typeof window === 'undefined') {
|
|
37
|
+
// On the server, build absolute URL and forward cookies so auth is available
|
|
38
|
+
try {
|
|
39
|
+
const { headers: getHeaders } = await import('next/headers')
|
|
40
|
+
const h = await getHeaders()
|
|
41
|
+
const host = h.get('x-forwarded-host') || h.get('host') || ''
|
|
42
|
+
const proto = h.get('x-forwarded-proto') || 'http'
|
|
43
|
+
const cookie = h.get('cookie') || ''
|
|
44
|
+
if (host) url = `${proto}://${host}/api/auth/feature-check`
|
|
45
|
+
headersInit = { cookie }
|
|
46
|
+
} catch {
|
|
47
|
+
// ignore; fall back to relative URL without forwarded cookies
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
const res = await fetch(url, {
|
|
52
|
+
method: 'POST',
|
|
53
|
+
credentials: 'include' as any,
|
|
54
|
+
headers: { 'content-type': 'application/json', ...(headersInit || {}) },
|
|
55
|
+
body: JSON.stringify({ features: requestFeatures }),
|
|
56
|
+
} as any)
|
|
57
|
+
if (res.ok) {
|
|
58
|
+
const data = await res.json().catch(() => ({ granted: [] }))
|
|
59
|
+
if (Array.isArray(data?.granted)) {
|
|
60
|
+
data.granted.forEach((f: string) => granted.add(f))
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
} catch {
|
|
64
|
+
// ignore fetch failures and keep feature set empty
|
|
65
|
+
}
|
|
66
|
+
return granted
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function buildAdminNav(
|
|
70
|
+
modules: any[],
|
|
71
|
+
ctx: { auth?: { roles?: string[]; sub?: string; orgId?: string | null; tenantId?: string | null }; path?: string },
|
|
72
|
+
userEntities?: Array<{ entityId: string; label: string; href: string }>,
|
|
73
|
+
translate?: (key: string | undefined, fallback: string) => string,
|
|
74
|
+
options?: BuildAdminNavOptions
|
|
75
|
+
): Promise<AdminNavItem[]> {
|
|
76
|
+
function capitalize(s: string) {
|
|
77
|
+
return s.charAt(0).toUpperCase() + s.slice(1)
|
|
78
|
+
}
|
|
79
|
+
function deriveTitleFromPath(p: string) {
|
|
80
|
+
const seg = p.split('/').filter(Boolean).pop() || ''
|
|
81
|
+
return seg ? seg.split('-').map(capitalize).join(' ') : 'Home'
|
|
82
|
+
}
|
|
83
|
+
const entries: AdminNavItem[] = []
|
|
84
|
+
|
|
85
|
+
// Collect all unique features needed across all routes first
|
|
86
|
+
const allRequiredFeatures = new Set<string>()
|
|
87
|
+
for (const m of modules) {
|
|
88
|
+
for (const r of m.backendRoutes ?? []) {
|
|
89
|
+
const features = (r as any).requireFeatures as string[] | undefined
|
|
90
|
+
if (features && features.length) {
|
|
91
|
+
features.forEach(f => allRequiredFeatures.add(f))
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Batch check all features in a single API call
|
|
97
|
+
let userFeatures = new Set<string>()
|
|
98
|
+
if (allRequiredFeatures.size > 0) {
|
|
99
|
+
const requestFeatures = Array.from(allRequiredFeatures)
|
|
100
|
+
if (options?.checkFeatures) {
|
|
101
|
+
try {
|
|
102
|
+
const resolved = await options.checkFeatures(requestFeatures)
|
|
103
|
+
if (resolved) {
|
|
104
|
+
userFeatures = new Set(resolved)
|
|
105
|
+
}
|
|
106
|
+
} catch {
|
|
107
|
+
// ignore and fall back to empty feature set
|
|
108
|
+
}
|
|
109
|
+
} else {
|
|
110
|
+
userFeatures = await fetchFeatureGrants(requestFeatures)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Helper: check if user has all required features (from cache)
|
|
115
|
+
function hasAllFeatures(required: string[]): boolean {
|
|
116
|
+
if (!required || required.length === 0) return true
|
|
117
|
+
return required.every(f => userFeatures.has(f))
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Icons are defined per-page in metadata; no heuristic derivation here.
|
|
121
|
+
for (const m of modules) {
|
|
122
|
+
const groupDefault = capitalize(m.id)
|
|
123
|
+
for (const r of m.backendRoutes ?? []) {
|
|
124
|
+
const href = (r.pattern ?? r.path ?? '') as string
|
|
125
|
+
if (!href || href.includes('[')) continue
|
|
126
|
+
if ((r as any).navHidden) continue
|
|
127
|
+
const title = (r.title as string) || deriveTitleFromPath(href)
|
|
128
|
+
const titleKey = (r as any).pageTitleKey ?? (r as any).titleKey
|
|
129
|
+
const group = (r.group as string) || groupDefault
|
|
130
|
+
const groupKey = (r as any).pageGroupKey ?? (r as any).groupKey
|
|
131
|
+
const groupId = (groupKey as string | undefined) ?? group
|
|
132
|
+
const displayGroup = translate ? translate(groupKey, group) : group
|
|
133
|
+
const displayTitle = translate ? translate(titleKey, title) : title
|
|
134
|
+
const visible = r.visible ? await Promise.resolve(r.visible(ctx)) : true
|
|
135
|
+
if (!visible) continue
|
|
136
|
+
const enabled = r.enabled ? await Promise.resolve(r.enabled(ctx)) : true
|
|
137
|
+
// If roles are required, check; otherwise include
|
|
138
|
+
const required = (r.requireRoles as string[]) || []
|
|
139
|
+
if (required.length) {
|
|
140
|
+
const roles = ctx.auth?.roles || []
|
|
141
|
+
const ok = required.some((role) => roles.includes(role))
|
|
142
|
+
if (!ok) continue
|
|
143
|
+
}
|
|
144
|
+
// If features are required, check from cached batch result
|
|
145
|
+
const features = (r as any).requireFeatures as string[] | undefined
|
|
146
|
+
if (features && features.length) {
|
|
147
|
+
const ok = hasAllFeatures(features)
|
|
148
|
+
if (!ok) continue
|
|
149
|
+
}
|
|
150
|
+
const order = (r as any).order as number | undefined
|
|
151
|
+
const priority = ((r as any).priority as number | undefined) ?? order
|
|
152
|
+
let icon = (r as any).icon as ReactNode | undefined
|
|
153
|
+
entries.push({
|
|
154
|
+
group: displayGroup,
|
|
155
|
+
groupId,
|
|
156
|
+
groupKey,
|
|
157
|
+
groupDefaultName: displayGroup,
|
|
158
|
+
title: displayTitle,
|
|
159
|
+
defaultTitle: displayTitle,
|
|
160
|
+
titleKey,
|
|
161
|
+
href,
|
|
162
|
+
enabled,
|
|
163
|
+
order,
|
|
164
|
+
priority,
|
|
165
|
+
icon,
|
|
166
|
+
})
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
// Build hierarchy: treat routes whose href starts with a parent href + '/'
|
|
170
|
+
const byHref = new Map<string, AdminNavItem>()
|
|
171
|
+
for (const e of entries) byHref.set(e.href, e)
|
|
172
|
+
const roots: AdminNavItem[] = []
|
|
173
|
+
for (const e of entries) {
|
|
174
|
+
// Find the longest parent href that is a strict prefix and within same group
|
|
175
|
+
let parent: AdminNavItem | undefined
|
|
176
|
+
for (const p of entries) {
|
|
177
|
+
if (p === e) continue
|
|
178
|
+
if (p.groupId !== e.groupId) continue
|
|
179
|
+
if (!e.href.startsWith(p.href + '/')) continue
|
|
180
|
+
if (!parent || p.href.length > parent.href.length) parent = p
|
|
181
|
+
}
|
|
182
|
+
if (parent) {
|
|
183
|
+
parent.children = parent.children || []
|
|
184
|
+
parent.children.push(e)
|
|
185
|
+
} else {
|
|
186
|
+
roots.push(e)
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Add dynamic user entities to the navigation
|
|
191
|
+
if (userEntities && userEntities.length > 0) {
|
|
192
|
+
const tableIcon = React.createElement(
|
|
193
|
+
'svg',
|
|
194
|
+
{ width: 16, height: 16, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2 },
|
|
195
|
+
React.createElement('rect', { x: 3, y: 4, width: 18, height: 16, rx: 2 }),
|
|
196
|
+
React.createElement('path', { d: 'M3 10h18M9 4v16M15 4v16' }),
|
|
197
|
+
)
|
|
198
|
+
// Find the "User Entities" item in the Data designer group (it should be a root item)
|
|
199
|
+
const userEntitiesItem = roots.find(item => item.groupKey === 'entities.nav.group' && item.titleKey === 'entities.nav.userEntities')
|
|
200
|
+
if (userEntitiesItem) {
|
|
201
|
+
const existingChildren = userEntitiesItem.children || []
|
|
202
|
+
const dynamicUserEntities = userEntities.map((entity) => ({
|
|
203
|
+
group: userEntitiesItem.group,
|
|
204
|
+
groupId: userEntitiesItem.groupId,
|
|
205
|
+
groupKey: userEntitiesItem.groupKey,
|
|
206
|
+
groupDefaultName: userEntitiesItem.groupDefaultName,
|
|
207
|
+
title: entity.label,
|
|
208
|
+
defaultTitle: entity.label,
|
|
209
|
+
href: entity.href,
|
|
210
|
+
enabled: true,
|
|
211
|
+
order: 1000, // High order to appear at the end
|
|
212
|
+
priority: 1000,
|
|
213
|
+
icon: tableIcon,
|
|
214
|
+
}))
|
|
215
|
+
// Merge and deduplicate by href to avoid duplicates coming from server or generator
|
|
216
|
+
const merged = [...existingChildren, ...dynamicUserEntities]
|
|
217
|
+
const byHref = new Map<string, AdminNavItem>()
|
|
218
|
+
for (const it of merged) {
|
|
219
|
+
if (!byHref.has(it.href)) byHref.set(it.href, it)
|
|
220
|
+
}
|
|
221
|
+
userEntitiesItem.children = Array.from(byHref.values())
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Sorting: group, then priority/order, then title. Apply within children too.
|
|
226
|
+
const sortItems = (arr: AdminNavItem[]) => {
|
|
227
|
+
arr.sort((a, b) => {
|
|
228
|
+
if (a.groupId !== b.groupId) return a.groupId.localeCompare(b.groupId)
|
|
229
|
+
const ap = a.priority ?? a.order ?? 10_000
|
|
230
|
+
const bp = b.priority ?? b.order ?? 10_000
|
|
231
|
+
if (ap !== bp) return ap - bp
|
|
232
|
+
return a.title.localeCompare(b.title)
|
|
233
|
+
})
|
|
234
|
+
for (const it of arr) if (it.children?.length) sortItems(it.children)
|
|
235
|
+
}
|
|
236
|
+
sortItems(roots)
|
|
237
|
+
return roots
|
|
238
|
+
}
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
export type CrudServerFieldErrors = Record<string, string>
|
|
2
|
+
|
|
3
|
+
export type NormalizedCrudServerError = {
|
|
4
|
+
message?: string
|
|
5
|
+
fieldErrors?: CrudServerFieldErrors
|
|
6
|
+
details?: unknown
|
|
7
|
+
status?: number
|
|
8
|
+
raw?: string | null
|
|
9
|
+
[key: string]: unknown
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const JSON_FIELD_KEYS = ['fieldErrors', 'fields', 'errors', 'data'] as const
|
|
13
|
+
const ISSUE_KEYS = ['details', 'issues', 'errors'] as const
|
|
14
|
+
|
|
15
|
+
function coerceFieldErrors(input: unknown): CrudServerFieldErrors | null {
|
|
16
|
+
if (!input || typeof input !== 'object') return null
|
|
17
|
+
const result: CrudServerFieldErrors = {}
|
|
18
|
+
for (const [rawKey, rawValue] of Object.entries(input as Record<string, unknown>)) {
|
|
19
|
+
const key = typeof rawKey === 'string' && rawKey.trim().length > 0 ? rawKey.trim() : null
|
|
20
|
+
if (!key) continue
|
|
21
|
+
if (rawValue === undefined || rawValue === null) continue
|
|
22
|
+
const message =
|
|
23
|
+
typeof rawValue === 'string'
|
|
24
|
+
? rawValue
|
|
25
|
+
: typeof (rawValue as any)?.message === 'string'
|
|
26
|
+
? (rawValue as any).message
|
|
27
|
+
: String(rawValue)
|
|
28
|
+
if (!message) continue
|
|
29
|
+
result[key] = message
|
|
30
|
+
}
|
|
31
|
+
return Object.keys(result).length ? result : null
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function mapIssueArray(issues: unknown): CrudServerFieldErrors | null {
|
|
35
|
+
if (!Array.isArray(issues)) return null
|
|
36
|
+
const result: CrudServerFieldErrors = {}
|
|
37
|
+
for (const issue of issues) {
|
|
38
|
+
if (!issue || typeof issue !== 'object') continue
|
|
39
|
+
const pathValue: unknown[] = Array.isArray((issue as any).path) ? (issue as any).path : []
|
|
40
|
+
let field: string | null = null
|
|
41
|
+
for (const part of pathValue) {
|
|
42
|
+
if (typeof part === 'string' && part.trim().length > 0) {
|
|
43
|
+
field = part.trim()
|
|
44
|
+
break
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (!field && typeof (issue as any).field === 'string') {
|
|
48
|
+
const fromField = ((issue as any).field as string).trim()
|
|
49
|
+
field = fromField.length > 0 ? fromField : null
|
|
50
|
+
}
|
|
51
|
+
if (!field && pathValue.length > 0) {
|
|
52
|
+
const joined = pathValue.map((part) => String(part)).join('.')
|
|
53
|
+
if (joined) field = joined
|
|
54
|
+
}
|
|
55
|
+
if (!field) continue
|
|
56
|
+
const message = typeof (issue as any).message === 'string' ? (issue as any).message : null
|
|
57
|
+
if (!message) continue
|
|
58
|
+
result[field] = message
|
|
59
|
+
}
|
|
60
|
+
return Object.keys(result).length ? result : null
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function tryParseJson(text: string): unknown {
|
|
64
|
+
try {
|
|
65
|
+
return JSON.parse(text)
|
|
66
|
+
} catch {
|
|
67
|
+
return null
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function collectCandidatePayloads(err: unknown): unknown[] {
|
|
72
|
+
const candidates: unknown[] = []
|
|
73
|
+
if (!err) return candidates
|
|
74
|
+
candidates.push(err)
|
|
75
|
+
|
|
76
|
+
if (typeof err === 'string') {
|
|
77
|
+
const parsed = tryParseJson(err)
|
|
78
|
+
if (parsed) candidates.push(parsed)
|
|
79
|
+
} else if (err instanceof Error) {
|
|
80
|
+
if (typeof err.message === 'string' && err.message.trim()) {
|
|
81
|
+
const parsed = tryParseJson(err.message.trim())
|
|
82
|
+
if (parsed) candidates.push(parsed)
|
|
83
|
+
}
|
|
84
|
+
if ((err as any).cause) {
|
|
85
|
+
candidates.push((err as any).cause)
|
|
86
|
+
}
|
|
87
|
+
} else if (typeof err === 'object') {
|
|
88
|
+
const maybeMessage = (err as any)?.message
|
|
89
|
+
if (typeof maybeMessage === 'string') {
|
|
90
|
+
const parsed = tryParseJson(maybeMessage)
|
|
91
|
+
if (parsed) candidates.push(parsed)
|
|
92
|
+
}
|
|
93
|
+
if ((err as any)?.body) candidates.push((err as any).body)
|
|
94
|
+
if ((err as any)?.response) candidates.push((err as any).response)
|
|
95
|
+
if ((err as any)?.data) candidates.push((err as any).data)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return candidates
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function normalizeCrudServerError(err: unknown): NormalizedCrudServerError {
|
|
102
|
+
let message: string | undefined
|
|
103
|
+
let fieldErrors: CrudServerFieldErrors | undefined
|
|
104
|
+
const processed = new Set<unknown>()
|
|
105
|
+
|
|
106
|
+
const queue = collectCandidatePayloads(err)
|
|
107
|
+
while (queue.length) {
|
|
108
|
+
const current = queue.shift()
|
|
109
|
+
if (!current || processed.has(current)) continue
|
|
110
|
+
processed.add(current)
|
|
111
|
+
|
|
112
|
+
if (typeof current === 'string') {
|
|
113
|
+
if (!message) message = current
|
|
114
|
+
const parsed = tryParseJson(current)
|
|
115
|
+
if (parsed) queue.push(parsed)
|
|
116
|
+
continue
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (current instanceof Response) {
|
|
120
|
+
const body = (current as any)?._bodyInit
|
|
121
|
+
if (body) queue.push(body)
|
|
122
|
+
continue
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (typeof current !== 'object') continue
|
|
126
|
+
|
|
127
|
+
const candidateMessage =
|
|
128
|
+
typeof (current as any).error === 'string'
|
|
129
|
+
? (current as any).error
|
|
130
|
+
: typeof (current as any).message === 'string'
|
|
131
|
+
? (current as any).message
|
|
132
|
+
: undefined
|
|
133
|
+
if (candidateMessage && !message) message = candidateMessage
|
|
134
|
+
|
|
135
|
+
for (const key of JSON_FIELD_KEYS) {
|
|
136
|
+
const value = (current as any)[key]
|
|
137
|
+
if (value && typeof value === 'object') {
|
|
138
|
+
const mapped = coerceFieldErrors(value)
|
|
139
|
+
if (mapped) {
|
|
140
|
+
fieldErrors = { ...(fieldErrors || {}), ...mapped }
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
for (const key of ISSUE_KEYS) {
|
|
146
|
+
const value = (current as any)[key]
|
|
147
|
+
const mapped = mapIssueArray(value)
|
|
148
|
+
if (mapped) {
|
|
149
|
+
fieldErrors = { ...(fieldErrors || {}), ...mapped }
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const nestedKeys = ['body', 'response', 'data', 'details']
|
|
154
|
+
for (const nestedKey of nestedKeys) {
|
|
155
|
+
const nested = (current as any)[nestedKey]
|
|
156
|
+
if (nested && !processed.has(nested)) queue.push(nested)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (!message && fieldErrors && Object.keys(fieldErrors).length === 1) {
|
|
161
|
+
const [, firstMessage] = Object.entries(fieldErrors)[0]
|
|
162
|
+
message = firstMessage
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (!message && err instanceof Error && err.message) {
|
|
166
|
+
message = err.message
|
|
167
|
+
} else if (!message && typeof err === 'string') {
|
|
168
|
+
message = err
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return { message, fieldErrors }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export type FieldNameMapperOptions = {
|
|
175
|
+
customEntity?: boolean
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function mapServerFieldNameToFormId(field: string, options?: FieldNameMapperOptions): string {
|
|
179
|
+
const trimmed = field.trim()
|
|
180
|
+
const customEntity = !!options?.customEntity
|
|
181
|
+
if (customEntity) {
|
|
182
|
+
if (trimmed.startsWith('cf_')) return trimmed.slice(3)
|
|
183
|
+
if (trimmed.startsWith('cf:')) return trimmed.slice(3)
|
|
184
|
+
return trimmed
|
|
185
|
+
}
|
|
186
|
+
if (trimmed.startsWith('cf_')) return trimmed
|
|
187
|
+
if (trimmed.startsWith('cf:')) return `cf_${trimmed.slice(3)}`
|
|
188
|
+
return trimmed
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function mapCrudServerErrorToFormErrors(
|
|
192
|
+
err: unknown,
|
|
193
|
+
options?: FieldNameMapperOptions,
|
|
194
|
+
): { message?: string; fieldErrors?: CrudServerFieldErrors } {
|
|
195
|
+
const normalized = normalizeCrudServerError(err)
|
|
196
|
+
const fieldErrors = normalized.fieldErrors
|
|
197
|
+
if (!fieldErrors) return { message: normalized.message }
|
|
198
|
+
|
|
199
|
+
const mapped: CrudServerFieldErrors = {}
|
|
200
|
+
for (const [key, value] of Object.entries(fieldErrors)) {
|
|
201
|
+
const formId = mapServerFieldNameToFormId(key, options)
|
|
202
|
+
if (!formId) continue
|
|
203
|
+
mapped[formId] = value
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
let message = normalized.message
|
|
207
|
+
const firstEntry = Object.entries(mapped)[0]
|
|
208
|
+
if (
|
|
209
|
+
firstEntry &&
|
|
210
|
+
(!message || (typeof message === 'string' && message.trim().toLowerCase() === 'invalid input'))
|
|
211
|
+
) {
|
|
212
|
+
const [, fieldMessage] = firstEntry
|
|
213
|
+
if (typeof fieldMessage === 'string' && fieldMessage.trim().length) {
|
|
214
|
+
message = fieldMessage
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return {
|
|
219
|
+
message,
|
|
220
|
+
fieldErrors: mapped,
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export function parseServerMessage(input: string): string {
|
|
225
|
+
const trimmed = input.trim()
|
|
226
|
+
if (!trimmed) return trimmed
|
|
227
|
+
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
|
|
228
|
+
try {
|
|
229
|
+
const parsed = JSON.parse(trimmed) as Record<string, unknown>
|
|
230
|
+
const text =
|
|
231
|
+
typeof parsed?.error === 'string' && parsed.error.trim()
|
|
232
|
+
? parsed.error.trim()
|
|
233
|
+
: typeof parsed?.message === 'string' && parsed.message.trim()
|
|
234
|
+
? parsed.message.trim()
|
|
235
|
+
: null
|
|
236
|
+
if (text) return text
|
|
237
|
+
} catch {
|
|
238
|
+
// ignore JSON parse failure, fall through to trimmed string
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return trimmed
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export async function raiseCrudError(res: Response, fallbackMessage?: string): Promise<never> {
|
|
245
|
+
let raw: string | null = null
|
|
246
|
+
try {
|
|
247
|
+
raw = await res.text()
|
|
248
|
+
} catch {
|
|
249
|
+
raw = null
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const trimmed = raw && raw.trim() ? raw.trim() : null
|
|
253
|
+
const parsed = trimmed ? tryParseJson(trimmed) : null
|
|
254
|
+
|
|
255
|
+
if (parsed && typeof parsed === 'object') {
|
|
256
|
+
const data = parsed as Record<string, unknown>
|
|
257
|
+
const rawMessage =
|
|
258
|
+
typeof data.error === 'string' && data.error.trim()
|
|
259
|
+
? data.error.trim()
|
|
260
|
+
: typeof data.message === 'string' && data.message.trim()
|
|
261
|
+
? data.message.trim()
|
|
262
|
+
: fallbackMessage ?? `Request failed (${res.status})`
|
|
263
|
+
const message = parseServerMessage(rawMessage)
|
|
264
|
+
throw {
|
|
265
|
+
...data,
|
|
266
|
+
status: res.status,
|
|
267
|
+
message,
|
|
268
|
+
raw: trimmed ?? null,
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const message = parseServerMessage(fallbackMessage ?? `Request failed (${res.status})`)
|
|
273
|
+
throw { message, status: res.status, raw: trimmed ?? null }
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export type CrudFormError = Error & {
|
|
277
|
+
status?: number
|
|
278
|
+
fieldErrors?: CrudServerFieldErrors
|
|
279
|
+
details?: unknown
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export function createCrudFormError(
|
|
283
|
+
message: string,
|
|
284
|
+
fieldErrors?: CrudServerFieldErrors,
|
|
285
|
+
extras?: Partial<Pick<CrudFormError, 'status' | 'details'>>,
|
|
286
|
+
): CrudFormError {
|
|
287
|
+
const error = new Error(message) as CrudFormError
|
|
288
|
+
if (fieldErrors && Object.keys(fieldErrors).length) error.fieldErrors = fieldErrors
|
|
289
|
+
if (extras?.status !== undefined) error.status = extras.status
|
|
290
|
+
if (extras?.details !== undefined) error.details = extras.details
|
|
291
|
+
return error
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export async function readJsonSafe<T>(res: Response, fallback: T | null = null): Promise<T | null> {
|
|
295
|
+
try {
|
|
296
|
+
const text = await res.text()
|
|
297
|
+
if (!text) return fallback
|
|
298
|
+
return JSON.parse(text) as T
|
|
299
|
+
} catch {
|
|
300
|
+
return fallback
|
|
301
|
+
}
|
|
302
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
import Link from 'next/link'
|
|
3
|
+
import { usePathname } from 'next/navigation'
|
|
4
|
+
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
5
|
+
import { LanguageSwitcher } from './LanguageSwitcher'
|
|
6
|
+
|
|
7
|
+
export function AuthFooter() {
|
|
8
|
+
const pathname = usePathname()
|
|
9
|
+
const t = useT()
|
|
10
|
+
const shouldShow =
|
|
11
|
+
pathname === '/login' ||
|
|
12
|
+
(typeof pathname === 'string' && pathname.startsWith('/onboarding'))
|
|
13
|
+
if (!shouldShow) return null
|
|
14
|
+
return (
|
|
15
|
+
<footer className="w-full border-t bg-background/80 backdrop-blur supports-[backdrop-filter]:bg-background/50">
|
|
16
|
+
<div className="max-w-screen-lg mx-auto px-4 py-3 flex flex-wrap items-center justify-end gap-4">
|
|
17
|
+
<nav className="flex items-center gap-3 text-xs text-muted-foreground">
|
|
18
|
+
<Link href="/terms" className="transition hover:text-foreground">
|
|
19
|
+
{t('common.terms')}
|
|
20
|
+
</Link>
|
|
21
|
+
<Link href="/privacy" className="transition hover:text-foreground">
|
|
22
|
+
{t('common.privacy')}
|
|
23
|
+
</Link>
|
|
24
|
+
</nav>
|
|
25
|
+
<LanguageSwitcher />
|
|
26
|
+
</div>
|
|
27
|
+
</footer>
|
|
28
|
+
)
|
|
29
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
import { useId, useTransition } from 'react'
|
|
3
|
+
import { useLocale, useT } from '@open-mercato/shared/lib/i18n/context'
|
|
4
|
+
import { useRouter } from 'next/navigation'
|
|
5
|
+
import { locales, type Locale } from '@open-mercato/shared/lib/i18n/config'
|
|
6
|
+
|
|
7
|
+
export function LanguageSwitcher() {
|
|
8
|
+
const current = useLocale()
|
|
9
|
+
const t = useT()
|
|
10
|
+
const router = useRouter()
|
|
11
|
+
const [pending, startTransition] = useTransition()
|
|
12
|
+
const selectId = useId()
|
|
13
|
+
|
|
14
|
+
const languageLabels: Record<Locale, string> = {
|
|
15
|
+
en: t('common.languages.english', 'English'),
|
|
16
|
+
pl: t('common.languages.polish', 'Polski'),
|
|
17
|
+
es: t('common.languages.spanish', 'Español'),
|
|
18
|
+
de: t('common.languages.german', 'Deutsch'),
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function setLocale(locale: Locale) {
|
|
22
|
+
if (locale === current) return
|
|
23
|
+
try {
|
|
24
|
+
const res = await fetch('/api/auth/locale', {
|
|
25
|
+
method: 'POST',
|
|
26
|
+
headers: { 'content-type': 'application/json' },
|
|
27
|
+
body: JSON.stringify({ locale }),
|
|
28
|
+
})
|
|
29
|
+
if (!res.ok) return
|
|
30
|
+
startTransition(() => router.refresh())
|
|
31
|
+
try {
|
|
32
|
+
window.dispatchEvent(new Event('om:refresh-sidebar'))
|
|
33
|
+
} catch {
|
|
34
|
+
// Ignore if window is unavailable
|
|
35
|
+
}
|
|
36
|
+
} catch {
|
|
37
|
+
// Ignore network errors; UX fallback keeps previous locale
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
43
|
+
<label htmlFor={selectId}>{t('common.language')}</label>
|
|
44
|
+
<div className="relative">
|
|
45
|
+
<select
|
|
46
|
+
id={selectId}
|
|
47
|
+
className="appearance-none rounded-md border bg-background px-3 py-1 pr-8 text-xs focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1 disabled:opacity-60"
|
|
48
|
+
value={current}
|
|
49
|
+
onChange={(event) => setLocale(event.target.value as Locale)}
|
|
50
|
+
disabled={pending}
|
|
51
|
+
>
|
|
52
|
+
{locales.map((locale) => (
|
|
53
|
+
<option key={locale} value={locale}>
|
|
54
|
+
{languageLabels[locale]}
|
|
55
|
+
</option>
|
|
56
|
+
))}
|
|
57
|
+
</select>
|
|
58
|
+
<span className="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground">
|
|
59
|
+
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
60
|
+
<path d="M6 9l6 6 6-6" />
|
|
61
|
+
</svg>
|
|
62
|
+
</span>
|
|
63
|
+
</div>
|
|
64
|
+
</div>
|
|
65
|
+
)
|
|
66
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import { FlashMessages } from '../backend/FlashMessages'
|
|
3
|
+
|
|
4
|
+
export function FrontendLayout({ header, footer, children }: { header?: React.ReactNode; footer?: React.ReactNode; children: React.ReactNode }) {
|
|
5
|
+
return (
|
|
6
|
+
<div className="min-h-svh flex flex-col">
|
|
7
|
+
<FlashMessages />
|
|
8
|
+
{header ? <div className="border-b bg-background/60">{header}</div> : null}
|
|
9
|
+
<div className="flex-1 min-h-0">{children}</div>
|
|
10
|
+
{footer ? <div className="border-t bg-background/60">{footer}</div> : null}
|
|
11
|
+
</div>
|
|
12
|
+
)
|
|
13
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export * from './theme/ThemeProvider'
|
|
2
|
+
export * from './theme/ThemeToggle'
|
|
3
|
+
export * from './theme/QueryProvider'
|
|
4
|
+
export * from './backend/AppShell'
|
|
5
|
+
export * from './backend/Page'
|
|
6
|
+
export * from './backend/DataTable'
|
|
7
|
+
export * from './backend/FilterBar'
|
|
8
|
+
export * from './backend/ValueIcons'
|
|
9
|
+
export * from './backend/ConfirmDialog'
|
|
10
|
+
export * from './backend/UserMenu'
|
|
11
|
+
export * from './backend/RowActions'
|
|
12
|
+
export * from './backend/utils/nav'
|
|
13
|
+
export * from './backend/CrudForm'
|
|
14
|
+
export * from './backend/JsonBuilder'
|
|
15
|
+
export * from './backend/detail'
|
|
16
|
+
export * from './backend/schedule'
|
|
17
|
+
|
|
18
|
+
export * from './backend/inputs'
|
|
19
|
+
export * from './backend/ContextHelp'
|
|
20
|
+
export * from './backend/dashboard'
|
|
21
|
+
export * from './frontend/Layout'
|
|
22
|
+
export * from './frontend/AuthFooter'
|
|
23
|
+
export * from './frontend/LanguageSwitcher'
|
|
24
|
+
export * from './primitives/button'
|
|
25
|
+
export * from './primitives/label'
|
|
26
|
+
export * from './primitives/separator'
|
|
27
|
+
export * from './primitives/spinner'
|
|
28
|
+
export * from './primitives/tabs'
|
|
29
|
+
export * from './primitives/DataLoader'
|
|
30
|
+
export * from './primitives/table'
|
|
31
|
+
export * from './primitives/ErrorNotice'
|
|
32
|
+
export * from './primitives/dialog'
|