@fayz-ai/ui 0.9.0 → 0.10.0

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 (156) hide show
  1. package/README.md +2 -2
  2. package/dist/analytics/AnalyticsCard.d.ts +10 -0
  3. package/dist/analytics/AnalyticsCard.d.ts.map +1 -0
  4. package/dist/analytics/AnalyticsControls.d.ts +18 -0
  5. package/dist/analytics/AnalyticsControls.d.ts.map +1 -0
  6. package/dist/analytics/AnalyticsGrid.d.ts +16 -0
  7. package/dist/analytics/AnalyticsGrid.d.ts.map +1 -0
  8. package/dist/analytics/context.d.ts +58 -0
  9. package/dist/analytics/context.d.ts.map +1 -0
  10. package/dist/analytics/derive.d.ts +81 -0
  11. package/dist/analytics/derive.d.ts.map +1 -0
  12. package/dist/analytics/index.d.ts +13 -0
  13. package/dist/analytics/index.d.ts.map +1 -0
  14. package/dist/analytics/range.d.ts +22 -0
  15. package/dist/analytics/range.d.ts.map +1 -0
  16. package/dist/{chunk-PVIB724L.js → chunk-22EVFMS3.js} +5 -3
  17. package/dist/chunk-22EVFMS3.js.map +1 -0
  18. package/dist/{chunk-G6OKZ7WL.js → chunk-6V5MGYXH.js} +13 -9
  19. package/dist/chunk-6V5MGYXH.js.map +1 -0
  20. package/dist/{chunk-KV3MHB6N.js → chunk-H63TOSQ7.js} +196 -53
  21. package/dist/chunk-H63TOSQ7.js.map +1 -0
  22. package/dist/{chunk-OUPJDGKE.js → chunk-I6CPKAOD.js} +134 -37
  23. package/dist/chunk-I6CPKAOD.js.map +1 -0
  24. package/dist/{chunk-YRVE4FOD.js → chunk-OKZ6WQB3.js} +3 -3
  25. package/dist/chunk-OKZ6WQB3.js.map +1 -0
  26. package/dist/{chunk-S5KNM4D2.js → chunk-SEIY2F5F.js} +235 -107
  27. package/dist/chunk-SEIY2F5F.js.map +1 -0
  28. package/dist/{chunk-75EFDLST.js → chunk-VAKWUUVC.js} +157 -6
  29. package/dist/chunk-VAKWUUVC.js.map +1 -0
  30. package/dist/dashboard/OnboardingWidget.d.ts +2 -14
  31. package/dist/dashboard/OnboardingWidget.d.ts.map +1 -1
  32. package/dist/dashboard/SidebarOnboarding.d.ts +28 -0
  33. package/dist/dashboard/SidebarOnboarding.d.ts.map +1 -0
  34. package/dist/dashboard/index.d.ts +2 -0
  35. package/dist/dashboard/index.d.ts.map +1 -1
  36. package/dist/dashboard/index.js +4 -4
  37. package/dist/dashboard/types.d.ts +18 -0
  38. package/dist/dashboard/types.d.ts.map +1 -1
  39. package/dist/dashboard/useOnboardingProgress.d.ts +28 -0
  40. package/dist/dashboard/useOnboardingProgress.d.ts.map +1 -0
  41. package/dist/icons.js +1 -1
  42. package/dist/index.d.ts +5 -4
  43. package/dist/index.d.ts.map +1 -1
  44. package/dist/index.js +960 -14
  45. package/dist/index.js.map +1 -1
  46. package/dist/layout/AppShell.d.ts +14 -1
  47. package/dist/layout/AppShell.d.ts.map +1 -1
  48. package/dist/layout/ModulePage.d.ts +11 -2
  49. package/dist/layout/ModulePage.d.ts.map +1 -1
  50. package/dist/layout/Sidebar.d.ts +8 -1
  51. package/dist/layout/Sidebar.d.ts.map +1 -1
  52. package/dist/layout/index.js +4 -4
  53. package/dist/lib/cn.d.ts.map +1 -0
  54. package/dist/preset.d.ts +3 -0
  55. package/dist/preset.d.ts.map +1 -0
  56. package/dist/primitives/data-table.d.ts +19 -4
  57. package/dist/primitives/data-table.d.ts.map +1 -1
  58. package/dist/primitives/date-picker.d.ts +4 -2
  59. package/dist/primitives/date-picker.d.ts.map +1 -1
  60. package/dist/primitives/filter-chip.d.ts +26 -0
  61. package/dist/primitives/filter-chip.d.ts.map +1 -0
  62. package/dist/primitives/header-tabs.d.ts +32 -0
  63. package/dist/primitives/header-tabs.d.ts.map +1 -0
  64. package/dist/primitives/hint.d.ts +25 -0
  65. package/dist/primitives/hint.d.ts.map +1 -0
  66. package/dist/primitives/index.d.ts +7 -0
  67. package/dist/primitives/index.d.ts.map +1 -1
  68. package/dist/primitives/index.js +4 -4
  69. package/dist/primitives/list-view.d.ts +14 -1
  70. package/dist/primitives/list-view.d.ts.map +1 -1
  71. package/dist/primitives/page-header.d.ts +23 -0
  72. package/dist/primitives/page-header.d.ts.map +1 -0
  73. package/dist/primitives/stat-tiles.d.ts +30 -0
  74. package/dist/primitives/stat-tiles.d.ts.map +1 -0
  75. package/dist/primitives/switch.d.ts +11 -0
  76. package/dist/primitives/switch.d.ts.map +1 -0
  77. package/dist/stores/index.d.ts +1 -0
  78. package/dist/stores/index.d.ts.map +1 -1
  79. package/dist/stores/page-header.store.d.ts +69 -0
  80. package/dist/stores/page-header.store.d.ts.map +1 -0
  81. package/package.json +4 -13
  82. package/dist/chunk-75EFDLST.js.map +0 -1
  83. package/dist/chunk-G6OKZ7WL.js.map +0 -1
  84. package/dist/chunk-KV3MHB6N.js.map +0 -1
  85. package/dist/chunk-OUPJDGKE.js.map +0 -1
  86. package/dist/chunk-PVIB724L.js.map +0 -1
  87. package/dist/chunk-S5KNM4D2.js.map +0 -1
  88. package/dist/chunk-YRVE4FOD.js.map +0 -1
  89. package/dist/utils/cn.d.ts.map +0 -1
  90. package/src/dashboard/ChartWidget.tsx +0 -50
  91. package/src/dashboard/ChartWidgetInner.tsx +0 -83
  92. package/src/dashboard/DashboardCanvas.tsx +0 -210
  93. package/src/dashboard/DashboardCustomizeMenu.tsx +0 -53
  94. package/src/dashboard/DashboardGrid.tsx +0 -35
  95. package/src/dashboard/DashboardRange.tsx +0 -62
  96. package/src/dashboard/GoldCard.tsx +0 -38
  97. package/src/dashboard/KpiCard.tsx +0 -238
  98. package/src/dashboard/OnboardingWidget.tsx +0 -140
  99. package/src/dashboard/TableWidget.tsx +0 -51
  100. package/src/dashboard/define.ts +0 -53
  101. package/src/dashboard/icon.ts +0 -13
  102. package/src/dashboard/index.ts +0 -38
  103. package/src/dashboard/layout.ts +0 -50
  104. package/src/dashboard/preferences.ts +0 -71
  105. package/src/dashboard/types.ts +0 -41
  106. package/src/icons.ts +0 -66
  107. package/src/index.ts +0 -238
  108. package/src/layout/AppShell.tsx +0 -987
  109. package/src/layout/ContentSplit.tsx +0 -153
  110. package/src/layout/ModulePage.tsx +0 -466
  111. package/src/layout/PageTransition.tsx +0 -57
  112. package/src/layout/SaveBar.tsx +0 -247
  113. package/src/layout/Sidebar.tsx +0 -535
  114. package/src/layout/Topbar.tsx +0 -462
  115. package/src/layout/index.ts +0 -39
  116. package/src/layout/useCrossModuleBack.ts +0 -30
  117. package/src/primitives/Avatar.tsx +0 -48
  118. package/src/primitives/SearchCombobox.tsx +0 -216
  119. package/src/primitives/badge.tsx +0 -68
  120. package/src/primitives/breadcrumb.tsx +0 -43
  121. package/src/primitives/button.tsx +0 -60
  122. package/src/primitives/card.tsx +0 -79
  123. package/src/primitives/checkbox.tsx +0 -53
  124. package/src/primitives/confirm-dialog.tsx +0 -125
  125. package/src/primitives/currency-input.tsx +0 -121
  126. package/src/primitives/data-table.tsx +0 -160
  127. package/src/primitives/date-picker.tsx +0 -225
  128. package/src/primitives/dropdown.tsx +0 -196
  129. package/src/primitives/index.ts +0 -87
  130. package/src/primitives/input.tsx +0 -50
  131. package/src/primitives/list-view.tsx +0 -252
  132. package/src/primitives/markdown-editor.tsx +0 -75
  133. package/src/primitives/markdown.tsx +0 -169
  134. package/src/primitives/modal.tsx +0 -156
  135. package/src/primitives/phone-link.tsx +0 -138
  136. package/src/primitives/popover.tsx +0 -30
  137. package/src/primitives/search-select.tsx +0 -314
  138. package/src/primitives/segmented-control.tsx +0 -66
  139. package/src/primitives/select.tsx +0 -157
  140. package/src/primitives/separator.tsx +0 -29
  141. package/src/primitives/sheet.tsx +0 -181
  142. package/src/primitives/skeleton.tsx +0 -46
  143. package/src/primitives/tabs.tsx +0 -53
  144. package/src/primitives/time-picker.tsx +0 -148
  145. package/src/primitives/toast.tsx +0 -46
  146. package/src/primitives/tooltip.tsx +0 -28
  147. package/src/stores/index.ts +0 -10
  148. package/src/stores/layout.store.ts +0 -31
  149. package/src/stores/theme.store.ts +0 -457
  150. package/src/styles.css +0 -461
  151. package/src/tailwind.ts +0 -43
  152. package/src/theme/index.ts +0 -94
  153. package/src/theme/preset.ts +0 -209
  154. package/src/theme/presets.ts +0 -106
  155. package/src/utils/cn.ts +0 -6
  156. /package/dist/{utils → lib}/cn.d.ts +0 -0
