create-mercato-app 0.4.2-canary-e5804f7db1
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/README.md +94 -0
- package/bin/create-mercato-app +21 -0
- package/dist/index.js +177 -0
- package/package.json +42 -0
- package/template/.env.example +217 -0
- package/template/.yarnrc.yml.template +2 -0
- package/template/components.json +22 -0
- package/template/gitignore +50 -0
- package/template/next.config.ts +28 -0
- package/template/package.json.template +87 -0
- package/template/postcss.config.mjs +5 -0
- package/template/public/catch-the-tornado-logo.png +0 -0
- package/template/public/file.svg +1 -0
- package/template/public/globe.svg +1 -0
- package/template/public/next.svg +1 -0
- package/template/public/open-mercato.svg +50 -0
- package/template/public/vercel.svg +1 -0
- package/template/public/window.svg +1 -0
- package/template/src/app/(backend)/backend/[...slug]/page.tsx +59 -0
- package/template/src/app/(backend)/backend/layout.tsx +350 -0
- package/template/src/app/(backend)/backend/page.tsx +13 -0
- package/template/src/app/(frontend)/[...slug]/page.tsx +32 -0
- package/template/src/app/api/[...slug]/route.ts +227 -0
- package/template/src/app/api/docs/markdown/route.ts +35 -0
- package/template/src/app/api/docs/openapi/route.ts +30 -0
- package/template/src/app/globals.css +178 -0
- package/template/src/app/layout.tsx +76 -0
- package/template/src/app/page.tsx +134 -0
- package/template/src/bootstrap.ts +58 -0
- package/template/src/components/ClientBootstrap.tsx +37 -0
- package/template/src/components/GlobalNoticeBars.tsx +116 -0
- package/template/src/components/OrganizationSwitcher.tsx +360 -0
- package/template/src/components/StartPageContent.tsx +269 -0
- package/template/src/components/ui/button.tsx +59 -0
- package/template/src/components/ui/card.tsx +92 -0
- package/template/src/components/ui/checkbox.tsx +29 -0
- package/template/src/components/ui/input.tsx +21 -0
- package/template/src/components/ui/label.tsx +24 -0
- package/template/src/di.ts +11 -0
- package/template/src/i18n/de.json +375 -0
- package/template/src/i18n/en.json +376 -0
- package/template/src/i18n/es.json +376 -0
- package/template/src/i18n/pl.json +375 -0
- package/template/src/modules/.gitkeep +0 -0
- package/template/src/modules.ts +31 -0
- package/template/src/proxy.ts +17 -0
- package/template/tsconfig.json +54 -0
- package/template/types/pg/index.d.ts +1 -0
- package/template/types/react-big-calendar/index.d.ts +16 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* App-level bootstrap file
|
|
3
|
+
*
|
|
4
|
+
* This thin wrapper imports generated files and passes them to the
|
|
5
|
+
* shared bootstrap factory. The actual bootstrap logic lives in
|
|
6
|
+
* @open-mercato/shared/lib/bootstrap.
|
|
7
|
+
*
|
|
8
|
+
* This file is imported by layout.tsx and API routes to initialize
|
|
9
|
+
* the application before any package code executes.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
// Register app dictionary loader before bootstrap (required for i18n in standalone packages)
|
|
13
|
+
import { registerAppDictionaryLoader } from '@open-mercato/shared/lib/i18n/server'
|
|
14
|
+
import type { Locale } from '@open-mercato/shared/lib/i18n/config'
|
|
15
|
+
|
|
16
|
+
registerAppDictionaryLoader(async (locale: Locale): Promise<Record<string, unknown>> => {
|
|
17
|
+
switch (locale) {
|
|
18
|
+
case 'en':
|
|
19
|
+
return import('./i18n/en.json').then((m) => m.default)
|
|
20
|
+
case 'pl':
|
|
21
|
+
return import('./i18n/pl.json').then((m) => m.default)
|
|
22
|
+
case 'es':
|
|
23
|
+
return import('./i18n/es.json').then((m) => m.default)
|
|
24
|
+
case 'de':
|
|
25
|
+
return import('./i18n/de.json').then((m) => m.default)
|
|
26
|
+
default:
|
|
27
|
+
return import('./i18n/en.json').then((m) => m.default)
|
|
28
|
+
}
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
// Generated imports (static - works with bundlers)
|
|
32
|
+
import { modules } from '@/.mercato/generated/modules.generated'
|
|
33
|
+
import { entities } from '@/.mercato/generated/entities.generated'
|
|
34
|
+
import { diRegistrars } from '@/.mercato/generated/di.generated'
|
|
35
|
+
import { E } from '@/.mercato/generated/entities.ids.generated'
|
|
36
|
+
import { entityFieldsRegistry } from '@/.mercato/generated/entity-fields-registry'
|
|
37
|
+
import { dashboardWidgetEntries } from '@/.mercato/generated/dashboard-widgets.generated'
|
|
38
|
+
import { injectionWidgetEntries } from '@/.mercato/generated/injection-widgets.generated'
|
|
39
|
+
import { injectionTables } from '@/.mercato/generated/injection-tables.generated'
|
|
40
|
+
import { searchModuleConfigs } from '@/.mercato/generated/search.generated'
|
|
41
|
+
|
|
42
|
+
// Bootstrap factory from shared package
|
|
43
|
+
import { createBootstrap, isBootstrapped } from '@open-mercato/shared/lib/bootstrap'
|
|
44
|
+
|
|
45
|
+
// Create bootstrap function with app's generated data
|
|
46
|
+
export const bootstrap = createBootstrap({
|
|
47
|
+
modules,
|
|
48
|
+
entities,
|
|
49
|
+
diRegistrars,
|
|
50
|
+
entityIds: E,
|
|
51
|
+
entityFieldsRegistry,
|
|
52
|
+
dashboardWidgetEntries,
|
|
53
|
+
injectionWidgetEntries,
|
|
54
|
+
injectionTables,
|
|
55
|
+
searchModuleConfigs,
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
export { isBootstrapped }
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { injectionWidgetEntries } from '@/.mercato/generated/injection-widgets.generated'
|
|
5
|
+
import { injectionTables } from '@/.mercato/generated/injection-tables.generated'
|
|
6
|
+
import { registerCoreInjectionWidgets, registerCoreInjectionTables } from '@open-mercato/core/modules/widgets/lib/injection'
|
|
7
|
+
import { registerInjectionWidgets } from '@open-mercato/ui/backend/injection/widgetRegistry'
|
|
8
|
+
import { dashboardWidgetEntries } from '@/.mercato/generated/dashboard-widgets.generated'
|
|
9
|
+
import { registerDashboardWidgets } from '@open-mercato/ui/backend/dashboard/widgetRegistry'
|
|
10
|
+
|
|
11
|
+
let _clientBootstrapped = false
|
|
12
|
+
|
|
13
|
+
function clientBootstrap() {
|
|
14
|
+
if (_clientBootstrapped) return
|
|
15
|
+
_clientBootstrapped = true
|
|
16
|
+
|
|
17
|
+
// Register injection widgets
|
|
18
|
+
registerInjectionWidgets(injectionWidgetEntries)
|
|
19
|
+
registerCoreInjectionWidgets(injectionWidgetEntries)
|
|
20
|
+
registerCoreInjectionTables(injectionTables)
|
|
21
|
+
|
|
22
|
+
// Register dashboard widgets
|
|
23
|
+
registerDashboardWidgets(dashboardWidgetEntries)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function ClientBootstrapProvider({ children }: { children: React.ReactNode }) {
|
|
27
|
+
React.useEffect(() => {
|
|
28
|
+
clientBootstrap()
|
|
29
|
+
}, [])
|
|
30
|
+
|
|
31
|
+
// Also bootstrap synchronously on first render for SSR hydration
|
|
32
|
+
if (typeof window !== 'undefined' && !_clientBootstrapped) {
|
|
33
|
+
clientBootstrap()
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return <>{children}</>
|
|
37
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react'
|
|
4
|
+
import Link from 'next/link'
|
|
5
|
+
import { X } from 'lucide-react'
|
|
6
|
+
import { Button } from '@/components/ui/button'
|
|
7
|
+
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
8
|
+
|
|
9
|
+
const DEMO_NOTICE_COOKIE = 'om_demo_notice_ack'
|
|
10
|
+
const COOKIE_NOTICE_COOKIE = 'om_cookie_notice_ack'
|
|
11
|
+
|
|
12
|
+
function getCookie(name: string): string | null {
|
|
13
|
+
if (typeof document === 'undefined') return null
|
|
14
|
+
const match = document.cookie.split(';').map((entry) => entry.trim()).find((entry) => entry.startsWith(`${name}=`))
|
|
15
|
+
return match ? match.split('=').slice(1).join('=') : null
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function setCookie(name: string, value: string, days = 365) {
|
|
19
|
+
if (typeof document === 'undefined') return
|
|
20
|
+
const maxAge = days * 24 * 60 * 60
|
|
21
|
+
document.cookie = `${name}=${value}; path=/; max-age=${maxAge}; SameSite=Lax`
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function GlobalNoticeBars({ demoModeEnabled }: { demoModeEnabled: boolean }) {
|
|
25
|
+
const t = useT()
|
|
26
|
+
const [showDemoNotice, setShowDemoNotice] = useState(false)
|
|
27
|
+
const [showCookieNotice, setShowCookieNotice] = useState(false)
|
|
28
|
+
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
if (demoModeEnabled && !getCookie(DEMO_NOTICE_COOKIE)) {
|
|
31
|
+
setShowDemoNotice(true)
|
|
32
|
+
}
|
|
33
|
+
if (!getCookie(COOKIE_NOTICE_COOKIE)) {
|
|
34
|
+
setShowCookieNotice(true)
|
|
35
|
+
}
|
|
36
|
+
}, [demoModeEnabled])
|
|
37
|
+
|
|
38
|
+
const activeBars = [showDemoNotice, showCookieNotice].filter(Boolean).length
|
|
39
|
+
if (activeBars === 0) return null
|
|
40
|
+
|
|
41
|
+
const handleDismissDemo = () => {
|
|
42
|
+
setCookie(DEMO_NOTICE_COOKIE, 'ack')
|
|
43
|
+
setShowDemoNotice(false)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const handleDismissCookies = () => {
|
|
47
|
+
setCookie(COOKIE_NOTICE_COOKIE, 'dismissed')
|
|
48
|
+
setShowCookieNotice(false)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const handleAcceptCookies = () => {
|
|
52
|
+
setCookie(COOKIE_NOTICE_COOKIE, 'ack')
|
|
53
|
+
setShowCookieNotice(false)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<div className="pointer-events-none fixed inset-x-0 bottom-4 z-[70] flex flex-col items-center gap-3 px-4">
|
|
58
|
+
{showDemoNotice ? (
|
|
59
|
+
<div className="pointer-events-auto w-full max-w-4xl rounded-lg border border-amber-200 bg-amber-50/90 p-4 shadow-lg backdrop-blur supports-[backdrop-filter]:bg-amber-50/70 dark:border-amber-900/70 dark:bg-amber-950/40">
|
|
60
|
+
<div className="flex items-start gap-3">
|
|
61
|
+
<div className="flex-1 text-sm text-amber-900 dark:text-amber-50 space-y-1">
|
|
62
|
+
<p className="font-medium">{t('notices.demo.title', 'Demo Environment')}</p>
|
|
63
|
+
<p>
|
|
64
|
+
{t('notices.demo.description', 'This instance is provided for demo purposes only. Data may be reset at any time and is not retained for any guaranteed period.')}
|
|
65
|
+
</p>
|
|
66
|
+
<p>
|
|
67
|
+
{t('notices.demo.superadminQuestion', 'Need persistent superadmin access?')}{' '}
|
|
68
|
+
<a
|
|
69
|
+
href="https://github.com/open-mercato"
|
|
70
|
+
target="_blank"
|
|
71
|
+
rel="noreferrer"
|
|
72
|
+
className="underline font-medium hover:text-amber-800 dark:hover:text-amber-200"
|
|
73
|
+
>
|
|
74
|
+
{t('notices.demo.installLink', 'Install Open Mercato locally')}
|
|
75
|
+
</a>
|
|
76
|
+
. {t('notices.demo.reviewLinks', 'Review our')}{' '}
|
|
77
|
+
<Link className="underline font-medium hover:text-amber-800 dark:hover:text-amber-200" href="/terms">
|
|
78
|
+
{t('common.terms')}
|
|
79
|
+
</Link>{' '}
|
|
80
|
+
{t('notices.demo.and', 'and')}{' '}
|
|
81
|
+
<Link className="underline font-medium hover:text-amber-800 dark:hover:text-amber-200" href="/privacy">
|
|
82
|
+
{t('common.privacy')}
|
|
83
|
+
</Link>
|
|
84
|
+
.
|
|
85
|
+
</p>
|
|
86
|
+
</div>
|
|
87
|
+
<Button variant="ghost" size="icon" onClick={handleDismissDemo} className="shrink-0 text-amber-900 dark:text-amber-100">
|
|
88
|
+
<X className="size-4" />
|
|
89
|
+
</Button>
|
|
90
|
+
</div>
|
|
91
|
+
</div>
|
|
92
|
+
) : null}
|
|
93
|
+
|
|
94
|
+
{showCookieNotice ? (
|
|
95
|
+
<div className="pointer-events-auto w-full max-w-4xl rounded-lg border border-slate-300 bg-background/95 p-4 shadow-lg backdrop-blur supports-[backdrop-filter]:bg-background/80 dark:border-slate-700">
|
|
96
|
+
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
97
|
+
<div className="text-sm text-muted-foreground">
|
|
98
|
+
{t('notices.cookies.description', 'We use essential cookies to remember your preferences. Learn how we handle data in our')}{' '}
|
|
99
|
+
<Link className="underline font-medium hover:text-foreground" href="/privacy">
|
|
100
|
+
{t('common.privacy')}
|
|
101
|
+
</Link>.
|
|
102
|
+
</div>
|
|
103
|
+
<div className="flex items-center gap-2 self-end sm:self-auto">
|
|
104
|
+
<Button variant="ghost" size="sm" onClick={handleDismissCookies}>
|
|
105
|
+
{t('notices.cookies.dismiss', 'Dismiss')}
|
|
106
|
+
</Button>
|
|
107
|
+
<Button size="sm" onClick={handleAcceptCookies}>
|
|
108
|
+
{t('notices.cookies.accept', 'Accept cookies')}
|
|
109
|
+
</Button>
|
|
110
|
+
</div>
|
|
111
|
+
</div>
|
|
112
|
+
</div>
|
|
113
|
+
) : null}
|
|
114
|
+
</div>
|
|
115
|
+
)
|
|
116
|
+
}
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
import * as React from 'react'
|
|
3
|
+
import Link from 'next/link'
|
|
4
|
+
import { useRouter } from 'next/navigation'
|
|
5
|
+
import { apiCall } from '@open-mercato/ui/backend/utils/apiCall'
|
|
6
|
+
import { raiseCrudError } from '@open-mercato/ui/backend/utils/serverErrors'
|
|
7
|
+
import { emitOrganizationScopeChanged } from '@open-mercato/shared/lib/frontend/organizationEvents'
|
|
8
|
+
import { OrganizationSelect, type OrganizationTreeNode } from '@open-mercato/core/modules/directory/components/OrganizationSelect'
|
|
9
|
+
import { TenantSelect, type TenantRecord } from '@open-mercato/core/modules/directory/components/TenantSelect'
|
|
10
|
+
import { ALL_ORGANIZATIONS_COOKIE_VALUE } from '@open-mercato/core/modules/directory/constants'
|
|
11
|
+
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
12
|
+
|
|
13
|
+
type OrganizationMenuNode = {
|
|
14
|
+
id: string
|
|
15
|
+
name: string
|
|
16
|
+
depth: number
|
|
17
|
+
selectable: boolean
|
|
18
|
+
children: OrganizationMenuNode[]
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
type SwitcherState =
|
|
22
|
+
| { status: 'loading' }
|
|
23
|
+
| { status: 'error' }
|
|
24
|
+
| { status: 'hidden' }
|
|
25
|
+
| {
|
|
26
|
+
status: 'ready'
|
|
27
|
+
nodes: OrganizationMenuNode[]
|
|
28
|
+
selectedId: string | null
|
|
29
|
+
canManage: boolean
|
|
30
|
+
tenantId: string | null
|
|
31
|
+
tenants: TenantRecord[]
|
|
32
|
+
isSuperAdmin: boolean
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type SelectedCookieState = {
|
|
36
|
+
value: string
|
|
37
|
+
hasCookie: boolean
|
|
38
|
+
raw: string | null
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
type TenantCookieState = {
|
|
42
|
+
value: string
|
|
43
|
+
hasCookie: boolean
|
|
44
|
+
raw: string | null
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
type OrganizationSwitcherPayload = {
|
|
48
|
+
items?: unknown
|
|
49
|
+
selectedId?: string | null
|
|
50
|
+
canManage?: boolean
|
|
51
|
+
tenantId?: string | null
|
|
52
|
+
tenants?: unknown
|
|
53
|
+
isSuperAdmin?: boolean
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function readSelectedOrganizationCookie(): SelectedCookieState {
|
|
57
|
+
if (typeof document === 'undefined') return { value: '', hasCookie: false, raw: null }
|
|
58
|
+
const cookies = document.cookie.split(';')
|
|
59
|
+
for (const entry of cookies) {
|
|
60
|
+
const trimmed = entry.trim()
|
|
61
|
+
if (trimmed.startsWith('om_selected_org=')) {
|
|
62
|
+
const raw = trimmed.slice('om_selected_org='.length)
|
|
63
|
+
try {
|
|
64
|
+
const decoded = decodeURIComponent(raw)
|
|
65
|
+
if (!decoded) {
|
|
66
|
+
return { value: '', hasCookie: true, raw: '' }
|
|
67
|
+
}
|
|
68
|
+
if (decoded === ALL_ORGANIZATIONS_COOKIE_VALUE) {
|
|
69
|
+
return { value: '', hasCookie: true, raw: decoded }
|
|
70
|
+
}
|
|
71
|
+
return { value: decoded, hasCookie: true, raw: decoded }
|
|
72
|
+
} catch {
|
|
73
|
+
if (!raw) {
|
|
74
|
+
return { value: '', hasCookie: true, raw }
|
|
75
|
+
}
|
|
76
|
+
if (raw === ALL_ORGANIZATIONS_COOKIE_VALUE) {
|
|
77
|
+
return { value: '', hasCookie: true, raw }
|
|
78
|
+
}
|
|
79
|
+
return { value: raw, hasCookie: true, raw }
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return { value: '', hasCookie: false, raw: null }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function readSelectedTenantCookie(): TenantCookieState {
|
|
87
|
+
if (typeof document === 'undefined') return { value: '', hasCookie: false, raw: null }
|
|
88
|
+
const cookies = document.cookie.split(';')
|
|
89
|
+
for (const entry of cookies) {
|
|
90
|
+
const trimmed = entry.trim()
|
|
91
|
+
if (trimmed.startsWith('om_selected_tenant=')) {
|
|
92
|
+
const raw = trimmed.slice('om_selected_tenant='.length)
|
|
93
|
+
try {
|
|
94
|
+
const decoded = decodeURIComponent(raw)
|
|
95
|
+
return { value: decoded || '', hasCookie: true, raw: decoded }
|
|
96
|
+
} catch {
|
|
97
|
+
return { value: raw || '', hasCookie: true, raw }
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return { value: '', hasCookie: false, raw: null }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function findFirstSelectable(nodes: OrganizationMenuNode[] | undefined): string | null {
|
|
105
|
+
if (!Array.isArray(nodes)) return null
|
|
106
|
+
for (const node of nodes) {
|
|
107
|
+
if (!node) continue
|
|
108
|
+
if (node.selectable !== false && typeof node.id === 'string' && node.id) return node.id
|
|
109
|
+
const child = findFirstSelectable(node.children)
|
|
110
|
+
if (child) return child
|
|
111
|
+
}
|
|
112
|
+
return null
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export default function OrganizationSwitcher() {
|
|
116
|
+
const router = useRouter()
|
|
117
|
+
const t = useT()
|
|
118
|
+
const [state, setState] = React.useState<SwitcherState>({ status: 'loading' })
|
|
119
|
+
const [cookieState, setCookieState] = React.useState<SelectedCookieState>(() => readSelectedOrganizationCookie())
|
|
120
|
+
const [tenantCookieState, setTenantCookieState] = React.useState<TenantCookieState>(() => readSelectedTenantCookie())
|
|
121
|
+
const cookieStateRef = React.useRef(cookieState)
|
|
122
|
+
cookieStateRef.current = cookieState
|
|
123
|
+
const tenantCookieRef = React.useRef(tenantCookieState)
|
|
124
|
+
tenantCookieRef.current = tenantCookieState
|
|
125
|
+
const value = cookieState.value
|
|
126
|
+
const tenantValue = tenantCookieState.value
|
|
127
|
+
|
|
128
|
+
const persistTenant = React.useCallback((next: string | null, options?: { refresh?: boolean }) => {
|
|
129
|
+
if (typeof document === 'undefined') return
|
|
130
|
+
const resolved = next ?? ''
|
|
131
|
+
setTenantCookieState({ value: resolved, hasCookie: true, raw: resolved })
|
|
132
|
+
const maxAge = 60 * 60 * 24 * 30
|
|
133
|
+
try {
|
|
134
|
+
document.cookie = `om_selected_tenant=${encodeURIComponent(resolved)}; path=/; max-age=${maxAge}; samesite=lax`
|
|
135
|
+
} catch {
|
|
136
|
+
// ignore failures
|
|
137
|
+
}
|
|
138
|
+
if (options?.refresh !== false) {
|
|
139
|
+
try { router.refresh() } catch {}
|
|
140
|
+
}
|
|
141
|
+
}, [router])
|
|
142
|
+
|
|
143
|
+
const persistSelection = React.useCallback((tenantId: string | null, next: string | null, options?: { refresh?: boolean }) => {
|
|
144
|
+
const resolved = next ?? ''
|
|
145
|
+
const cookieValue = next ?? ALL_ORGANIZATIONS_COOKIE_VALUE
|
|
146
|
+
setCookieState({ value: resolved, hasCookie: true, raw: cookieValue })
|
|
147
|
+
const maxAge = 60 * 60 * 24 * 30 // 30 days
|
|
148
|
+
if (typeof document !== 'undefined') {
|
|
149
|
+
document.cookie = `om_selected_org=${encodeURIComponent(cookieValue)}; path=/; max-age=${maxAge}; samesite=lax`
|
|
150
|
+
}
|
|
151
|
+
if (tenantId !== undefined) {
|
|
152
|
+
persistTenant(tenantId ?? null, { refresh: false })
|
|
153
|
+
}
|
|
154
|
+
emitOrganizationScopeChanged({ organizationId: resolved || null, tenantId: tenantId ?? null })
|
|
155
|
+
if (options?.refresh !== false) {
|
|
156
|
+
try { router.refresh() } catch {}
|
|
157
|
+
}
|
|
158
|
+
}, [persistTenant, router])
|
|
159
|
+
|
|
160
|
+
const handleChange = React.useCallback((next: string | null) => {
|
|
161
|
+
const tenantId = state.status === 'ready' ? state.tenantId ?? null : tenantValue || null
|
|
162
|
+
persistSelection(tenantId, next, { refresh: true })
|
|
163
|
+
}, [persistSelection, state, tenantValue])
|
|
164
|
+
|
|
165
|
+
type LoadOptions = { tenantId?: string | null; abortRef?: { current: boolean }; refreshAfter?: boolean }
|
|
166
|
+
|
|
167
|
+
const load = React.useCallback(async (options?: LoadOptions) => {
|
|
168
|
+
const abortRef = options?.abortRef
|
|
169
|
+
const targetTenant = typeof options?.tenantId === 'string' && options.tenantId.trim().length > 0 ? options.tenantId.trim() : null
|
|
170
|
+
setState({ status: 'loading' })
|
|
171
|
+
try {
|
|
172
|
+
const params = new URLSearchParams()
|
|
173
|
+
if (targetTenant) params.set('tenantId', targetTenant)
|
|
174
|
+
const search = params.toString()
|
|
175
|
+
const url = `/api/directory/organization-switcher${search ? `?${search}` : ''}`
|
|
176
|
+
const call = await apiCall<OrganizationSwitcherPayload>(url)
|
|
177
|
+
if (abortRef?.current) return
|
|
178
|
+
if (call.status === 401 || call.status === 403) {
|
|
179
|
+
setState({ status: 'hidden' })
|
|
180
|
+
return
|
|
181
|
+
}
|
|
182
|
+
if (!call.ok) {
|
|
183
|
+
await raiseCrudError(call.response, t('organizationSwitcher.error', 'Failed to load'))
|
|
184
|
+
}
|
|
185
|
+
const json = (call.result ?? {}) as OrganizationSwitcherPayload
|
|
186
|
+
if (abortRef?.current) return
|
|
187
|
+
const rawItems = Array.isArray(json.items) ? json.items : []
|
|
188
|
+
const selected = typeof json.selectedId === 'string' ? json.selectedId : null
|
|
189
|
+
const manage = Boolean(json.canManage)
|
|
190
|
+
const resolvedTenantId = typeof json.tenantId === 'string' && json.tenantId.trim().length > 0 ? json.tenantId.trim() : null
|
|
191
|
+
const tenantList = Array.isArray(json.tenants)
|
|
192
|
+
? (json.tenants as unknown[]).map((entry) => {
|
|
193
|
+
if (!entry || typeof entry !== 'object') return null
|
|
194
|
+
const record = entry as Record<string, unknown>
|
|
195
|
+
const id = typeof record.id === 'string' ? record.id : null
|
|
196
|
+
if (!id) return null
|
|
197
|
+
const name = typeof record.name === 'string' && record.name.length > 0 ? record.name : id
|
|
198
|
+
const isActive = record.isActive !== false
|
|
199
|
+
return { id, name, isActive }
|
|
200
|
+
}).filter((tenant): tenant is TenantRecord => tenant !== null)
|
|
201
|
+
: []
|
|
202
|
+
const cookieInfo = cookieStateRef.current
|
|
203
|
+
const shouldFallbackToFirst =
|
|
204
|
+
!selected
|
|
205
|
+
&& (
|
|
206
|
+
!cookieInfo.hasCookie
|
|
207
|
+
|| (cookieInfo.raw !== null && cookieInfo.raw !== ALL_ORGANIZATIONS_COOKIE_VALUE)
|
|
208
|
+
)
|
|
209
|
+
const fallbackSelected = selected ?? (shouldFallbackToFirst ? findFirstSelectable(rawItems) : null)
|
|
210
|
+
const isSuperAdmin = Boolean(json.isSuperAdmin)
|
|
211
|
+
if (!rawItems.length && !manage && !isSuperAdmin && tenantList.length === 0) {
|
|
212
|
+
setState({ status: 'hidden' })
|
|
213
|
+
if (fallbackSelected) {
|
|
214
|
+
persistSelection(resolvedTenantId, fallbackSelected, { refresh: false })
|
|
215
|
+
}
|
|
216
|
+
if (options?.refreshAfter) {
|
|
217
|
+
try { router.refresh() } catch {}
|
|
218
|
+
}
|
|
219
|
+
return
|
|
220
|
+
}
|
|
221
|
+
setState({
|
|
222
|
+
status: 'ready',
|
|
223
|
+
nodes: rawItems as OrganizationMenuNode[],
|
|
224
|
+
selectedId: fallbackSelected,
|
|
225
|
+
canManage: manage,
|
|
226
|
+
tenantId: resolvedTenantId,
|
|
227
|
+
tenants: tenantList,
|
|
228
|
+
isSuperAdmin,
|
|
229
|
+
})
|
|
230
|
+
const currentTenantCookie = tenantCookieRef.current
|
|
231
|
+
if (resolvedTenantId !== null) {
|
|
232
|
+
if (!currentTenantCookie.hasCookie || currentTenantCookie.value !== resolvedTenantId) {
|
|
233
|
+
persistTenant(resolvedTenantId, { refresh: false })
|
|
234
|
+
}
|
|
235
|
+
} else if (currentTenantCookie.hasCookie && currentTenantCookie.value !== '') {
|
|
236
|
+
setTenantCookieState({ value: '', hasCookie: true, raw: '' })
|
|
237
|
+
}
|
|
238
|
+
const currentCookie = cookieStateRef.current
|
|
239
|
+
if (fallbackSelected) {
|
|
240
|
+
const tenantMatches = currentTenantCookie.hasCookie ? currentTenantCookie.value === (resolvedTenantId ?? '') : false
|
|
241
|
+
if (
|
|
242
|
+
!currentCookie.hasCookie ||
|
|
243
|
+
currentCookie.value !== fallbackSelected ||
|
|
244
|
+
currentCookie.raw !== fallbackSelected ||
|
|
245
|
+
!tenantMatches
|
|
246
|
+
) {
|
|
247
|
+
persistSelection(resolvedTenantId, fallbackSelected, { refresh: false })
|
|
248
|
+
} else {
|
|
249
|
+
emitOrganizationScopeChanged({ organizationId: fallbackSelected, tenantId: resolvedTenantId ?? null })
|
|
250
|
+
}
|
|
251
|
+
} else {
|
|
252
|
+
if (
|
|
253
|
+
!currentCookie.hasCookie ||
|
|
254
|
+
currentCookie.raw !== ALL_ORGANIZATIONS_COOKIE_VALUE ||
|
|
255
|
+
currentCookie.value !== '' ||
|
|
256
|
+
(resolvedTenantId !== null && currentTenantCookie.value !== resolvedTenantId)
|
|
257
|
+
) {
|
|
258
|
+
persistSelection(resolvedTenantId, null, { refresh: false })
|
|
259
|
+
} else {
|
|
260
|
+
emitOrganizationScopeChanged({ organizationId: null, tenantId: resolvedTenantId ?? null })
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
if (options?.refreshAfter) {
|
|
264
|
+
try { router.refresh() } catch {}
|
|
265
|
+
}
|
|
266
|
+
} catch {
|
|
267
|
+
if (abortRef?.current) return
|
|
268
|
+
setState({ status: 'error' })
|
|
269
|
+
}
|
|
270
|
+
}, [persistSelection, persistTenant, router, t])
|
|
271
|
+
|
|
272
|
+
const handleTenantChange = React.useCallback((nextTenantId: string | null) => {
|
|
273
|
+
const normalized = typeof nextTenantId === 'string' && nextTenantId.trim().length > 0 ? nextTenantId.trim() : null
|
|
274
|
+
const currentTenant = state.status === 'ready' ? state.tenantId : (tenantValue || null)
|
|
275
|
+
if ((currentTenant ?? null) === (normalized ?? null)) return
|
|
276
|
+
persistTenant(normalized, { refresh: false })
|
|
277
|
+
load({ tenantId: normalized ?? undefined, refreshAfter: true })
|
|
278
|
+
}, [load, persistTenant, state, tenantValue])
|
|
279
|
+
|
|
280
|
+
React.useEffect(() => {
|
|
281
|
+
const abortRef = { current: false }
|
|
282
|
+
load({ abortRef })
|
|
283
|
+
return () => { abortRef.current = true }
|
|
284
|
+
}, [load])
|
|
285
|
+
|
|
286
|
+
const nodes = React.useMemo<OrganizationTreeNode[]>(() => {
|
|
287
|
+
if (state.status !== 'ready') return []
|
|
288
|
+
const items = state.nodes
|
|
289
|
+
const map = (node: OrganizationMenuNode, parents: string[]): OrganizationTreeNode => {
|
|
290
|
+
const nextPath = [...parents, node.name]
|
|
291
|
+
return {
|
|
292
|
+
id: node.id,
|
|
293
|
+
name: node.name,
|
|
294
|
+
depth: node.depth,
|
|
295
|
+
pathLabel: nextPath.join(' / '),
|
|
296
|
+
selectable: node.selectable,
|
|
297
|
+
children: node.children.map((child) => map(child, nextPath)),
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
return items.map((node) => map(node, []))
|
|
301
|
+
}, [state])
|
|
302
|
+
|
|
303
|
+
const hasOptions = nodes.length > 0 && state.status === 'ready'
|
|
304
|
+
const canManage = state.status === 'ready' && state.canManage
|
|
305
|
+
const tenantSelectOptions = state.status === 'ready' ? state.tenants : []
|
|
306
|
+
const tenantSelectValue = state.status === 'ready'
|
|
307
|
+
? state.tenantId ?? ''
|
|
308
|
+
: tenantValue
|
|
309
|
+
const showTenantSelect = state.status === 'ready' && state.isSuperAdmin && tenantSelectOptions.length > 0
|
|
310
|
+
|
|
311
|
+
if (state.status === 'hidden') {
|
|
312
|
+
return null
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return (
|
|
316
|
+
<div className="flex flex-wrap items-center gap-2 text-sm">
|
|
317
|
+
{showTenantSelect ? (
|
|
318
|
+
<>
|
|
319
|
+
<label className="hidden text-xs text-muted-foreground sm:inline" htmlFor="tenant-switcher">
|
|
320
|
+
{t('organizationSwitcher.tenantLabel', 'Tenant')}
|
|
321
|
+
</label>
|
|
322
|
+
<TenantSelect
|
|
323
|
+
id="tenant-switcher"
|
|
324
|
+
value={tenantSelectValue}
|
|
325
|
+
onChange={handleTenantChange}
|
|
326
|
+
tenants={tenantSelectOptions}
|
|
327
|
+
fetchOnMount={false}
|
|
328
|
+
includeEmptyOption={false}
|
|
329
|
+
className="h-9 rounded border px-2 text-sm"
|
|
330
|
+
aria-label={t('organizationSwitcher.tenantLabel', 'Tenant')}
|
|
331
|
+
/>
|
|
332
|
+
</>
|
|
333
|
+
) : null}
|
|
334
|
+
<label className="hidden text-xs text-muted-foreground sm:inline" htmlFor="org-switcher">{t('organizationSwitcher.label')}</label>
|
|
335
|
+
{state.status === 'loading' ? (
|
|
336
|
+
<span className="text-xs text-muted-foreground">{t('organizationSwitcher.loading')}</span>
|
|
337
|
+
) : state.status === 'error' ? (
|
|
338
|
+
<span className="text-xs text-destructive">{t('organizationSwitcher.error')}</span>
|
|
339
|
+
) : hasOptions ? (
|
|
340
|
+
<OrganizationSelect
|
|
341
|
+
id="org-switcher"
|
|
342
|
+
value={value || null}
|
|
343
|
+
onChange={handleChange}
|
|
344
|
+
nodes={nodes}
|
|
345
|
+
fetchOnMount={false}
|
|
346
|
+
includeAllOption
|
|
347
|
+
aria-label={t('organizationSwitcher.label')}
|
|
348
|
+
className="h-9 rounded border px-2 text-sm"
|
|
349
|
+
/>
|
|
350
|
+
) : (
|
|
351
|
+
<span className="text-xs text-muted-foreground">{t('organizationSwitcher.empty')}</span>
|
|
352
|
+
)}
|
|
353
|
+
{canManage ? (
|
|
354
|
+
<Link href="/backend/directory/organizations" className="text-xs text-muted-foreground hover:text-foreground">
|
|
355
|
+
{t('organizationSwitcher.manage')}
|
|
356
|
+
</Link>
|
|
357
|
+
) : null}
|
|
358
|
+
</div>
|
|
359
|
+
)
|
|
360
|
+
}
|