@fayz-ai/saas 0.5.0 → 0.6.6
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/dist/app/AdminShell.d.ts +14 -1
- package/dist/app/AdminShell.d.ts.map +1 -1
- package/dist/app/LoginPage.d.ts +2 -16
- package/dist/app/LoginPage.d.ts.map +1 -1
- package/dist/app/admin-app.d.ts +21 -0
- package/dist/app/admin-app.d.ts.map +1 -0
- package/dist/app/config.d.ts +40 -3
- package/dist/app/config.d.ts.map +1 -1
- package/dist/app/scaffold.d.ts +1 -2
- package/dist/app/scaffold.d.ts.map +1 -1
- package/dist/{chunk-DJW67TCY.js → chunk-DCRT47W4.js} +155 -28
- package/dist/chunk-DCRT47W4.js.map +1 -0
- package/dist/{chunk-QGMDVLLW.cjs → chunk-SRIUD35R.cjs} +155 -28
- package/dist/chunk-SRIUD35R.cjs.map +1 -0
- package/dist/crud/CrudDetailPage.d.ts.map +1 -1
- package/dist/index.cjs +1127 -5792
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +4 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +839 -5509
- package/dist/index.js.map +1 -1
- package/dist/org/adapters/supabase.d.ts +18 -0
- package/dist/org/adapters/supabase.d.ts.map +1 -1
- package/dist/org/context.d.ts.map +1 -1
- package/dist/org/index.cjs +11 -10
- package/dist/org/index.js +2 -1
- package/dist/shell/components/crud/archetypes/ClientOrdersTab.d.ts +4 -2
- package/dist/shell/components/crud/archetypes/ClientOrdersTab.d.ts.map +1 -1
- package/dist/shell/components/layout/BottomNav.d.ts.map +1 -1
- package/dist/shell/components/layout/CommandPalette.d.ts.map +1 -1
- package/dist/shell/components/organization/InviteMemberDialog.d.ts.map +1 -1
- package/dist/shell/components/organization/PermissionMatrixEditor.d.ts +6 -1
- package/dist/shell/components/organization/PermissionMatrixEditor.d.ts.map +1 -1
- package/dist/shell/components/organization/PermissionProfilesTab.d.ts.map +1 -1
- package/dist/shell/components/organization/TeamTab.d.ts.map +1 -1
- package/dist/shell/components/plugins/PluginSettingsPanel.d.ts.map +1 -1
- package/dist/shell/components/settings/ConnectedUserProfile.d.ts +2 -1
- package/dist/shell/components/settings/ConnectedUserProfile.d.ts.map +1 -1
- package/dist/shell/components/settings/SettingsPage.d.ts +8 -1
- package/dist/shell/components/settings/SettingsPage.d.ts.map +1 -1
- package/dist/shell/config/theme/tokens.d.ts +12 -1
- package/dist/shell/config/theme/tokens.d.ts.map +1 -1
- package/dist/shell/config/theme/utils.d.ts +8 -0
- package/dist/shell/config/theme/utils.d.ts.map +1 -1
- package/dist/shell/lib/i18n.d.ts.map +1 -1
- package/dist/shell/types/client-orders.d.ts +4 -0
- package/dist/shell/types/client-orders.d.ts.map +1 -1
- package/dist/shell/types/crud.d.ts +4 -0
- package/dist/shell/types/crud.d.ts.map +1 -1
- package/package.json +7 -9
- package/src/app/AdminShell.tsx +103 -35
- package/src/app/LoginPage.tsx +2 -264
- package/src/app/{createFayzApp.tsx → admin-app.tsx} +98 -40
- package/src/app/config.ts +44 -2
- package/src/app/scaffold.tsx +13 -26
- package/src/crud/CrudDetailPage.tsx +43 -9
- package/src/crud/CrudPage.tsx +1 -1
- package/src/index.ts +12 -5
- package/src/org/adapters/supabase.ts +176 -28
- package/src/org/context.tsx +33 -2
- package/src/shell/components/crud/archetypes/ClientOrdersTab.tsx +12 -4
- package/src/shell/components/layout/BottomNav.tsx +39 -4
- package/src/shell/components/layout/CommandPalette.tsx +14 -10
- package/src/shell/components/organization/InviteMemberDialog.tsx +14 -3
- package/src/shell/components/organization/PermissionMatrixEditor.tsx +48 -10
- package/src/shell/components/organization/PermissionProfilesTab.tsx +65 -23
- package/src/shell/components/organization/TeamTab.tsx +36 -9
- package/src/shell/components/plugins/PluginSettingsPanel.tsx +5 -3
- package/src/shell/components/settings/ConnectedUserProfile.tsx +68 -25
- package/src/shell/components/settings/SettingsPage.tsx +12 -3
- package/src/shell/config/theme/tokens.ts +13 -1
- package/src/shell/config/theme/utils.ts +76 -15
- package/src/shell/lib/i18n.ts +4 -0
- package/src/shell/types/client-orders.ts +5 -0
- package/src/shell/types/crud.ts +4 -0
- package/dist/app/createFayzApp.d.ts +0 -11
- package/dist/app/createFayzApp.d.ts.map +0 -1
- package/dist/chunk-DJW67TCY.js.map +0 -1
- package/dist/chunk-QGMDVLLW.cjs.map +0 -1
- package/dist/shell/components/organization/ImpersonationBanner.d.ts +0 -3
- package/dist/shell/components/organization/ImpersonationBanner.d.ts.map +0 -1
- package/dist/shell/components/settings/ConnectedHolidaysSettings.d.ts +0 -3
- package/dist/shell/components/settings/ConnectedHolidaysSettings.d.ts.map +0 -1
- package/dist/shell/components/settings/ConnectedLocationsOverview.d.ts +0 -3
- package/dist/shell/components/settings/ConnectedLocationsOverview.d.ts.map +0 -1
- package/dist/shell/createSaasApp.d.ts +0 -145
- package/dist/shell/createSaasApp.d.ts.map +0 -1
- package/src/shell/components/organization/ImpersonationBanner.tsx +0 -29
- package/src/shell/components/settings/ConnectedHolidaysSettings.tsx +0 -99
- package/src/shell/components/settings/ConnectedLocationsOverview.tsx +0 -57
- package/src/shell/createSaasApp.tsx +0 -943
|
@@ -1,943 +0,0 @@
|
|
|
1
|
-
import React from 'react'
|
|
2
|
-
import { useThemeStore } from './stores/theme.store'
|
|
3
|
-
import { useBillingStore } from './stores/billing.store'
|
|
4
|
-
import { useAuthStore } from './stores/auth.store'
|
|
5
|
-
import { usePermissionsStore } from './stores/permissions.store'
|
|
6
|
-
import { createSupabaseClient, getSupabaseClientOptional } from './lib/supabase'
|
|
7
|
-
import { setEntityRouteMap, resolveEntityHref } from './lib/entity-routes'
|
|
8
|
-
import { ToastProvider } from './components/notifications/ToastProvider'
|
|
9
|
-
import { RouterProvider, setGlobalRouter, hashRouterAdapter, type RouterAdapter } from './lib/router'
|
|
10
|
-
import { AppShell } from '@fayz-ai/ui'
|
|
11
|
-
import type { NavigationItem as UiNavigationItem } from '@fayz-ai/ui'
|
|
12
|
-
import { MockModeBanner } from './components/layout/MockModeBanner'
|
|
13
|
-
import { WidgetSlot } from '../plugins/WidgetSlot'
|
|
14
|
-
import { SettingsPage } from './components/settings/SettingsPage'
|
|
15
|
-
import { BillingPage } from './components/billing/BillingPage'
|
|
16
|
-
import { ChatFab } from './components/chat/ChatFab'
|
|
17
|
-
import { ChatPanel } from './components/chat/ChatPanel'
|
|
18
|
-
import { CommandPalette, type CommandItem, type EntitySearchResult, type EntitySearchFn } from './components/layout/CommandPalette'
|
|
19
|
-
import { useLayoutStore } from './stores/layout.store'
|
|
20
|
-
import { LoginPage } from './components/auth/LoginPage'
|
|
21
|
-
import { ProtectedRoute } from './components/auth/ProtectedRoute'
|
|
22
|
-
import { AuthAdapterProvider } from './lib/auth-context'
|
|
23
|
-
import { OrgAdapterProvider } from './lib/org-context'
|
|
24
|
-
import { createMockAuthAdapter } from './lib/auth-adapters/mock'
|
|
25
|
-
import { createSupabaseAuthAdapter } from './lib/auth-adapters/supabase'
|
|
26
|
-
import { createMockOrgAdapter } from './lib/org-adapters/mock'
|
|
27
|
-
import { createSupabaseOrgAdapter } from './lib/org-adapters/supabase'
|
|
28
|
-
import { OrgSwitcher } from './components/organization/OrgSwitcher'
|
|
29
|
-
import { OrgInitializer } from './components/organization/OrgInitializer'
|
|
30
|
-
import { PermissionGate } from '../permissions/PermissionGate'
|
|
31
|
-
import { ImpersonationBanner } from './components/organization/ImpersonationBanner'
|
|
32
|
-
import { TeamTab } from './components/organization/TeamTab'
|
|
33
|
-
import { PermissionProfilesTab } from './components/organization/PermissionProfilesTab'
|
|
34
|
-
import { ConnectedLocationsOverview } from './components/settings/ConnectedLocationsOverview'
|
|
35
|
-
import { LocationsCrudPage } from './components/settings/LocationsCrudPage'
|
|
36
|
-
import { LocationSwitchOverlay } from './components/layout/LocationSwitchOverlay'
|
|
37
|
-
import { ConnectedHolidaysSettings } from './components/settings/ConnectedHolidaysSettings'
|
|
38
|
-
import { ConnectedFieldRulesSettings } from './components/settings/ConnectedFieldRulesSettings'
|
|
39
|
-
import { Users, ShieldCheck, MapPin, CalendarOff, SlidersHorizontal, Puzzle } from 'lucide-react'
|
|
40
|
-
import * as LucideIcons from 'lucide-react'
|
|
41
|
-
import { resolvePluginRuntime, PluginRuntimeProvider } from './lib/plugins'
|
|
42
|
-
import type { AuthAdapter } from './types/auth-adapter'
|
|
43
|
-
import type { OrgAdapter } from './types/org-adapter'
|
|
44
|
-
import type { NavigationItem } from './types/layout'
|
|
45
|
-
import type { Plan, PlanConfig } from './types/billing'
|
|
46
|
-
import { normalizePlanConfig } from './types/billing'
|
|
47
|
-
import type { PluginManifest, TenantPluginBinding } from '@fayz-ai/core'
|
|
48
|
-
import type {
|
|
49
|
-
PluginPermissionRequirement,
|
|
50
|
-
ResolvedPluginManifest,
|
|
51
|
-
PluginSettingsTab,
|
|
52
|
-
} from './types/plugins'
|
|
53
|
-
import type { CreateThemeOptions } from './config/theme/utils'
|
|
54
|
-
import { resolveTheme } from './config/theme/utils'
|
|
55
|
-
import type { SaasTheme } from './config/theme/tokens'
|
|
56
|
-
import type { AuthProvider } from './types/auth'
|
|
57
|
-
import type { PermissionsConfig, PermissionAction } from './types/permissions'
|
|
58
|
-
import { useOrganizationStore } from './stores/organization.store'
|
|
59
|
-
import { usePermission } from './hooks/usePermission'
|
|
60
|
-
import { useTenantPlugins } from './hooks/useTenantPlugins'
|
|
61
|
-
import { I18nProvider, builtInLocales } from './lib/i18n'
|
|
62
|
-
import { useLocaleStore } from './stores/locale.store'
|
|
63
|
-
import { setDefaultCurrency, setCurrentLocale, registerTranslations, recordNavigation } from '@fayz-ai/core'
|
|
64
|
-
import { useTranslation } from './hooks/useTranslation'
|
|
65
|
-
|
|
66
|
-
// ---------------------------------------------------------------------------
|
|
67
|
-
// Page config — unifies navigation + routing in one declaration
|
|
68
|
-
// ---------------------------------------------------------------------------
|
|
69
|
-
export interface PageConfig {
|
|
70
|
-
/** URL path, e.g. '/' or '/clients' */
|
|
71
|
-
path: string
|
|
72
|
-
/** Sidebar/nav label */
|
|
73
|
-
label: string
|
|
74
|
-
/** Lucide icon name */
|
|
75
|
-
icon: string
|
|
76
|
-
/** The page component to render */
|
|
77
|
-
component: React.ComponentType
|
|
78
|
-
/** Navigation section (default: 'main') */
|
|
79
|
-
section?: 'main' | 'secondary' | 'settings'
|
|
80
|
-
/** Sort order within section (auto-incremented if omitted) */
|
|
81
|
-
position?: number
|
|
82
|
-
/** Optional badge */
|
|
83
|
-
badge?: string | number
|
|
84
|
-
/** Permission required to access this page */
|
|
85
|
-
permission?: { feature: string; action: PermissionAction }
|
|
86
|
-
/** Child pages — renders as a dropdown in topbar, collapsible in sidebar.
|
|
87
|
-
* Children can omit `component` to be navigation-only links (pointing to routes the parent handles). */
|
|
88
|
-
children?: Array<Omit<PageConfig, 'component'> & { component?: React.ComponentType }>
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
// ---------------------------------------------------------------------------
|
|
92
|
-
// Config
|
|
93
|
-
// ---------------------------------------------------------------------------
|
|
94
|
-
export interface SaasAppConfig {
|
|
95
|
-
/** App display name (used in sidebar header) */
|
|
96
|
-
name: string
|
|
97
|
-
/** Logo — string renders as initial badge, ReactNode renders as-is */
|
|
98
|
-
logo?: string | React.ReactNode
|
|
99
|
-
/** Supabase project URL — one project per SaaS (core in saas_core schema, data in public) */
|
|
100
|
-
supabaseUrl?: string
|
|
101
|
-
/** Supabase anon key */
|
|
102
|
-
supabaseAnonKey?: string
|
|
103
|
-
/** Theme overrides — use SaasTheme (friendly) or CreateThemeOptions (granular) */
|
|
104
|
-
theme?: CreateThemeOptions | SaasTheme
|
|
105
|
-
/** Default theme mode: 'light' or 'dark'. User can toggle. Default: 'light' */
|
|
106
|
-
defaultThemeMode?: 'light' | 'dark'
|
|
107
|
-
/** Layout variant (default: 'sidebar') */
|
|
108
|
-
layout?: 'sidebar' | 'topbar' | 'minimal'
|
|
109
|
-
/** Content floats in a rounded frame over the sidebar background (default: true). Set false to disable. */
|
|
110
|
-
sidebarFrame?: boolean
|
|
111
|
-
/** Vertical-specific pages */
|
|
112
|
-
pages: PageConfig[]
|
|
113
|
-
/** Plugins to register */
|
|
114
|
-
plugins?: PluginManifest[]
|
|
115
|
-
/** Runtime plugin activation for tenant-aware apps */
|
|
116
|
-
pluginRuntime?: {
|
|
117
|
-
/** Opt into loading tenant plugin bindings from the backend RPC. Static apps should leave this off. */
|
|
118
|
-
hydrateTenantPlugins?: boolean
|
|
119
|
-
tenantPlugins?: TenantPluginBinding[]
|
|
120
|
-
resolveTenantPlugins?: (context: {
|
|
121
|
-
tenant: { id: string; slug: string; verticalId?: string; plan?: string } | null
|
|
122
|
-
user: { id: string } | null
|
|
123
|
-
}) => TenantPluginBinding[] | undefined
|
|
124
|
-
}
|
|
125
|
-
/** Router adapter (default: hashRouterAdapter) */
|
|
126
|
-
router?: RouterAdapter
|
|
127
|
-
/** Billing configuration */
|
|
128
|
-
billing?: {
|
|
129
|
-
plans: PlanConfig[]
|
|
130
|
-
stripePublishableKey?: string
|
|
131
|
-
}
|
|
132
|
-
/** AI chat configuration */
|
|
133
|
-
chat?: {
|
|
134
|
-
enabled?: boolean
|
|
135
|
-
systemPrompt?: string
|
|
136
|
-
apiEndpoint?: string
|
|
137
|
-
title?: string
|
|
138
|
-
}
|
|
139
|
-
/** Notification configuration */
|
|
140
|
-
notifications?: {
|
|
141
|
-
changelogUrl?: string
|
|
142
|
-
}
|
|
143
|
-
/** Locale / i18n configuration */
|
|
144
|
-
locale?: {
|
|
145
|
-
default?: string
|
|
146
|
-
supported?: string[]
|
|
147
|
-
translations?: Record<string, Record<string, string>>
|
|
148
|
-
/** ISO 4217 currency code for all currency fields (e.g. 'BRL'). Inferred from locale when omitted. */
|
|
149
|
-
currency?: string
|
|
150
|
-
}
|
|
151
|
-
/** Mock user for demo/dev (will be replaced by auth in production) */
|
|
152
|
-
user?: {
|
|
153
|
-
fullName: string
|
|
154
|
-
email: string
|
|
155
|
-
avatarUrl?: string
|
|
156
|
-
}
|
|
157
|
-
/** Auth configuration */
|
|
158
|
-
auth?: {
|
|
159
|
-
adapter?: AuthAdapter | 'mock' | 'supabase'
|
|
160
|
-
requireAuth?: boolean
|
|
161
|
-
loginLogo?: React.ReactNode
|
|
162
|
-
loginLayout?: 'split' | 'centered'
|
|
163
|
-
loginTagline?: string
|
|
164
|
-
loginDescription?: string
|
|
165
|
-
showOAuth?: boolean
|
|
166
|
-
oauthProviders?: Exclude<AuthProvider, 'email'>[]
|
|
167
|
-
}
|
|
168
|
-
/** Organization / multi-tenant configuration */
|
|
169
|
-
organization?: {
|
|
170
|
-
adapter?: OrgAdapter | 'mock' | 'supabase'
|
|
171
|
-
multiOrg?: boolean
|
|
172
|
-
}
|
|
173
|
-
/** Vertical/niche ID — auto-set on tenant creation, skips niche selection in onboarding */
|
|
174
|
-
verticalId?: string
|
|
175
|
-
/** Permission profiles configuration */
|
|
176
|
-
permissions?: PermissionsConfig
|
|
177
|
-
/** Additional settings tabs to merge with built-in ones */
|
|
178
|
-
settingsTabs?: { id: string; label: string; component: React.ReactNode }[]
|
|
179
|
-
/** Set to false to hide built-in Settings page (default: true) */
|
|
180
|
-
showSettings?: boolean
|
|
181
|
-
/** Set to false to hide built-in Billing page (default: true when plans provided) */
|
|
182
|
-
showBilling?: boolean
|
|
183
|
-
/** Custom mobile bottom nav items (max 5). If omitted, auto-picks first main items. */
|
|
184
|
-
bottomNav?: Array<{ label: string; icon: string; route: string }>
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
// ---------------------------------------------------------------------------
|
|
188
|
-
// Internal: build NavigationItem[] from PageConfig[] + built-ins
|
|
189
|
-
// ---------------------------------------------------------------------------
|
|
190
|
-
type RouteEntry = {
|
|
191
|
-
component: React.ComponentType<any>
|
|
192
|
-
permission?: PluginPermissionRequirement
|
|
193
|
-
plugin?: ResolvedPluginManifest
|
|
194
|
-
fullBleed?: boolean
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
function sortNavigation(
|
|
198
|
-
navigation: (NavigationItem & { permission?: PluginPermissionRequirement })[],
|
|
199
|
-
): (NavigationItem & { permission?: PluginPermissionRequirement })[] {
|
|
200
|
-
return [...navigation].sort((a, b) => {
|
|
201
|
-
const sectionOrder = { main: 0, secondary: 1, settings: 2 }
|
|
202
|
-
const sa = sectionOrder[a.section] ?? 1
|
|
203
|
-
const sb = sectionOrder[b.section] ?? 1
|
|
204
|
-
if (sa !== sb) return sa - sb
|
|
205
|
-
return a.position - b.position
|
|
206
|
-
})
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
function buildNavigation(
|
|
210
|
-
pages: PageConfig[],
|
|
211
|
-
config: SaasAppConfig
|
|
212
|
-
): { navigation: (NavigationItem & { permission?: PluginPermissionRequirement })[]; routes: Map<string, RouteEntry>; entityRouteMap: Map<string, string> } {
|
|
213
|
-
const navigation: (NavigationItem & { permission?: PluginPermissionRequirement })[] = []
|
|
214
|
-
const routes = new Map<string, RouteEntry>()
|
|
215
|
-
// Maps "archetype:kind" (e.g. "person:staff") or "archetype" to route path
|
|
216
|
-
const entityRouteMap = new Map<string, string>()
|
|
217
|
-
|
|
218
|
-
// Vertical pages
|
|
219
|
-
let mainPos = 0
|
|
220
|
-
let secondaryPos = 0
|
|
221
|
-
for (const page of pages) {
|
|
222
|
-
const section = page.section ?? 'main'
|
|
223
|
-
const position = page.position ?? (section === 'main' ? mainPos++ : secondaryPos++)
|
|
224
|
-
|
|
225
|
-
// Build child navigation items + routes
|
|
226
|
-
const childNavItems: NavigationItem[] = []
|
|
227
|
-
if (page.children) {
|
|
228
|
-
for (const child of page.children) {
|
|
229
|
-
childNavItems.push({
|
|
230
|
-
id: child.path.slice(1).replace(/\//g, '-'),
|
|
231
|
-
label: child.label,
|
|
232
|
-
icon: child.icon,
|
|
233
|
-
route: child.path,
|
|
234
|
-
section,
|
|
235
|
-
position: 0,
|
|
236
|
-
badge: child.badge,
|
|
237
|
-
})
|
|
238
|
-
// Only register route if child has its own component and path isn't already handled
|
|
239
|
-
if (child.component && !routes.has(child.path)) {
|
|
240
|
-
if ((child.component as any)?.__isCrudPage) {
|
|
241
|
-
;(child.component as any).__crudBasePath = child.path
|
|
242
|
-
const eDef = (child.component as any).__entityDef
|
|
243
|
-
if (eDef?.data?.archetypeKind) {
|
|
244
|
-
entityRouteMap.set(`${eDef.data.archetype ?? ''}:${eDef.data.archetypeKind}`, child.path)
|
|
245
|
-
} else if (eDef?.data?.archetype) {
|
|
246
|
-
entityRouteMap.set(eDef.data.archetype, child.path)
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
routes.set(child.path, { component: child.component, permission: child.permission })
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
navigation.push({
|
|
255
|
-
id: page.path === '/' ? 'home' : page.path.slice(1).replace(/\//g, '-'),
|
|
256
|
-
label: page.label,
|
|
257
|
-
icon: page.icon,
|
|
258
|
-
route: page.path,
|
|
259
|
-
section,
|
|
260
|
-
position,
|
|
261
|
-
badge: page.badge,
|
|
262
|
-
permission: page.permission,
|
|
263
|
-
...(childNavItems.length > 0 ? { children: childNavItems } : {}),
|
|
264
|
-
})
|
|
265
|
-
// Set basePath on CRUD pages so they know their mount point
|
|
266
|
-
if ((page.component as any)?.__isCrudPage) {
|
|
267
|
-
;(page.component as any).__crudBasePath = page.path
|
|
268
|
-
const eDef = (page.component as any).__entityDef
|
|
269
|
-
if (eDef?.data?.archetypeKind) {
|
|
270
|
-
entityRouteMap.set(`${eDef.data.archetype ?? ''}:${eDef.data.archetypeKind}`, page.path)
|
|
271
|
-
} else if (eDef?.data?.archetype) {
|
|
272
|
-
entityRouteMap.set(eDef.data.archetype, page.path)
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
|
-
routes.set(page.path, { component: page.component, permission: page.permission })
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
// Built-in Billing page (route exists but no sidebar nav item — accessed via user dropdown)
|
|
279
|
-
const showBilling = config.showBilling ?? (config.billing?.plans && config.billing.plans.length > 0)
|
|
280
|
-
if (showBilling) {
|
|
281
|
-
routes.set('/billing', { component: BillingPage })
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
// Built-in Settings page (route exists but no sidebar nav item — accessed via user dropdown)
|
|
285
|
-
const showSettings = config.showSettings !== false
|
|
286
|
-
if (showSettings) {
|
|
287
|
-
routes.set('/settings', { component: SettingsPage })
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
return { navigation: sortNavigation(navigation), routes, entityRouteMap }
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
// ---------------------------------------------------------------------------
|
|
294
|
-
// Internal: render logo from string or ReactNode
|
|
295
|
-
// ---------------------------------------------------------------------------
|
|
296
|
-
// ---------------------------------------------------------------------------
|
|
297
|
-
// Global entity search — queries persons, products, services in parallel
|
|
298
|
-
// ---------------------------------------------------------------------------
|
|
299
|
-
|
|
300
|
-
function createGlobalEntitySearch(): EntitySearchFn {
|
|
301
|
-
return async (query: string): Promise<EntitySearchResult[]> => {
|
|
302
|
-
const supabase = getSupabaseClientOptional()
|
|
303
|
-
if (!supabase) return []
|
|
304
|
-
|
|
305
|
-
const term = `%${query}%`
|
|
306
|
-
const limit = 8
|
|
307
|
-
|
|
308
|
-
const [persons, products, services] = await Promise.all([
|
|
309
|
-
supabase.schema('saas_core').from('persons')
|
|
310
|
-
.select('id, name, email, phone, kind')
|
|
311
|
-
.eq('is_active', true)
|
|
312
|
-
.ilike('name', term)
|
|
313
|
-
.limit(limit)
|
|
314
|
-
.then((r: any) => r.data ?? []),
|
|
315
|
-
supabase.schema('saas_core').from('products')
|
|
316
|
-
.select('id, name, sku, price')
|
|
317
|
-
.eq('is_active', true)
|
|
318
|
-
.ilike('name', term)
|
|
319
|
-
.limit(limit)
|
|
320
|
-
.then((r: any) => r.data ?? []),
|
|
321
|
-
supabase.schema('saas_core').from('services')
|
|
322
|
-
.select('id, name, price, duration_minutes')
|
|
323
|
-
.eq('is_active', true)
|
|
324
|
-
.ilike('name', term)
|
|
325
|
-
.limit(limit)
|
|
326
|
-
.then((r: any) => r.data ?? []),
|
|
327
|
-
])
|
|
328
|
-
|
|
329
|
-
const results: EntitySearchResult[] = []
|
|
330
|
-
|
|
331
|
-
for (const p of persons) {
|
|
332
|
-
const kind = p.kind ? String(p.kind) : 'person'
|
|
333
|
-
const kindLabel = kind.charAt(0).toUpperCase() + kind.slice(1) + 's'
|
|
334
|
-
results.push({
|
|
335
|
-
id: p.id,
|
|
336
|
-
label: p.name,
|
|
337
|
-
subtitle: [p.email, p.phone].filter(Boolean).join(' · ') || undefined,
|
|
338
|
-
group: kindLabel,
|
|
339
|
-
icon: 'User',
|
|
340
|
-
data: { ...p, archetype: 'person' },
|
|
341
|
-
})
|
|
342
|
-
}
|
|
343
|
-
for (const p of products) {
|
|
344
|
-
results.push({
|
|
345
|
-
id: p.id,
|
|
346
|
-
label: p.name,
|
|
347
|
-
subtitle: [p.sku, p.price != null ? `$${p.price}` : null].filter(Boolean).join(' · ') || undefined,
|
|
348
|
-
group: 'Products',
|
|
349
|
-
icon: 'Box',
|
|
350
|
-
data: { ...p, archetype: 'product' },
|
|
351
|
-
})
|
|
352
|
-
}
|
|
353
|
-
for (const s of services) {
|
|
354
|
-
results.push({
|
|
355
|
-
id: s.id,
|
|
356
|
-
label: s.name,
|
|
357
|
-
subtitle: [
|
|
358
|
-
s.duration_minutes ? `${s.duration_minutes}min` : null,
|
|
359
|
-
s.price != null ? `$${s.price}` : null,
|
|
360
|
-
].filter(Boolean).join(' · ') || undefined,
|
|
361
|
-
group: 'Services',
|
|
362
|
-
icon: 'Sparkles',
|
|
363
|
-
data: { ...s, archetype: 'service' },
|
|
364
|
-
})
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
return results
|
|
368
|
-
}
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
function CommandPaletteWrapper({ navigation, routerAdapter }: { navigation: NavigationItem[]; routerAdapter: RouterAdapter }) {
|
|
372
|
-
const { commandPaletteOpen, setCommandPaletteOpen } = useLayoutStore()
|
|
373
|
-
|
|
374
|
-
const entitySearch = React.useMemo(() => createGlobalEntitySearch(), [])
|
|
375
|
-
|
|
376
|
-
const commands: CommandItem[] = React.useMemo(() => {
|
|
377
|
-
const items: CommandItem[] = []
|
|
378
|
-
for (const nav of navigation) {
|
|
379
|
-
items.push({
|
|
380
|
-
id: nav.id,
|
|
381
|
-
label: nav.label,
|
|
382
|
-
icon: nav.icon,
|
|
383
|
-
group: 'Pages',
|
|
384
|
-
action: () => routerAdapter.navigate(nav.route),
|
|
385
|
-
})
|
|
386
|
-
if ((nav as any).children) {
|
|
387
|
-
for (const child of (nav as any).children) {
|
|
388
|
-
items.push({
|
|
389
|
-
id: child.id,
|
|
390
|
-
label: child.label,
|
|
391
|
-
icon: child.icon,
|
|
392
|
-
group: nav.label,
|
|
393
|
-
action: () => routerAdapter.navigate(child.route),
|
|
394
|
-
})
|
|
395
|
-
}
|
|
396
|
-
}
|
|
397
|
-
}
|
|
398
|
-
// System actions
|
|
399
|
-
items.push(
|
|
400
|
-
{ id: 'settings', label: 'Settings', icon: 'Settings', group: 'System', action: () => routerAdapter.navigate('/settings') },
|
|
401
|
-
{ id: 'billing', label: 'Billing', icon: 'CreditCard', group: 'System', action: () => routerAdapter.navigate('/billing') },
|
|
402
|
-
)
|
|
403
|
-
return items
|
|
404
|
-
}, [navigation, routerAdapter])
|
|
405
|
-
|
|
406
|
-
const handleEntitySelect = React.useCallback((result: EntitySearchResult) => {
|
|
407
|
-
const archetype = result.data?.archetype ? String(result.data.archetype) : undefined
|
|
408
|
-
const kind = result.data?.kind ? String(result.data.kind) : undefined
|
|
409
|
-
const href = resolveEntityHref(result.id, archetype, kind)
|
|
410
|
-
routerAdapter.navigate(href)
|
|
411
|
-
}, [routerAdapter])
|
|
412
|
-
|
|
413
|
-
return React.createElement(CommandPalette, {
|
|
414
|
-
commands,
|
|
415
|
-
open: commandPaletteOpen,
|
|
416
|
-
onOpenChange: setCommandPaletteOpen,
|
|
417
|
-
onEntitySearch: entitySearch,
|
|
418
|
-
onEntitySelect: handleEntitySelect,
|
|
419
|
-
})
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
function renderLogo(name: string, logo?: string | React.ReactNode): React.ReactNode {
|
|
423
|
-
if (logo && typeof logo !== 'string') return logo
|
|
424
|
-
const initial = typeof logo === 'string' ? logo : name.charAt(0).toUpperCase()
|
|
425
|
-
return React.createElement('div', { className: 'flex items-center gap-3' },
|
|
426
|
-
React.createElement('div', {
|
|
427
|
-
className: 'w-8 h-8 rounded-lg bg-primary flex items-center justify-center text-primary-foreground font-bold text-sm',
|
|
428
|
-
}, initial),
|
|
429
|
-
React.createElement('span', { className: 'font-bold text-lg' }, name),
|
|
430
|
-
)
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
// ---------------------------------------------------------------------------
|
|
434
|
-
// Internal: resolve auth adapter from config
|
|
435
|
-
// ---------------------------------------------------------------------------
|
|
436
|
-
function resolveAuthAdapter(config: SaasAppConfig): AuthAdapter | null {
|
|
437
|
-
if (!config.auth?.adapter) return null
|
|
438
|
-
if (typeof config.auth.adapter === 'object') return config.auth.adapter
|
|
439
|
-
if (config.auth.adapter === 'mock') return createMockAuthAdapter()
|
|
440
|
-
if (config.auth.adapter === 'supabase') {
|
|
441
|
-
if (!getSupabaseClientOptional()) return createMockAuthAdapter()
|
|
442
|
-
return createSupabaseAuthAdapter()
|
|
443
|
-
}
|
|
444
|
-
return null
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
// ---------------------------------------------------------------------------
|
|
448
|
-
// Internal: resolve org adapter from config
|
|
449
|
-
// ---------------------------------------------------------------------------
|
|
450
|
-
function resolveOrgAdapter(config: SaasAppConfig): OrgAdapter | null {
|
|
451
|
-
if (!config.organization?.adapter) return null
|
|
452
|
-
if (typeof config.organization.adapter === 'object') return config.organization.adapter
|
|
453
|
-
if (config.organization.adapter === 'mock') {
|
|
454
|
-
return createMockOrgAdapter(config.permissions?.defaultProfiles)
|
|
455
|
-
}
|
|
456
|
-
if (config.organization.adapter === 'supabase') {
|
|
457
|
-
if (!getSupabaseClientOptional()) return createMockOrgAdapter(config.permissions?.defaultProfiles)
|
|
458
|
-
return createSupabaseOrgAdapter()
|
|
459
|
-
}
|
|
460
|
-
return null
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
function buildSettingsTabs(
|
|
464
|
-
config: SaasAppConfig,
|
|
465
|
-
pluginTabs: PluginSettingsTab[],
|
|
466
|
-
can: (feature: string, action: PermissionAction) => boolean,
|
|
467
|
-
t: (key: string, params?: Record<string, string | number>) => string,
|
|
468
|
-
): { id: string; label: string; icon?: React.ReactNode; component: React.ReactNode }[] {
|
|
469
|
-
const settingsTabs: { id: string; label: string; icon?: React.ReactNode; component: React.ReactNode }[] = []
|
|
470
|
-
|
|
471
|
-
if (config.organization) {
|
|
472
|
-
settingsTabs.push(
|
|
473
|
-
{ id: 'team', label: t('settings.team'), icon: React.createElement(Users, { className: 'h-4 w-4' }), component: React.createElement(TeamTab) },
|
|
474
|
-
{ id: 'permissions', label: t('settings.permissions'), icon: React.createElement(ShieldCheck, { className: 'h-4 w-4' }), component: React.createElement(PermissionProfilesTab) },
|
|
475
|
-
{ id: 'locations', label: t('settings.locations'), icon: React.createElement(MapPin, { className: 'h-4 w-4' }), component: React.createElement(LocationsCrudPage) },
|
|
476
|
-
{ id: 'field-rules', label: t('settings.fieldRules'), icon: React.createElement(SlidersHorizontal, { className: 'h-4 w-4' }), component: React.createElement(ConnectedFieldRulesSettings) },
|
|
477
|
-
)
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
if (config.settingsTabs) {
|
|
481
|
-
settingsTabs.push(...config.settingsTabs)
|
|
482
|
-
}
|
|
483
|
-
|
|
484
|
-
for (const tab of pluginTabs) {
|
|
485
|
-
if (tab.permission && !can(tab.permission.feature, tab.permission.action)) {
|
|
486
|
-
continue
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
const IconComp = tab.icon ? (LucideIcons as any)[tab.icon] ?? Puzzle : Puzzle
|
|
490
|
-
// Try to translate plugin settings tab label via t('settings.plugin.{id}'), fallback to raw label
|
|
491
|
-
const pluginLabelKey = `settings.plugin.${tab.id}`
|
|
492
|
-
const pluginLabel = t(pluginLabelKey)
|
|
493
|
-
settingsTabs.push({
|
|
494
|
-
id: tab.id,
|
|
495
|
-
label: pluginLabel === pluginLabelKey ? tab.label : pluginLabel,
|
|
496
|
-
icon: React.createElement(IconComp, { className: 'h-4 w-4' }),
|
|
497
|
-
component: React.createElement(tab.component),
|
|
498
|
-
isPlugin: true,
|
|
499
|
-
} as any)
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
return settingsTabs
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
// ---------------------------------------------------------------------------
|
|
506
|
-
// createSaasApp — returns a ready-to-render App component
|
|
507
|
-
// ---------------------------------------------------------------------------
|
|
508
|
-
export function createSaasApp(config: SaasAppConfig): React.FC {
|
|
509
|
-
// Initialize Supabase — single project, core tables in saas_core schema
|
|
510
|
-
if (config.supabaseUrl && config.supabaseAnonKey) {
|
|
511
|
-
createSupabaseClient(config.supabaseUrl, config.supabaseAnonKey)
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
const routerAdapter = config.router ?? hashRouterAdapter()
|
|
515
|
-
setGlobalRouter(routerAdapter)
|
|
516
|
-
|
|
517
|
-
const { navigation: baseNavigation, routes: baseRoutes, entityRouteMap } = buildNavigation(config.pages, config)
|
|
518
|
-
setEntityRouteMap(entityRouteMap)
|
|
519
|
-
const layout = config.layout ?? 'sidebar'
|
|
520
|
-
const logoNode = renderLogo(config.name, config.logo)
|
|
521
|
-
|
|
522
|
-
// Resolve adapters at creation time
|
|
523
|
-
const authAdapter = resolveAuthAdapter(config)
|
|
524
|
-
const orgAdapter = resolveOrgAdapter(config)
|
|
525
|
-
const requireAuth = config.auth?.requireAuth ?? !!authAdapter
|
|
526
|
-
|
|
527
|
-
// Login page component
|
|
528
|
-
const LoginPageWrapper: React.FC = () => {
|
|
529
|
-
return React.createElement(LoginPage, {
|
|
530
|
-
appName: config.name,
|
|
531
|
-
logo: config.auth?.loginLogo ?? logoNode,
|
|
532
|
-
layout: config.auth?.loginLayout ?? 'split',
|
|
533
|
-
tagline: config.auth?.loginTagline,
|
|
534
|
-
description: config.auth?.loginDescription,
|
|
535
|
-
showOAuth: config.auth?.showOAuth,
|
|
536
|
-
oauthProviders: config.auth?.oauthProviders,
|
|
537
|
-
onSuccess: () => { routerAdapter.navigate('/') },
|
|
538
|
-
})
|
|
539
|
-
}
|
|
540
|
-
|
|
541
|
-
// The inner app (authenticated content)
|
|
542
|
-
const AppContent: React.FC = () => {
|
|
543
|
-
const route = routerAdapter.usePathname()
|
|
544
|
-
const authUser = useAuthStore((s) => s.user)
|
|
545
|
-
const currentOrg = useOrganizationStore((s) => s.currentOrg)
|
|
546
|
-
const currentProfile = usePermissionsStore((s) => s.currentProfile)
|
|
547
|
-
const { can } = usePermission()
|
|
548
|
-
const shouldHydrateTenantPlugins =
|
|
549
|
-
config.pluginRuntime?.hydrateTenantPlugins === true &&
|
|
550
|
-
!config.pluginRuntime?.resolveTenantPlugins &&
|
|
551
|
-
!config.pluginRuntime?.tenantPlugins
|
|
552
|
-
const { tenantPlugins: hydratedTenantPlugins } = useTenantPlugins({ enabled: shouldHydrateTenantPlugins })
|
|
553
|
-
const { t } = useTranslation()
|
|
554
|
-
|
|
555
|
-
// Derive display user from auth or config fallback
|
|
556
|
-
const user = authUser
|
|
557
|
-
? { fullName: authUser.fullName, email: authUser.email, avatarUrl: authUser.avatarUrl }
|
|
558
|
-
: config.user
|
|
559
|
-
|
|
560
|
-
// Merge plugin-declared features into permission store (must be before any early return)
|
|
561
|
-
React.useEffect(() => {
|
|
562
|
-
const pf = config.plugins?.flatMap((p) => p.declaredFeatures ?? []) ?? []
|
|
563
|
-
if (pf.length === 0) return
|
|
564
|
-
const store = usePermissionsStore.getState()
|
|
565
|
-
const existing = new Set(store.features.map((f) => f.id))
|
|
566
|
-
const toAdd = pf.filter((f) => !existing.has(f.id))
|
|
567
|
-
if (toAdd.length > 0) store.setFeatures([...store.features, ...toAdd])
|
|
568
|
-
}, [])
|
|
569
|
-
|
|
570
|
-
// Record every route change into the global navigation history so shared
|
|
571
|
-
// headers (SubpageHeader / Breadcrumb) can render a context-aware back link
|
|
572
|
-
// that returns to the actual previous page across module boundaries. The
|
|
573
|
-
// label resolver is filled in below once `navigation` is built; we read it
|
|
574
|
-
// via a ref so this effect can live before the early return.
|
|
575
|
-
const navLabelRef = React.useRef<(path: string) => string>(() => '')
|
|
576
|
-
React.useEffect(() => {
|
|
577
|
-
recordNavigation(route, navLabelRef.current(route))
|
|
578
|
-
}, [route])
|
|
579
|
-
|
|
580
|
-
// If we're on the login route, render login page
|
|
581
|
-
if (route === '/login') {
|
|
582
|
-
return React.createElement(LoginPageWrapper)
|
|
583
|
-
}
|
|
584
|
-
|
|
585
|
-
const tenantPluginBindings = config.pluginRuntime?.resolveTenantPlugins?.({
|
|
586
|
-
tenant: currentOrg
|
|
587
|
-
? {
|
|
588
|
-
id: currentOrg.id,
|
|
589
|
-
slug: currentOrg.slug,
|
|
590
|
-
verticalId: currentOrg.verticalId,
|
|
591
|
-
plan: currentOrg.plan,
|
|
592
|
-
}
|
|
593
|
-
: null,
|
|
594
|
-
user: authUser ? { id: authUser.id } : null,
|
|
595
|
-
}) ?? config.pluginRuntime?.tenantPlugins ?? hydratedTenantPlugins
|
|
596
|
-
const usesHydratedTenantBindings = shouldHydrateTenantPlugins && !!currentOrg
|
|
597
|
-
|
|
598
|
-
const initialPluginRuntime = resolvePluginRuntime({
|
|
599
|
-
plugins: config.plugins as any,
|
|
600
|
-
tenantPlugins: tenantPluginBindings as any,
|
|
601
|
-
hasTenantBindings: usesHydratedTenantBindings ? true : undefined,
|
|
602
|
-
context: {
|
|
603
|
-
tenant: currentOrg
|
|
604
|
-
? {
|
|
605
|
-
id: currentOrg.id,
|
|
606
|
-
slug: currentOrg.slug,
|
|
607
|
-
verticalId: currentOrg.verticalId,
|
|
608
|
-
plan: currentOrg.plan,
|
|
609
|
-
}
|
|
610
|
-
: null,
|
|
611
|
-
user: authUser
|
|
612
|
-
? {
|
|
613
|
-
id: authUser.id,
|
|
614
|
-
role: currentProfile?.id,
|
|
615
|
-
}
|
|
616
|
-
: null,
|
|
617
|
-
currentPath: route,
|
|
618
|
-
matchedPath: route,
|
|
619
|
-
layout,
|
|
620
|
-
hasPermission: (requirement) => can(requirement.feature, requirement.action),
|
|
621
|
-
},
|
|
622
|
-
})
|
|
623
|
-
|
|
624
|
-
const navigation = sortNavigation([
|
|
625
|
-
...baseNavigation,
|
|
626
|
-
...initialPluginRuntime.navigation.map((item) => ({
|
|
627
|
-
id: item.id ?? item.route,
|
|
628
|
-
label: item.label,
|
|
629
|
-
icon: item.icon ?? 'Package',
|
|
630
|
-
route: item.route,
|
|
631
|
-
section: item.section,
|
|
632
|
-
position: item.position,
|
|
633
|
-
badge: item.badge,
|
|
634
|
-
permission: item.permission,
|
|
635
|
-
})),
|
|
636
|
-
])
|
|
637
|
-
const routes = new Map(baseRoutes)
|
|
638
|
-
for (const pluginRoute of initialPluginRuntime.routes) {
|
|
639
|
-
routes.set(pluginRoute.path, {
|
|
640
|
-
component: pluginRoute.component as any,
|
|
641
|
-
permission: pluginRoute.permission,
|
|
642
|
-
plugin: pluginRoute.plugin as any,
|
|
643
|
-
fullBleed: pluginRoute.fullBleed,
|
|
644
|
-
})
|
|
645
|
-
}
|
|
646
|
-
|
|
647
|
-
// Module-level label for a route, used by the global navigation history.
|
|
648
|
-
// Exact nav match first, then prefix match (e.g. '/clients/{id}' → 'Clientes').
|
|
649
|
-
navLabelRef.current = (path: string): string => {
|
|
650
|
-
const exact = navigation.find((n) => n.route === path)?.label
|
|
651
|
-
if (exact) return exact
|
|
652
|
-
for (const n of navigation) {
|
|
653
|
-
if (n.route !== '/' && path.startsWith(n.route + '/')) return n.label
|
|
654
|
-
}
|
|
655
|
-
return ''
|
|
656
|
-
}
|
|
657
|
-
|
|
658
|
-
// Resolve page: exact match first, then prefix match for CRUD sub-routes and plugin routes
|
|
659
|
-
let matchedPath = route
|
|
660
|
-
let routeEntry = routes.get(route)
|
|
661
|
-
if (!routeEntry) {
|
|
662
|
-
// Try prefix match (e.g. /services/new → /services, /financial/settings/... → /financial)
|
|
663
|
-
for (const [path, entry] of routes) {
|
|
664
|
-
if (path !== '/' && route.startsWith(path + '/')) {
|
|
665
|
-
routeEntry = entry
|
|
666
|
-
matchedPath = path
|
|
667
|
-
break
|
|
668
|
-
}
|
|
669
|
-
}
|
|
670
|
-
}
|
|
671
|
-
const pluginRuntime = {
|
|
672
|
-
...initialPluginRuntime,
|
|
673
|
-
context: {
|
|
674
|
-
...initialPluginRuntime.context,
|
|
675
|
-
matchedPath,
|
|
676
|
-
},
|
|
677
|
-
}
|
|
678
|
-
const NotFoundPage: React.FC = () => React.createElement('div', { className: 'flex flex-col items-center justify-center py-24 text-center' },
|
|
679
|
-
React.createElement('h1', { className: 'text-6xl font-bold text-muted-foreground/20 mb-4' }, t('app.notFound.title')),
|
|
680
|
-
React.createElement('h2', { className: 'text-xl font-semibold mb-2' }, t('app.notFound.heading')),
|
|
681
|
-
React.createElement('p', { className: 'text-sm text-muted-foreground mb-6' }, t('app.notFound.message')),
|
|
682
|
-
React.createElement('button', {
|
|
683
|
-
className: 'rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors',
|
|
684
|
-
onClick: () => routerAdapter.navigate('/'),
|
|
685
|
-
}, t('app.notFound.goToDashboard')),
|
|
686
|
-
)
|
|
687
|
-
const PageComponent = routeEntry?.component ?? NotFoundPage
|
|
688
|
-
const pagePermission = routeEntry?.permission
|
|
689
|
-
const pageTitle = navigation.find((n) => n.route === matchedPath)?.label ?? navigation.find((n) => n.route === route)?.label ?? navigation[0]?.label ?? ''
|
|
690
|
-
const settingsTabs = buildSettingsTabs(config, pluginRuntime.settingsTabs as any, can, t)
|
|
691
|
-
|
|
692
|
-
const handleSignOut = async () => {
|
|
693
|
-
if (authAdapter) {
|
|
694
|
-
try {
|
|
695
|
-
await authAdapter.signOut()
|
|
696
|
-
useAuthStore.getState().reset()
|
|
697
|
-
useAuthStore.getState().setInitialized(true)
|
|
698
|
-
} catch {
|
|
699
|
-
// ignore sign out errors
|
|
700
|
-
}
|
|
701
|
-
routerAdapter.navigate('/login')
|
|
702
|
-
}
|
|
703
|
-
}
|
|
704
|
-
|
|
705
|
-
const renderedPage = matchedPath === '/settings' && config.showSettings !== false
|
|
706
|
-
? React.createElement(SettingsPage, {
|
|
707
|
-
extraTabs: settingsTabs,
|
|
708
|
-
beforeContent: React.createElement(WidgetSlot, {
|
|
709
|
-
zone: 'settings.before',
|
|
710
|
-
className: 'space-y-4',
|
|
711
|
-
contextOverrides: { matchedPath },
|
|
712
|
-
}),
|
|
713
|
-
afterContent: React.createElement(WidgetSlot, {
|
|
714
|
-
zone: 'settings.after',
|
|
715
|
-
className: 'space-y-4',
|
|
716
|
-
contextOverrides: { matchedPath },
|
|
717
|
-
}),
|
|
718
|
-
})
|
|
719
|
-
: React.createElement(PageComponent, routeEntry?.plugin ? {
|
|
720
|
-
plugin: routeEntry.plugin,
|
|
721
|
-
runtime: pluginRuntime.context,
|
|
722
|
-
config: routeEntry.plugin.config,
|
|
723
|
-
} : undefined)
|
|
724
|
-
|
|
725
|
-
// Build page element — wrap in PermissionGate if page has permission config.
|
|
726
|
-
// Full-bleed routes (chat, kanban, canvas) opt out of the p-6 padding +
|
|
727
|
-
// animation wrapper and fill the content region edge-to-edge.
|
|
728
|
-
const fullBleed = Boolean(routeEntry?.fullBleed)
|
|
729
|
-
const pageContent = fullBleed
|
|
730
|
-
? React.createElement('div', { key: matchedPath, className: 'h-full min-h-0 overflow-hidden' }, renderedPage)
|
|
731
|
-
: React.createElement('div', { key: matchedPath, className: 'saas-page-enter space-y-6 p-6' },
|
|
732
|
-
React.createElement(WidgetSlot, {
|
|
733
|
-
zone: 'page.before',
|
|
734
|
-
className: 'space-y-4',
|
|
735
|
-
contextOverrides: { matchedPath },
|
|
736
|
-
}),
|
|
737
|
-
renderedPage,
|
|
738
|
-
React.createElement(WidgetSlot, {
|
|
739
|
-
zone: 'page.after',
|
|
740
|
-
className: 'space-y-4',
|
|
741
|
-
contextOverrides: { matchedPath },
|
|
742
|
-
}),
|
|
743
|
-
)
|
|
744
|
-
const pageElement: React.ReactNode = pagePermission
|
|
745
|
-
? React.createElement(
|
|
746
|
-
PermissionGate,
|
|
747
|
-
{
|
|
748
|
-
feature: pagePermission.feature,
|
|
749
|
-
action: pagePermission.action,
|
|
750
|
-
fallback: React.createElement('div', { className: 'flex items-center justify-center py-24' },
|
|
751
|
-
React.createElement('div', { className: 'text-center' },
|
|
752
|
-
React.createElement('h2', { className: 'text-xl font-semibold mb-2' }, t('app.accessDenied.heading')),
|
|
753
|
-
React.createElement('p', { className: 'text-muted-foreground' }, t('app.accessDenied.message'))
|
|
754
|
-
)
|
|
755
|
-
),
|
|
756
|
-
children: pageContent,
|
|
757
|
-
},
|
|
758
|
-
)
|
|
759
|
-
: pageContent
|
|
760
|
-
|
|
761
|
-
// Build orgSwitcher if configured
|
|
762
|
-
const orgSwitcherElement = orgAdapter
|
|
763
|
-
? React.createElement(OrgSwitcher)
|
|
764
|
-
: undefined
|
|
765
|
-
|
|
766
|
-
const content = React.createElement(
|
|
767
|
-
PluginRuntimeProvider,
|
|
768
|
-
{ value: pluginRuntime },
|
|
769
|
-
React.createElement(
|
|
770
|
-
React.Fragment,
|
|
771
|
-
null,
|
|
772
|
-
React.createElement(MockModeBanner),
|
|
773
|
-
React.createElement(ImpersonationBanner),
|
|
774
|
-
React.createElement(
|
|
775
|
-
AppShell,
|
|
776
|
-
{
|
|
777
|
-
variant: layout,
|
|
778
|
-
contentFrame: config.sidebarFrame !== false,
|
|
779
|
-
navigation: navigation as unknown as UiNavigationItem[],
|
|
780
|
-
user,
|
|
781
|
-
pageTitle: undefined,
|
|
782
|
-
currentPath: matchedPath,
|
|
783
|
-
onNavigate: (path: string) => { routerAdapter.navigate(path) },
|
|
784
|
-
onSignOut: authAdapter ? handleSignOut : () => console.log('sign out'),
|
|
785
|
-
onSettings: () => { routerAdapter.navigate('/settings') },
|
|
786
|
-
onBilling: routes.has('/billing') ? () => { routerAdapter.navigate('/billing') } : undefined,
|
|
787
|
-
logo: logoNode,
|
|
788
|
-
orgSwitcher: orgSwitcherElement,
|
|
789
|
-
topbarStart: React.createElement(WidgetSlot, {
|
|
790
|
-
zone: 'shell.topbar.start',
|
|
791
|
-
className: 'flex items-center gap-2',
|
|
792
|
-
contextOverrides: { matchedPath },
|
|
793
|
-
}),
|
|
794
|
-
topbarEnd: React.createElement(WidgetSlot, {
|
|
795
|
-
zone: 'shell.topbar.end',
|
|
796
|
-
className: 'flex items-center gap-2',
|
|
797
|
-
contextOverrides: { matchedPath },
|
|
798
|
-
}),
|
|
799
|
-
sidebarTopContent: React.createElement(WidgetSlot, {
|
|
800
|
-
zone: 'shell.sidebar.before-nav',
|
|
801
|
-
className: 'space-y-2',
|
|
802
|
-
contextOverrides: { matchedPath },
|
|
803
|
-
}),
|
|
804
|
-
sidebarFooterContent: React.createElement(WidgetSlot, {
|
|
805
|
-
zone: 'shell.sidebar.footer',
|
|
806
|
-
className: 'space-y-2',
|
|
807
|
-
contextOverrides: { matchedPath },
|
|
808
|
-
}),
|
|
809
|
-
bottomNav: config.bottomNav,
|
|
810
|
-
},
|
|
811
|
-
pageElement,
|
|
812
|
-
),
|
|
813
|
-
// Org initializer
|
|
814
|
-
orgAdapter ? React.createElement(OrgInitializer, { verticalId: config.verticalId }) : null,
|
|
815
|
-
// Location switch overlay
|
|
816
|
-
React.createElement(LocationSwitchOverlay),
|
|
817
|
-
// Chat
|
|
818
|
-
config.chat?.enabled !== false && config.chat
|
|
819
|
-
? React.createElement(React.Fragment, null,
|
|
820
|
-
React.createElement(ChatFab, { apiEndpoint: config.chat.apiEndpoint, systemPrompt: config.chat.systemPrompt }),
|
|
821
|
-
React.createElement(ChatPanel, { title: config.chat.title, apiEndpoint: config.chat.apiEndpoint, systemPrompt: config.chat.systemPrompt }),
|
|
822
|
-
)
|
|
823
|
-
: null,
|
|
824
|
-
React.createElement(WidgetSlot, {
|
|
825
|
-
zone: 'shell.floating',
|
|
826
|
-
contextOverrides: { matchedPath },
|
|
827
|
-
}),
|
|
828
|
-
// Command palette
|
|
829
|
-
React.createElement(CommandPaletteWrapper, { navigation, routerAdapter }),
|
|
830
|
-
),
|
|
831
|
-
)
|
|
832
|
-
|
|
833
|
-
// Wrap in ProtectedRoute if auth is required
|
|
834
|
-
if (requireAuth && authAdapter) {
|
|
835
|
-
return React.createElement(
|
|
836
|
-
ProtectedRoute,
|
|
837
|
-
{ onUnauthenticated: () => { routerAdapter.navigate('/login') } },
|
|
838
|
-
content,
|
|
839
|
-
)
|
|
840
|
-
}
|
|
841
|
-
|
|
842
|
-
return content
|
|
843
|
-
}
|
|
844
|
-
|
|
845
|
-
// The full App component
|
|
846
|
-
const SaasApp: React.FC = () => {
|
|
847
|
-
const initialize = useThemeStore((s) => s.initialize)
|
|
848
|
-
const setOverrides = useThemeStore((s) => s.setOverrides)
|
|
849
|
-
const setPlans = useBillingStore((s) => s.setPlans)
|
|
850
|
-
const setFeatures = usePermissionsStore((s) => s.setFeatures)
|
|
851
|
-
|
|
852
|
-
const setMode = useThemeStore((s) => s.setMode)
|
|
853
|
-
|
|
854
|
-
React.useEffect(() => {
|
|
855
|
-
if (config.theme) {
|
|
856
|
-
const isSaasTheme = 'brand' in config.theme && ('radius' in config.theme || 'sidebar' in config.theme || 'font' in config.theme)
|
|
857
|
-
const resolved = isSaasTheme ? resolveTheme(config.theme as SaasTheme) : config.theme as CreateThemeOptions
|
|
858
|
-
setOverrides(resolved)
|
|
859
|
-
}
|
|
860
|
-
// Set default mode if no user preference saved
|
|
861
|
-
if (config.defaultThemeMode && !localStorage.getItem('saas-core:theme-mode')) {
|
|
862
|
-
setMode(config.defaultThemeMode)
|
|
863
|
-
}
|
|
864
|
-
initialize()
|
|
865
|
-
}, [initialize, setOverrides, setMode])
|
|
866
|
-
|
|
867
|
-
React.useEffect(() => {
|
|
868
|
-
if (config.billing?.plans) {
|
|
869
|
-
setPlans(config.billing.plans.map(normalizePlanConfig) as any)
|
|
870
|
-
}
|
|
871
|
-
}, [setPlans])
|
|
872
|
-
|
|
873
|
-
// Initialize permissions config (consumer-declared only; plugin features merged in AppContent)
|
|
874
|
-
React.useEffect(() => {
|
|
875
|
-
if (config.permissions?.features) {
|
|
876
|
-
setFeatures(config.permissions.features)
|
|
877
|
-
}
|
|
878
|
-
}, [setFeatures])
|
|
879
|
-
|
|
880
|
-
// Initialize locale on the unified @fayz-ai/core i18n.
|
|
881
|
-
React.useEffect(() => {
|
|
882
|
-
setCurrentLocale(config.locale?.default ?? 'en')
|
|
883
|
-
setDefaultCurrency(config.locale?.currency)
|
|
884
|
-
}, [])
|
|
885
|
-
|
|
886
|
-
let inner: React.ReactNode = React.createElement(
|
|
887
|
-
RouterProvider,
|
|
888
|
-
{ value: routerAdapter },
|
|
889
|
-
React.createElement(ToastProvider, null),
|
|
890
|
-
React.createElement(AppContent),
|
|
891
|
-
)
|
|
892
|
-
|
|
893
|
-
// Wrap in OrgAdapterProvider if adapter is configured
|
|
894
|
-
if (orgAdapter) {
|
|
895
|
-
inner = React.createElement(
|
|
896
|
-
OrgAdapterProvider,
|
|
897
|
-
{ value: orgAdapter, children: inner },
|
|
898
|
-
)
|
|
899
|
-
}
|
|
900
|
-
|
|
901
|
-
// Wrap in AuthAdapterProvider if adapter is configured
|
|
902
|
-
if (authAdapter) {
|
|
903
|
-
inner = React.createElement(
|
|
904
|
-
AuthAdapterProvider,
|
|
905
|
-
{ value: authAdapter, children: inner },
|
|
906
|
-
)
|
|
907
|
-
}
|
|
908
|
-
|
|
909
|
-
// Merge built-in locale packs + plugin locales + consumer overrides
|
|
910
|
-
const mergedTranslations: Record<string, Record<string, string>> = {}
|
|
911
|
-
const supportedLocales = config.locale?.supported ?? ['en', 'pt-BR']
|
|
912
|
-
for (const loc of supportedLocales) {
|
|
913
|
-
// Layer 1: built-in core translations
|
|
914
|
-
const layer: Record<string, string> = { ...(builtInLocales[loc] ?? {}) }
|
|
915
|
-
// Layer 2: plugin translations (each plugin can ship its own locales)
|
|
916
|
-
if (config.plugins) {
|
|
917
|
-
for (const plugin of config.plugins) {
|
|
918
|
-
if (plugin.locales?.[loc]) {
|
|
919
|
-
Object.assign(layer, plugin.locales[loc])
|
|
920
|
-
}
|
|
921
|
-
}
|
|
922
|
-
}
|
|
923
|
-
// Layer 3: consumer app overrides (highest priority)
|
|
924
|
-
Object.assign(layer, config.locale?.translations?.[loc] ?? {})
|
|
925
|
-
mergedTranslations[loc] = layer
|
|
926
|
-
}
|
|
927
|
-
// Register into @fayz-ai/core so the shimmed useTranslation resolves these keys.
|
|
928
|
-
registerTranslations(mergedTranslations)
|
|
929
|
-
|
|
930
|
-
// Wrap in I18nProvider (outermost)
|
|
931
|
-
inner = React.createElement(I18nProvider, {
|
|
932
|
-
value: {
|
|
933
|
-
defaultLocale: config.locale?.default ?? 'en',
|
|
934
|
-
supported: supportedLocales,
|
|
935
|
-
translations: mergedTranslations,
|
|
936
|
-
}
|
|
937
|
-
}, inner)
|
|
938
|
-
|
|
939
|
-
return inner as React.JSX.Element
|
|
940
|
-
}
|
|
941
|
-
|
|
942
|
-
return SaasApp
|
|
943
|
-
}
|