@@ -1,987 +0,0 @@
1
- import * as React from 'react'
2
- import {
3
- Menu,
4
- Home,
5
- Receipt,
6
- ArrowLeftRight,
7
- Calendar,
8
- CreditCard,
9
- Settings,
10
- MessageCircle,
11
- Wallet,
12
- PiggyBank,
13
- Plus,
14
- type LucideIcon,
15
- } from 'lucide-react'
16
- import { Sidebar, type NavigationItem, type SidebarUser, type SidebarProps } from './Sidebar'
17
- import { Topbar } from './Topbar'
18
- import { SaveBar, SaveBarProvider } from './SaveBar'
19
- import { useLayoutStore } from '../stores/layout.store'
20
- import { cn } from '../utils/cn'
21
-
22
- // ---------------------------------------------------------------------------
23
- // Module header slot — the page top-bar exposes a DOM node that module pages
24
- // (ModulePage 'tabs' variant) portal their title + sub-nav into, so the module
25
- // header lives in the shell's top-of-page container (GoHighLevel style).
26
- // ---------------------------------------------------------------------------
27
-
28
- const ModuleHeaderSlotContext = React.createContext<HTMLElement | null>(null)
29
-
30
- export function useModuleHeaderSlot(): HTMLElement | null {
31
- return React.useContext(ModuleHeaderSlotContext)
32
- }
33
-
34
- // Right-aligned slot for a page's primary action buttons (e.g. "New campaign").
35
- const ModuleHeaderActionsSlotContext = React.createContext<HTMLElement | null>(null)
36
-
37
- export function useModuleHeaderActionsSlot(): HTMLElement | null {
38
- return React.useContext(ModuleHeaderActionsSlotContext)
39
- }
40
-
41
- // ---------------------------------------------------------------------------
42
- // Types
43
- // ---------------------------------------------------------------------------
44
-
45
- export interface AppShellUser {
46
- fullName: string
47
- avatarUrl?: string
48
- email: string
49
- }
50
-
51
- // ---------------------------------------------------------------------------
52
- // Bottom-nav item model
53
- //
54
- // Two kinds share the mobile tab bar:
55
- // - route (default): a normal tab that navigates to `route`.
56
- // - action: a NON-route item rendered as a raised, elevated circular center
57
- // button (Mobills style). It fires `onBottomNavAction(id)` instead of
58
- // navigating — e.g. a global quick-add "+".
59
- // ---------------------------------------------------------------------------
60
-
61
- export interface BottomNavRouteItem {
62
- kind?: 'route'
63
- label: string
64
- icon: string
65
- route: string
66
- }
67
-
68
- export interface BottomNavActionItem {
69
- kind: 'action'
70
- /** Identifier passed to onBottomNavAction. */
71
- id: string
72
- icon: string
73
- /** Optional accessible label. */
74
- label?: string
75
- }
76
-
77
- export type BottomNavItem = BottomNavRouteItem | BottomNavActionItem
78
-
79
- /** Mobile header treatment (small screens, <md). Desktop is unaffected.
80
- * - 'minimal' : a small top header bar (the default / today's behavior).
81
- * - 'transparent' : NO header bar; content is edge-to-edge with a floating
82
- * profile avatar pinned top-right (calls onProfile).
83
- * - 'hidden' : no header bar and no floating avatar. */
84
- export type MobileHeaderVariant = 'transparent' | 'minimal' | 'hidden'
85
-
86
- export interface AppShellProps {
87
- /** Layout variant */
88
- variant: 'sidebar' | 'topbar' | 'minimal'
89
- /** Wrap the main content in an inset framed card. The sidebar is always
90
- * flush/full-height (GoHighLevel style). */
91
- contentFrame?: boolean
92
- navigation?: NavigationItem[]
93
- logo?: React.ReactNode
94
- user?: AppShellUser
95
- children?: React.ReactNode
96
- onNavigate?: (route: string) => void
97
- onSignOut?: () => void
98
- onProfile?: () => void
99
- onSettings?: () => void
100
- onBilling?: () => void
101
- /** Current-plan pill shown next to the user in the topbar menu. */
102
- userPlan?: { label: string; paid: boolean }
103
- /** i18n label for the billing/subscription user-menu item. */
104
- billingLabel?: string
105
- pageTitle?: string
106
- currentPath?: string
107
- userMenuExtras?: { label: string; icon?: React.ReactNode; onClick: () => void }[]
108
- orgSwitcher?: React.ReactNode
109
- topbarStart?: React.ReactNode
110
- topbarEnd?: React.ReactNode
111
- sidebarTopContent?: React.ReactNode
112
- sidebarFooterContent?: React.ReactNode
113
- bottomNav?: BottomNavItem[]
114
- /** Fired when a bottom-nav `action` item (e.g. the center "+") is tapped. */
115
- onBottomNavAction?: (id: string) => void
116
- /** Mobile header treatment (<md). Default: 'minimal'. Desktop unaffected. */
117
- mobileHeader?: MobileHeaderVariant
118
- /** Rendered in sidebar bottom user slot */
119
- userMenuSlot?: React.ReactNode
120
- /** Rendered in sidebar bottom notification slot */
121
- notificationSlot?: React.ReactNode
122
- /** Unread notification count */
123
- unreadCount?: number
124
- /** Optional headings above the sidebar nav sections (contextual sidebars). */
125
- sidebarSectionLabels?: { main?: string; secondary?: string; settings?: string }
126
- /** Identity of the current sidebar context — changing it animates the rail
127
- * swap as a push (new nav in, previous nav out). */
128
- sidebarNavKey?: string
129
- /** Push direction for a `sidebarNavKey` change. Default 'forward'. */
130
- sidebarNavDirection?: 'forward' | 'back'
131
- /**
132
- * Full-height column pinned to the right (the assistant dock, a tasks
133
- * panel). Rendered as a flex SIBLING of the layout, so the app narrows to
134
- * make room instead of being covered — the difference between a split view
135
- * and a modal that happens to be tall.
136
- *
137
- * Falsy renders nothing and costs nothing: no wrapper, no divider, the
138
- * layout element stands alone exactly as before.
139
- */
140
- rightRail?: React.ReactNode
141
- /** Whether the rail is showing. Separate from `rightRail` so the column can
142
- * animate its width instead of snapping when it closes. */
143
- rightRailOpen?: boolean
144
- /** Starting width in px. Ignored once the user drags. Default 380. */
145
- defaultRightRailWidth?: number
146
- /** Fired after every drag so the host can persist the preference. */
147
- onRightRailWidthChange?: (width: number) => void
148
- /** Drag bounds in px. Defaults 300 / 720. */
149
- rightRailMinWidth?: number
150
- rightRailMaxWidth?: number
151
- /** Accessible name for the drag handle. */
152
- rightRailResizeLabel?: string
153
- }
154
-
155
- // ---------------------------------------------------------------------------
156
- // Sidebar layout
157
- // ---------------------------------------------------------------------------
158
-
159
- function SidebarLayout({
160
- navigation = [],
161
- logo,
162
- user,
163
- children,
164
- onNavigate,
165
- onSignOut,
166
- onProfile,
167
- onSettings,
168
- onBilling,
169
- userMenuExtras: _userMenuExtras,
170
- orgSwitcher,
171
- sidebarTopContent,
172
- sidebarFooterContent,
173
- frame,
174
- currentPath,
175
- pageTitle,
176
- topbarStart,
177
- topbarEnd,
178
- userMenuSlot,
179
- notificationSlot,
180
- unreadCount = 0,
181
- hasBottomNav = false,
182
- mobileHeader = 'minimal',
183
- sidebarSectionLabels,
184
- sidebarNavKey,
185
- sidebarNavDirection,
186
- }: {
187
- navigation?: NavigationItem[]
188
- logo?: React.ReactNode
189
- user?: AppShellUser
190
- children?: React.ReactNode
191
- onNavigate?: (route: string) => void
192
- onSignOut?: () => void
193
- onProfile?: () => void
194
- onSettings?: () => void
195
- onBilling?: () => void
196
- userMenuExtras?: AppShellProps['userMenuExtras']
197
- orgSwitcher?: React.ReactNode
198
- sidebarTopContent?: React.ReactNode
199
- sidebarFooterContent?: React.ReactNode
200
- frame?: boolean
201
- currentPath?: string
202
- pageTitle?: string
203
- topbarStart?: React.ReactNode
204
- topbarEnd?: React.ReactNode
205
- userMenuSlot?: React.ReactNode
206
- notificationSlot?: React.ReactNode
207
- unreadCount?: number
208
- /** When a mobile bottom-nav bar is present, the mobile hamburger is
209
- * suppressed so the bottom nav is the primary mobile navigation. */
210
- hasBottomNav?: boolean
211
- /** Mobile header treatment (<md). Desktop keeps its top bar untouched. */
212
- mobileHeader?: MobileHeaderVariant
213
- sidebarSectionLabels?: AppShellProps['sidebarSectionLabels']
214
- sidebarNavKey?: string
215
- sidebarNavDirection?: 'forward' | 'back'
216
- }) {
217
- const sidebarCollapsed = useLayoutStore((s) => s.sidebarCollapsed)
218
- const setSidebarCollapsed = useLayoutStore((s) => s.setSidebarCollapsed)
219
- const [mobileMenuOpen, setMobileMenuOpen] = React.useState(false)
220
- const [headerSlot, setHeaderSlot] = React.useState<HTMLElement | null>(null)
221
- const [actionsSlot, setActionsSlot] = React.useState<HTMLElement | null>(null)
222
-
223
- return (
224
- <SaveBarProvider>
225
- <ModuleHeaderSlotContext.Provider value={headerSlot}>
226
- <ModuleHeaderActionsSlotContext.Provider value={actionsSlot}>
227
- <div className={cn(
228
- 'flex h-screen overflow-hidden bg-background',
229
- // Frame mode: the inset gap around the framed card takes the sidebar (menu)
230
- // color, so the white card reads as a panel floating on the same tone as the
231
- // left menu. The card's own md:m-2 margin reveals this behind it.
232
- frame && 'md:bg-sidebar',
233
- )}>
234
- {/* Desktop Sidebar — always flush / full-height (GoHighLevel style) */}
235
- <div className="hidden md:flex">
236
- <Sidebar
237
- navigation={navigation}
238
- logo={logo}
239
- collapsed={sidebarCollapsed}
240
- onToggle={() => setSidebarCollapsed(!sidebarCollapsed)}
241
- onNavigate={onNavigate}
242
- currentPath={currentPath}
243
- user={user}
244
- onSignOut={onSignOut}
245
- onProfile={onProfile}
246
- onSettings={onSettings}
247
- onBilling={onBilling}
248
- orgSwitcher={orgSwitcher}
249
- topContent={sidebarTopContent}
250
- footerContent={sidebarFooterContent}
251
- borderless={false}
252
- userMenuSlot={userMenuSlot}
253
- notificationSlot={notificationSlot}
254
- unreadCount={unreadCount}
255
- sectionLabels={sidebarSectionLabels}
256
- navKey={sidebarNavKey}
257
- navDirection={sidebarNavDirection}
258
- />
259
- </div>
260
-
261
- {/* Main content — optionally wrapped in an inset framed card.
262
- The inset/rounding is suppressed on mobile so the content stays
263
- edge-to-edge (the framed card only reads well on larger screens). */}
264
- <div className={cn(
265
- 'relative flex flex-1 flex-col overflow-hidden',
266
- frame && 'md:m-2 md:rounded-xl md:border md:border-border md:bg-card'
267
- )}>
268
- {/* Top bar — mobile hamburger + module header slot (title + sub-nav) + actions.
269
- On mobile the bar is suppressed for the 'transparent' / 'hidden' header
270
- treatments (content goes edge-to-edge); desktop keeps it always. The
271
- portal targets below stay mounted (display:none on mobile) so module
272
- pages can still portal their sub-nav on desktop without crashing. */}
273
- <div className={cn(
274
- 'flex min-h-14 shrink-0 items-center justify-between gap-3 border-b border-border bg-card px-4 py-2',
275
- mobileHeader !== 'minimal' && 'hidden md:flex',
276
- )}>
277
- <div className="flex min-w-0 flex-1 items-center gap-3">
278
- {/* Mobile hamburger — suppressed when a bottom-nav bar owns mobile
279
- navigation (mobile-first apps). Desktop sidebar is untouched. */}
280
- {!hasBottomNav && (
281
- <button
282
- onClick={() => setMobileMenuOpen(true)}
283
- className="inline-flex items-center justify-center rounded-md p-2 text-muted-foreground hover:bg-accent md:hidden"
284
- aria-label="Open menu"
285
- >
286
- <Menu className="h-5 w-5" />
287
- </button>
288
- )}
289
- {pageTitle && <h2 className="font-semibold text-lg">{pageTitle}</h2>}
290
- {topbarStart}
291
- {/* Module pages (tabs variant) portal their sub-nav here */}
292
- <div ref={setHeaderSlot} className="flex min-w-0 flex-1 items-center" />
293
- </div>
294
- <div className="flex shrink-0 items-center gap-2">
295
- {/* Pages portal their primary action buttons here */}
296
- <div ref={setActionsSlot} className="flex items-center gap-2" />
297
- {topbarEnd}
298
- </div>
299
- </div>
300
-
301
- {/* Page content */}
302
- <main className="min-w-0 flex-1 overflow-y-auto pb-16 md:pb-0">
303
- {children}
304
- </main>
305
-
306
- {/* App-wide floating SaveBar (spans the content column, not the sidebar) */}
307
- <SaveBar />
308
- </div>
309
-
310
- {/* Mobile drawer — animated, owns its own close transition. */}
311
- {mobileMenuOpen && (
312
- <MobileOverlay
313
- navigation={navigation}
314
- logo={logo}
315
- user={user}
316
- currentPath={currentPath}
317
- onNavigate={onNavigate}
318
- onSignOut={onSignOut}
319
- onProfile={onProfile}
320
- onSettings={onSettings}
321
- onBilling={onBilling}
322
- orgSwitcher={orgSwitcher}
323
- topContent={sidebarTopContent}
324
- footerContent={sidebarFooterContent}
325
- userMenuSlot={userMenuSlot}
326
- notificationSlot={notificationSlot}
327
- unreadCount={unreadCount}
328
- onClose={() => setMobileMenuOpen(false)}
329
- />
330
- )}
331
- </div>
332
- </ModuleHeaderActionsSlotContext.Provider>
333
- </ModuleHeaderSlotContext.Provider>
334
- </SaveBarProvider>
335
- )
336
- }
337
-
338
- // ---------------------------------------------------------------------------
339
- // Topbar layout
340
- // ---------------------------------------------------------------------------
341
-
342
- function TopbarLayout({
343
- navigation = [],
344
- logo,
345
- user,
346
- children,
347
- onNavigate,
348
- onSignOut,
349
- onProfile,
350
- onSettings,
351
- onBilling,
352
- userPlan,
353
- billingLabel,
354
- userMenuExtras,
355
- orgSwitcher,
356
- frame,
357
- currentPath,
358
- topbarStart,
359
- topbarEnd,
360
- notificationSlot,
361
- hasBottomNav = false,
362
- }: {
363
- navigation?: NavigationItem[]
364
- logo?: React.ReactNode
365
- user?: AppShellUser
366
- children?: React.ReactNode
367
- onNavigate?: (route: string) => void
368
- onSignOut?: () => void
369
- onProfile?: () => void
370
- onSettings?: () => void
371
- onBilling?: () => void
372
- userPlan?: AppShellProps['userPlan']
373
- billingLabel?: AppShellProps['billingLabel']
374
- userMenuExtras?: AppShellProps['userMenuExtras']
375
- /** Workspace switcher — rendered beside the logo (desktop) and in the mobile drawer. */
376
- orgSwitcher?: React.ReactNode
377
- frame?: boolean
378
- currentPath?: string
379
- topbarStart?: React.ReactNode
380
- topbarEnd?: React.ReactNode
381
- /** Bell/inbox slot — in the topbar layout it renders with the right-side actions. */
382
- notificationSlot?: React.ReactNode
383
- /** When a mobile bottom-nav bar is present, the mobile hamburger is
384
- * suppressed so the bottom nav is the primary mobile navigation. */
385
- hasBottomNav?: boolean
386
- }) {
387
- const [mobileMenuOpen, setMobileMenuOpen] = React.useState(false)
388
-
389
- // Topbar is full-bleed (edge to edge); only the content area gets the frame.
390
- return (
391
- <SaveBarProvider>
392
- <div className="relative flex h-screen flex-col overflow-hidden bg-background">
393
- <Topbar
394
- navigation={navigation}
395
- logo={logo}
396
- user={user}
397
- currentPath={currentPath}
398
- onNavigate={onNavigate}
399
- onSignOut={onSignOut}
400
- onProfile={onProfile}
401
- onSettings={onSettings}
402
- onBilling={onBilling}
403
- userPlan={userPlan}
404
- billingLabel={billingLabel}
405
- userMenuExtras={userMenuExtras}
406
- orgSwitcher={orgSwitcher}
407
- leftContent={topbarStart}
408
- rightContent={(topbarEnd || notificationSlot) ? <>{topbarEnd}{notificationSlot}</> : undefined}
409
- onMenuClick={hasBottomNav ? undefined : () => setMobileMenuOpen(true)}
410
- />
411
- {/* Frame inset/border only from md up, so mobile stays edge-to-edge. The
412
- inset gap behind the framed card is the header color (bg-sidebar), so the
413
- white card reads as a panel floating on the same navy as the topbar. */}
414
- {/* The frame is fixed to the viewport and the content scrolls INSIDE it,
415
- mirroring SidebarLayout. With `main` as the scroller the card grew with
416
- the page and its bottom edge rode off-screen. */}
417
- <main className={cn('flex min-w-0 min-h-0 flex-1 flex-col overflow-hidden', frame && 'md:bg-sidebar md:p-2')}>
418
- <div className={cn('flex min-w-0 min-h-0 flex-1 flex-col overflow-hidden', frame && 'md:rounded-xl md:border md:border-border md:bg-card')}>
419
- <div className="min-h-0 flex-1 overflow-y-auto pb-16 md:pb-0">
420
- {children}
421
- </div>
422
- </div>
423
- </main>
424
-
425
- {/* App-wide floating SaveBar */}
426
- <SaveBar />
427
-
428
- {/* Mobile drawer — topbar has no persistent sidebar, so the burger opens
429
- the same animated nav drawer used by the sidebar layout. */}
430
- {mobileMenuOpen && (
431
- <MobileOverlay
432
- navigation={navigation}
433
- logo={logo}
434
- user={user}
435
- currentPath={currentPath}
436
- onNavigate={onNavigate}
437
- onSignOut={onSignOut}
438
- onProfile={onProfile}
439
- onSettings={onSettings}
440
- onBilling={onBilling}
441
- orgSwitcher={orgSwitcher}
442
- userMenuSlot={user ? <DrawerUserRow user={user} /> : undefined}
443
- onClose={() => setMobileMenuOpen(false)}
444
- />
445
- )}
446
- </div>
447
- </SaveBarProvider>
448
- )
449
- }
450
-
451
- // Compact user identity for the mobile drawer's bottom row.
452
- function DrawerUserRow({ user }: { user: AppShellUser }) {
453
- const label = user.fullName || user.email
454
- return (
455
- <div className="flex min-w-0 items-center gap-2 px-1">
456
- {user.avatarUrl ? (
457
- <img src={user.avatarUrl} alt="" className="h-7 w-7 shrink-0 rounded-full object-cover" />
458
- ) : (
459
- <span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-primary text-xs font-semibold text-primary-foreground">
460
- {(label || '?').charAt(0).toUpperCase()}
461
- </span>
462
- )}
463
- <span className="truncate text-sm font-medium text-sidebar-foreground">{label}</span>
464
- </div>
465
- )
466
- }
467
-
468
- // ---------------------------------------------------------------------------
469
- // Minimal layout
470
- // ---------------------------------------------------------------------------
471
-
472
- function MinimalLayout({
473
- logo,
474
- user: _user,
475
- children,
476
- headerEnd,
477
- }: {
478
- logo?: React.ReactNode
479
- user?: AppShellUser
480
- children?: React.ReactNode
481
- headerEnd?: React.ReactNode
482
- }) {
483
- return (
484
- <div className="flex h-screen flex-col">
485
- <header className="flex h-14 shrink-0 items-center justify-between border-b border-border bg-card px-4 md:px-6">
486
- <div className="flex items-center gap-3">
487
- {logo ?? <span className="text-lg font-bold">App</span>}
488
- </div>
489
- {headerEnd && <div className="flex items-center gap-2">{headerEnd}</div>}
490
- </header>
491
- <main className="min-w-0 flex-1 overflow-y-auto pb-16 md:pb-0">
492
- {children}
493
- </main>
494
- </div>
495
- )
496
- }
497
-
498
- // ---------------------------------------------------------------------------
499
- // Mobile overlay drawer
500
- // ---------------------------------------------------------------------------
501
-
502
- type MobileOverlayProps = Omit<SidebarProps, 'collapsed' | 'onToggle'> & {
503
- onClose: () => void
504
- }
505
-
506
- function MobileOverlay({ onClose, onNavigate, ...sidebarProps }: MobileOverlayProps) {
507
- // Mount hidden, then flip to visible on the next frame so the CSS transition
508
- // plays on open. On dismiss we play the exit transition first, then unmount.
509
- const [visible, setVisible] = React.useState(false)
510
-
511
- React.useEffect(() => {
512
- const id = requestAnimationFrame(() => setVisible(true))
513
- return () => cancelAnimationFrame(id)
514
- }, [])
515
-
516
- const close = React.useCallback(() => {
517
- setVisible(false)
518
- window.setTimeout(onClose, 250)
519
- }, [onClose])
520
-
521
- // Lock body scroll while the drawer is open.
522
- React.useEffect(() => {
523
- const previous = document.body.style.overflow
524
- document.body.style.overflow = 'hidden'
525
- return () => {
526
- document.body.style.overflow = previous
527
- }
528
- }, [])
529
-
530
- // Dismiss on Escape.
531
- React.useEffect(() => {
532
- const onKey = (e: KeyboardEvent) => {
533
- if (e.key === 'Escape') close()
534
- }
535
- window.addEventListener('keydown', onKey)
536
- return () => window.removeEventListener('keydown', onKey)
537
- }, [close])
538
-
539
- return (
540
- <div className="fixed inset-0 z-[60] md:hidden">
541
- {/* Backdrop — fades in/out */}
542
- <div
543
- className={cn(
544
- 'absolute inset-0 bg-black/50 transition-opacity duration-300 ease-out',
545
- visible ? 'opacity-100' : 'opacity-0',
546
- )}
547
- onClick={close}
548
- />
549
- {/* Drawer — slides in from the left, reusing the real Sidebar for full
550
- design parity (icons, active state, org switcher, bottom user row). */}
551
- <div
552
- className={cn(
553
- 'absolute inset-y-0 left-0 shadow-2xl transition-transform duration-300 ease-out will-change-transform',
554
- visible ? 'translate-x-0' : '-translate-x-full',
555
- )}
556
- >
557
- <Sidebar
558
- {...sidebarProps}
559
- collapsed={false}
560
- onToggle={close}
561
- borderless
562
- onNavigate={(route) => {
563
- onNavigate?.(route)
564
- close()
565
- }}
566
- />
567
- </div>
568
- </div>
569
- )
570
- }
571
-
572
- // ---------------------------------------------------------------------------
573
- // Mobile bottom tab bar — rendered for every variant on small screens.
574
- // ui must NOT import from saas, so the icon lookup is a small local map (a
575
- // mirror of the saas icon convention). Unknown icon names fall back to Home.
576
- // ---------------------------------------------------------------------------
577
-
578
- const BOTTOM_NAV_ICONS: Record<string, LucideIcon> = {
579
- Home,
580
- Receipt,
581
- ArrowLeftRight,
582
- Calendar,
583
- CreditCard,
584
- Settings,
585
- Menu,
586
- MessageCircle,
587
- Wallet,
588
- PiggyBank,
589
- Plus,
590
- }
591
-
592
- // Floating profile avatar — mobile-only (md:hidden), pinned top-right over the
593
- // content. Used with the 'transparent' mobile header treatment so there is no
594
- // header bar but the user still has a one-tap route into their profile.
595
- function FloatingProfileAvatar({ user, onClick }: { user?: AppShellUser; onClick?: () => void }) {
596
- if (!user) return null
597
- const label = user.fullName || user.email
598
- return (
599
- <button
600
- type="button"
601
- onClick={onClick}
602
- aria-label="Profile"
603
- className="fixed right-3 top-3 z-40 md:hidden"
604
- >
605
- {user.avatarUrl ? (
606
- <img
607
- src={user.avatarUrl}
608
- alt=""
609
- className="h-9 w-9 rounded-full object-cover shadow-md ring-2 ring-background"
610
- />
611
- ) : (
612
- <span className="flex h-9 w-9 items-center justify-center rounded-full bg-primary text-sm font-semibold text-primary-foreground shadow-md ring-2 ring-background">
613
- {(label || '?').charAt(0).toUpperCase()}
614
- </span>
615
- )}
616
- </button>
617
- )
618
- }
619
-
620
- function MobileBottomNav({
621
- items,
622
- currentPath,
623
- onNavigate,
624
- onAction,
625
- }: {
626
- items: NonNullable<AppShellProps['bottomNav']>
627
- currentPath?: string
628
- onNavigate?: (route: string) => void
629
- onAction?: (id: string) => void
630
- }) {
631
- const path = currentPath ?? ''
632
- return (
633
- <nav className="fixed inset-x-0 bottom-0 z-50 flex h-16 items-center justify-around border-t border-border bg-background md:hidden">
634
- {items.map((item) => {
635
- // Elevated center ACTION button (Mobills style) — raised above the bar,
636
- // circular, primary-filled; fires onBottomNavAction instead of routing.
637
- if (item.kind === 'action') {
638
- const ActionIcon = BOTTOM_NAV_ICONS[item.icon] ?? Plus
639
- return (
640
- <div key={item.id} className="flex flex-1 items-start justify-center">
641
- <button
642
- type="button"
643
- onClick={() => onAction?.(item.id)}
644
- aria-label={item.label ?? 'Add'}
645
- className="-mt-6 flex h-14 w-14 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg shadow-primary/30 ring-4 ring-background transition-transform active:scale-95"
646
- >
647
- <ActionIcon className="h-6 w-6" />
648
- </button>
649
- </div>
650
- )
651
- }
652
- const isActive =
653
- item.route === '/'
654
- ? path === '/' || path === ''
655
- : path === item.route || path.startsWith(item.route + '/')
656
- const Icon = BOTTOM_NAV_ICONS[item.icon] ?? Home
657
- return (
658
- <button
659
- key={item.route}
660
- type="button"
661
- onClick={() => onNavigate?.(item.route)}
662
- className={cn(
663
- 'flex flex-1 flex-col items-center justify-center gap-0.5 px-1 py-1 transition-colors',
664
- isActive ? 'text-primary' : 'text-muted-foreground',
665
- )}
666
- aria-current={isActive ? 'page' : undefined}
667
- >
668
- <Icon className="h-5 w-5" />
669
- <span className="text-xs">{item.label}</span>
670
- </button>
671
- )
672
- })}
673
- </nav>
674
- )
675
- }
676
-
677
- // ---------------------------------------------------------------------------
678
- // AppShell — main entry point
679
- // ---------------------------------------------------------------------------
680
-
681
- export function AppShell({
682
- variant,
683
- contentFrame,
684
- navigation = [],
685
- logo,
686
- user,
687
- children,
688
- onNavigate,
689
- onSignOut,
690
- onProfile,
691
- onSettings,
692
- onBilling,
693
- userPlan,
694
- billingLabel,
695
- pageTitle,
696
- currentPath,
697
- userMenuExtras,
698
- orgSwitcher,
699
- topbarStart,
700
- topbarEnd,
701
- sidebarTopContent,
702
- sidebarFooterContent,
703
- bottomNav,
704
- onBottomNavAction,
705
- mobileHeader = 'minimal',
706
- userMenuSlot,
707
- notificationSlot,
708
- unreadCount = 0,
709
- sidebarSectionLabels,
710
- sidebarNavKey,
711
- sidebarNavDirection,
712
- rightRail,
713
- rightRailOpen = true,
714
- defaultRightRailWidth,
715
- onRightRailWidthChange,
716
- rightRailMinWidth,
717
- rightRailMaxWidth,
718
- rightRailResizeLabel,
719
- }: AppShellProps) {
720
- const hasBottomNav = !!bottomNav?.length
721
- const bottomBar = hasBottomNav ? (
722
- <MobileBottomNav
723
- items={bottomNav!}
724
- currentPath={currentPath}
725
- onNavigate={onNavigate}
726
- onAction={onBottomNavAction}
727
- />
728
- ) : null
729
- // Transparent mobile header → no bar, a floating avatar top-right instead.
730
- const floatingAvatar =
731
- mobileHeader === 'transparent' && user ? (
732
- <FloatingProfileAvatar user={user} onClick={onProfile} />
733
- ) : null
734
-
735
- let layoutElement: React.ReactNode
736
- switch (variant) {
737
- case 'sidebar':
738
- layoutElement = (
739
- <SidebarLayout
740
- navigation={navigation}
741
- logo={logo}
742
- user={user}
743
- onNavigate={onNavigate}
744
- onSignOut={onSignOut}
745
- onProfile={onProfile}
746
- onSettings={onSettings}
747
- onBilling={onBilling}
748
- userMenuExtras={userMenuExtras}
749
- orgSwitcher={orgSwitcher}
750
- sidebarTopContent={sidebarTopContent}
751
- sidebarFooterContent={sidebarFooterContent}
752
- frame={contentFrame}
753
- currentPath={currentPath}
754
- pageTitle={pageTitle}
755
- topbarStart={topbarStart}
756
- topbarEnd={topbarEnd}
757
- userMenuSlot={userMenuSlot}
758
- notificationSlot={notificationSlot}
759
- unreadCount={unreadCount}
760
- hasBottomNav={hasBottomNav}
761
- mobileHeader={mobileHeader}
762
- sidebarSectionLabels={sidebarSectionLabels}
763
- sidebarNavKey={sidebarNavKey}
764
- sidebarNavDirection={sidebarNavDirection}
765
- >
766
- {children}
767
- </SidebarLayout>
768
- )
769
- break
770
-
771
- case 'topbar':
772
- layoutElement = (
773
- <TopbarLayout
774
- navigation={navigation}
775
- logo={logo}
776
- user={user}
777
- onNavigate={onNavigate}
778
- onSignOut={onSignOut}
779
- onProfile={onProfile}
780
- onSettings={onSettings}
781
- onBilling={onBilling}
782
- userPlan={userPlan}
783
- billingLabel={billingLabel}
784
- userMenuExtras={userMenuExtras}
785
- orgSwitcher={orgSwitcher}
786
- frame={contentFrame}
787
- currentPath={currentPath}
788
- topbarStart={topbarStart}
789
- topbarEnd={topbarEnd}
790
- notificationSlot={notificationSlot}
791
- hasBottomNav={hasBottomNav}
792
- >
793
- {children}
794
- </TopbarLayout>
795
- )
796
- break
797
-
798
- case 'minimal':
799
- layoutElement = (
800
- <MinimalLayout logo={logo} user={user} headerEnd={topbarEnd}>
801
- {children}
802
- </MinimalLayout>
803
- )
804
- break
805
-
806
- default: {
807
- const _exhaustive: never = variant
808
- return null
809
- }
810
- }
811
-
812
- return (
813
- <>
814
- {rightRail ? (
815
- <SplitWithRightRail
816
- rail={rightRail}
817
- open={rightRailOpen}
818
- frame={contentFrame}
819
- defaultWidth={defaultRightRailWidth}
820
- onWidthChange={onRightRailWidthChange}
821
- minWidth={rightRailMinWidth}
822
- maxWidth={rightRailMaxWidth}
823
- resizeLabel={rightRailResizeLabel}
824
- >
825
- {layoutElement}
826
- </SplitWithRightRail>
827
- ) : (
828
- layoutElement
829
- )}
830
- {floatingAvatar}
831
- {bottomBar}
832
- </>
833
- )
834
- }
835
-
836
- // ---------------------------------------------------------------------------
837
- // SplitWithRightRail — the app on the left, a full-height rail on the right,
838
- // a divider the user owns between them.
839
- //
840
- // The layouts all root at `h-screen overflow-hidden`, so putting them in a
841
- // `flex-1 min-w-0` cell keeps their height and lets them shrink. `min-w-0` is
842
- // load-bearing: without it a flex child refuses to go below its content width
843
- // and the rail gets pushed off-screen instead of the content reflowing.
844
- // ---------------------------------------------------------------------------
845
-
846
- function SplitWithRightRail({
847
- children,
848
- rail,
849
- open,
850
- frame,
851
- defaultWidth = 380,
852
- onWidthChange,
853
- minWidth = 300,
854
- maxWidth = 720,
855
- resizeLabel = 'Resize panel',
856
- }: {
857
- children: React.ReactNode
858
- rail: React.ReactNode
859
- open: boolean
860
- /** Frame mode: the gutter between content and rail takes the sidebar tone,
861
- * the same surface the content card floats on. Without this the split shows
862
- * a bare strip of page background and the rail reads as bolted on. */
863
- frame?: boolean
864
- defaultWidth?: number
865
- onWidthChange?: (width: number) => void
866
- minWidth?: number
867
- maxWidth?: number
868
- resizeLabel?: string
869
- }) {
870
- const [width, setWidth] = React.useState(defaultWidth)
871
- const [dragging, setDragging] = React.useState(false)
872
-
873
- const clamp = React.useCallback(
874
- (value: number) => Math.min(maxWidth, Math.max(minWidth, value)),
875
- [minWidth, maxWidth],
876
- )
877
-
878
- // The app follows the pointer to the pixel, so the drag runs on window
879
- // listeners rather than on the handle: the cursor routinely outruns a 6px
880
- // target, and a resize that stops when you move too fast feels broken.
881
- React.useEffect(() => {
882
- if (!dragging) return
883
- const onMove = (event: PointerEvent) => {
884
- event.preventDefault()
885
- setWidth(clamp(window.innerWidth - event.clientX))
886
- }
887
- const stop = () => setDragging(false)
888
- window.addEventListener('pointermove', onMove)
889
- window.addEventListener('pointerup', stop)
890
- window.addEventListener('pointercancel', stop)
891
- // Text selection across the whole app while dragging a divider is pure
892
- // noise, and iframes/canvases would otherwise swallow the pointer.
893
- const previousSelect = document.body.style.userSelect
894
- const previousCursor = document.body.style.cursor
895
- document.body.style.userSelect = 'none'
896
- document.body.style.cursor = 'col-resize'
897
- return () => {
898
- window.removeEventListener('pointermove', onMove)
899
- window.removeEventListener('pointerup', stop)
900
- window.removeEventListener('pointercancel', stop)
901
- document.body.style.userSelect = previousSelect
902
- document.body.style.cursor = previousCursor
903
- }
904
- }, [dragging, clamp])
905
-
906
- // Report only on release: firing per pointer frame would write to
907
- // localStorage ~60×/second for one gesture.
908
- React.useEffect(() => {
909
- if (dragging) return
910
- onWidthChange?.(width)
911
- // eslint-disable-next-line react-hooks/exhaustive-deps
912
- }, [dragging])
913
-
914
- const nudge = (delta: number) => setWidth((current) => clamp(current + delta))
915
-
916
- return (
917
- // Sidebar tone always: the gutter and the divider sit in the shell's
918
- // chrome, next to the menu — painting them page-white put a bright strip
919
- // between the dark chrome and the rail, which read as a seam rather than
920
- // as space.
921
- <div className={cn('flex h-screen w-full overflow-hidden bg-sidebar', !frame && 'md:bg-background')}>
922
- <div className="min-w-0 flex-1">{children}</div>
923
-
924
- {/* Divider — desktop only; on a phone the rail is a sheet over the app,
925
- and there is nothing to divide. */}
926
- <div
927
- role="separator"
928
- aria-orientation="vertical"
929
- aria-label={resizeLabel}
930
- aria-valuenow={width}
931
- aria-valuemin={minWidth}
932
- aria-valuemax={maxWidth}
933
- tabIndex={0}
934
- onPointerDown={(event) => {
935
- event.preventDefault()
936
- setDragging(true)
937
- }}
938
- onDoubleClick={() => setWidth(clamp(defaultWidth))}
939
- onKeyDown={(event) => {
940
- // Keyboard resize: a pointer-only divider makes the split view
941
- // unusable for anyone not using a mouse.
942
- if (event.key === 'ArrowLeft') { event.preventDefault(); nudge(24) }
943
- else if (event.key === 'ArrowRight') { event.preventDefault(); nudge(-24) }
944
- else if (event.key === 'Home') { event.preventDefault(); setWidth(maxWidth) }
945
- else if (event.key === 'End') { event.preventDefault(); setWidth(minWidth) }
946
- }}
947
- className={cn(
948
- 'group relative hidden w-1 shrink-0 cursor-col-resize focus-visible:outline-none',
949
- open && 'md:block',
950
- )}
951
- >
952
- {/* Invisible at rest: the gutter's own tone already separates the two
953
- panes, and a permanent rule there just adds a line to look at.
954
- It appears when you reach for it. */}
955
- <span
956
- className={cn(
957
- 'absolute inset-y-6 left-1/2 w-px -translate-x-1/2 rounded-full bg-transparent transition-colors',
958
- 'group-hover:bg-primary/60 group-focus-visible:bg-primary',
959
- dragging && 'bg-primary',
960
- )}
961
- />
962
- {/* Hit area wider than the line: 1px is honest visually and hostile
963
- to aim for. */}
964
- <span className="absolute inset-y-0 -left-1.5 -right-1.5" />
965
- </div>
966
-
967
- {/* Rendered once: two copies would mount the rail's contents twice. On a
968
- phone the box collapses to zero width and the rail positions itself
969
- `fixed` over the screen. The frame inset is padding HERE rather than
970
- margin on the rail — a full-height child plus vertical margins
971
- overflows its own box. */}
972
- <aside
973
- style={{ ['--fayz-rail-w' as string]: `${width}px` }}
974
- className={cn(
975
- 'w-0 shrink-0 overflow-hidden',
976
- // Animating WIDTH is what makes the app glide back instead of
977
- // snapping; skipped mid-drag so the divider stays glued to the cursor.
978
- !dragging && 'motion-safe:transition-[width] motion-safe:duration-200 motion-safe:ease-out',
979
- open && 'md:w-[var(--fayz-rail-w)]',
980
- frame && 'md:py-2 md:pr-2',
981
- )}
982
- >
983
- {rail}
984
- </aside>
985
- </div>
986
- )
987
- }