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.
Files changed (49) hide show
  1. package/README.md +94 -0
  2. package/bin/create-mercato-app +21 -0
  3. package/dist/index.js +177 -0
  4. package/package.json +42 -0
  5. package/template/.env.example +217 -0
  6. package/template/.yarnrc.yml.template +2 -0
  7. package/template/components.json +22 -0
  8. package/template/gitignore +50 -0
  9. package/template/next.config.ts +28 -0
  10. package/template/package.json.template +87 -0
  11. package/template/postcss.config.mjs +5 -0
  12. package/template/public/catch-the-tornado-logo.png +0 -0
  13. package/template/public/file.svg +1 -0
  14. package/template/public/globe.svg +1 -0
  15. package/template/public/next.svg +1 -0
  16. package/template/public/open-mercato.svg +50 -0
  17. package/template/public/vercel.svg +1 -0
  18. package/template/public/window.svg +1 -0
  19. package/template/src/app/(backend)/backend/[...slug]/page.tsx +59 -0
  20. package/template/src/app/(backend)/backend/layout.tsx +350 -0
  21. package/template/src/app/(backend)/backend/page.tsx +13 -0
  22. package/template/src/app/(frontend)/[...slug]/page.tsx +32 -0
  23. package/template/src/app/api/[...slug]/route.ts +227 -0
  24. package/template/src/app/api/docs/markdown/route.ts +35 -0
  25. package/template/src/app/api/docs/openapi/route.ts +30 -0
  26. package/template/src/app/globals.css +178 -0
  27. package/template/src/app/layout.tsx +76 -0
  28. package/template/src/app/page.tsx +134 -0
  29. package/template/src/bootstrap.ts +58 -0
  30. package/template/src/components/ClientBootstrap.tsx +37 -0
  31. package/template/src/components/GlobalNoticeBars.tsx +116 -0
  32. package/template/src/components/OrganizationSwitcher.tsx +360 -0
  33. package/template/src/components/StartPageContent.tsx +269 -0
  34. package/template/src/components/ui/button.tsx +59 -0
  35. package/template/src/components/ui/card.tsx +92 -0
  36. package/template/src/components/ui/checkbox.tsx +29 -0
  37. package/template/src/components/ui/input.tsx +21 -0
  38. package/template/src/components/ui/label.tsx +24 -0
  39. package/template/src/di.ts +11 -0
  40. package/template/src/i18n/de.json +375 -0
  41. package/template/src/i18n/en.json +376 -0
  42. package/template/src/i18n/es.json +376 -0
  43. package/template/src/i18n/pl.json +375 -0
  44. package/template/src/modules/.gitkeep +0 -0
  45. package/template/src/modules.ts +31 -0
  46. package/template/src/proxy.ts +17 -0
  47. package/template/tsconfig.json +54 -0
  48. package/template/types/pg/index.d.ts +1 -0
  49. 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
+ }