@fayz-ai/ui 0.8.2 → 0.8.4

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 (63) hide show
  1. package/dist/{chunk-XAOGMWLC.cjs → chunk-7ZQQZD4Q.cjs} +354 -80
  2. package/dist/chunk-7ZQQZD4Q.cjs.map +1 -0
  3. package/dist/{chunk-IEYCOJTY.cjs → chunk-B3BTZDTA.cjs} +10 -42
  4. package/dist/chunk-B3BTZDTA.cjs.map +1 -0
  5. package/dist/{chunk-O5F4LLSL.js → chunk-DYMBFHD7.js} +8 -7
  6. package/dist/chunk-DYMBFHD7.js.map +1 -0
  7. package/dist/{chunk-FY7OZNKU.js → chunk-G6OKZ7WL.js} +35 -8
  8. package/dist/chunk-G6OKZ7WL.js.map +1 -0
  9. package/dist/{chunk-W5O2X277.js → chunk-M34ACV5E.js} +27 -4
  10. package/dist/chunk-M34ACV5E.js.map +1 -0
  11. package/dist/{chunk-NWPMNPGR.cjs → chunk-MUGRP5DD.cjs} +57 -34
  12. package/dist/chunk-MUGRP5DD.cjs.map +1 -0
  13. package/dist/{chunk-ODY3GZ3Y.cjs → chunk-NZV47HWO.cjs} +36 -7
  14. package/dist/chunk-NZV47HWO.cjs.map +1 -0
  15. package/dist/{chunk-J3IUFUNR.js → chunk-R4LPSXTT.js} +8 -38
  16. package/dist/chunk-R4LPSXTT.js.map +1 -0
  17. package/dist/{chunk-TDGSY5W6.cjs → chunk-R4SQ2GVD.cjs} +8 -7
  18. package/dist/chunk-R4SQ2GVD.cjs.map +1 -0
  19. package/dist/{chunk-MZZMZYSE.js → chunk-VVQ45ZDZ.js} +357 -83
  20. package/dist/chunk-VVQ45ZDZ.js.map +1 -0
  21. package/dist/dashboard/OnboardingWidget.d.ts.map +1 -1
  22. package/dist/dashboard/index.cjs +26 -26
  23. package/dist/dashboard/index.js +2 -2
  24. package/dist/index.cjs +137 -137
  25. package/dist/index.js +5 -5
  26. package/dist/layout/AppShell.d.ts +41 -1
  27. package/dist/layout/AppShell.d.ts.map +1 -1
  28. package/dist/layout/ModulePage.d.ts +9 -6
  29. package/dist/layout/ModulePage.d.ts.map +1 -1
  30. package/dist/layout/Sidebar.d.ts +24 -1
  31. package/dist/layout/Sidebar.d.ts.map +1 -1
  32. package/dist/layout/Topbar.d.ts +13 -1
  33. package/dist/layout/Topbar.d.ts.map +1 -1
  34. package/dist/layout/index.cjs +19 -19
  35. package/dist/layout/index.js +1 -1
  36. package/dist/primitives/data-table.d.ts.map +1 -1
  37. package/dist/primitives/index.cjs +83 -83
  38. package/dist/primitives/index.js +2 -2
  39. package/dist/primitives/search-select.d.ts +3 -1
  40. package/dist/primitives/search-select.d.ts.map +1 -1
  41. package/dist/theme/index.cjs +8 -8
  42. package/dist/theme/index.d.ts.map +1 -1
  43. package/dist/theme/index.js +1 -1
  44. package/package.json +3 -3
  45. package/src/dashboard/OnboardingWidget.tsx +41 -1
  46. package/src/layout/AppShell.tsx +255 -7
  47. package/src/layout/ModulePage.tsx +32 -3
  48. package/src/layout/Sidebar.tsx +198 -61
  49. package/src/layout/Topbar.tsx +51 -4
  50. package/src/primitives/data-table.tsx +10 -1
  51. package/src/primitives/search-select.tsx +12 -4
  52. package/src/styles.css +34 -0
  53. package/src/theme/index.ts +9 -6
  54. package/dist/chunk-FY7OZNKU.js.map +0 -1
  55. package/dist/chunk-IEYCOJTY.cjs.map +0 -1
  56. package/dist/chunk-J3IUFUNR.js.map +0 -1
  57. package/dist/chunk-MZZMZYSE.js.map +0 -1
  58. package/dist/chunk-NWPMNPGR.cjs.map +0 -1
  59. package/dist/chunk-O5F4LLSL.js.map +0 -1
  60. package/dist/chunk-ODY3GZ3Y.cjs.map +0 -1
  61. package/dist/chunk-TDGSY5W6.cjs.map +0 -1
  62. package/dist/chunk-W5O2X277.js.map +0 -1
  63. package/dist/chunk-XAOGMWLC.cjs.map +0 -1
@@ -1,6 +1,8 @@
1
1
  import * as React from 'react'
2
2
  import * as Tooltip from '@radix-ui/react-tooltip'
3
3
  import {
4
+ Crown,
5
+ ExternalLink,
4
6
  Home,
5
7
  Users,
6
8
  Settings,
@@ -24,6 +26,7 @@ import {
24
26
  ShoppingBag,
25
27
  Target,
26
28
  Wrench,
29
+ Truck,
27
30
  ClipboardList,
28
31
  ListChecks,
29
32
  Briefcase,
@@ -82,6 +85,8 @@ import {
82
85
  Inbox,
83
86
  Star,
84
87
  TrendingUp,
88
+ GraduationCap,
89
+ Repeat,
85
90
  UserCheck,
86
91
  UserPlus,
87
92
  UserX,
@@ -89,6 +94,11 @@ import {
89
94
  Boxes,
90
95
  Clock3,
91
96
  FileCheck2,
97
+ User,
98
+ Palette,
99
+ ShieldCheck,
100
+ SlidersHorizontal,
101
+ Puzzle,
92
102
  type LucideIcon,
93
103
  } from 'lucide-react'
94
104
  import { cn } from '../utils/cn'
@@ -102,6 +112,14 @@ export interface NavigationItem {
102
112
  section: 'main' | 'secondary' | 'settings'
103
113
  badge?: string | number
104
114
  permission?: { feature: string; action: 'read' | 'create' | 'edit' | 'delete' }
115
+ /** Freemium discovery: the user's role allows this item, but their plan does
116
+ * not entitle the feature. The item stays visible with a small Crown badge
117
+ * and, on click, leads to the plan's UpgradePrompt. */
118
+ premium?: boolean
119
+ /** Sales-channel / external link: when set, the item opens this URL in a new
120
+ * tab instead of navigating an internal route. Rendered as an anchor with an
121
+ * external-link glyph. `route` is ignored for matching when `href` is set. */
122
+ href?: string
105
123
  children?: NavigationItem[]
106
124
  }
107
125
 
@@ -135,20 +153,42 @@ export interface SidebarProps {
135
153
  notificationSlot?: React.ReactNode
136
154
  /** Unread notification count for the bell indicator */
137
155
  unreadCount?: number
156
+ /** Optional headings above each nav section. Used by contextual sidebars
157
+ * (e.g. /settings swaps the rail for "Settings links" + "Plugins"); the
158
+ * normal app rail leaves them undefined and renders no headings. */
159
+ sectionLabels?: { main?: string; secondary?: string; settings?: string }
160
+ /** Identity of the current rail context. When it changes, the rail animates
161
+ * as a push: the new nav slides in while the previous one slides out.
162
+ * Leave undefined for a static rail (no swap animation). */
163
+ navKey?: string
164
+ /** Push direction for a `navKey` change. 'forward' pulls the new rail in
165
+ * from the right (drilling in), 'back' from the left (returning). */
166
+ navDirection?: 'forward' | 'back'
138
167
  }
139
168
 
140
169
  const ICON_MAP: Record<string, LucideIcon> = {
141
170
  Home, Users, Settings, CreditCard, Bell, Calendar, CalendarClock, Package, Activity, BarChart3,
142
- FileText, Mail, DollarSign, Megaphone, ShoppingCart, Target, Wrench,
171
+ FileText, Mail, DollarSign, Megaphone, ShoppingCart, Target, Wrench, Truck,
143
172
  Receipt, ShoppingBag, ClipboardList, ListChecks, Briefcase, UserCog, BookOpen, BookOpenCheck, MessageCircle, Globe,
144
173
  Percent, Tag, Tags, Camera, UtensilsCrossed, MapPin, Map, Handshake, Contact,
145
174
  Building2, Filter, Plus, List, ListPlus, Search, Shield, Ban, Clock, Dog, Cat, PawPrint, Heart, FolderOpen, LayoutTemplate, LeafyGreen, Apple, Egg, Wheat,
146
175
  PartyPopper, Radio, CalendarDays, CalendarCheck2, CalendarX, BadgeDollarSign, Banknote, Landmark, CircleDollarSign, Layers, Music, Eye, ListMusic, Disc3, UsersRound, Mic,
147
176
  Zap, Award, LayoutDashboard, MessageSquare, Inbox, Star, TrendingUp, UserCheck, UserPlus, UserX, Workflow, Boxes, Clock3, FileCheck2,
177
+ GraduationCap, Repeat,
178
+ User, Palette, ShieldCheck, SlidersHorizontal, Puzzle,
148
179
  }
149
180
 
150
181
  export { ICON_MAP }
151
182
 
183
+ /** Kept in sync with --saas-rail-duration in styles.css. */
184
+ const RAIL_PUSH_MS = 260
185
+
186
+ /** Layout effect on the client, plain effect during SSR (avoids the warning).
187
+ * The rail push needs the layout variant: the animation classes must be on
188
+ * the DOM before paint, or the new rail shows one frame at its final spot. */
189
+ const useIsomorphicLayoutEffect =
190
+ typeof window !== 'undefined' ? React.useLayoutEffect : React.useEffect
191
+
152
192
  function getIcon(name: string): LucideIcon {
153
193
  return ICON_MAP[name] ?? Home
154
194
  }
@@ -165,30 +205,46 @@ function NavItem({
165
205
  onNavigate: (route: string) => void
166
206
  }) {
167
207
  const Icon = getIcon(item.icon)
208
+ const isExternal = !!item.href
168
209
 
169
- const content = (
170
- <button
171
- onClick={() => onNavigate(item.route)}
172
- className={cn(
173
- 'flex w-full items-center gap-3 rounded-md px-2.5 py-1.5 text-[13px] font-medium transition-colors',
174
- 'hover:bg-sidebar-accent/60 hover:text-sidebar-accent-foreground',
175
- isActive
176
- ? 'bg-sidebar-accent font-semibold text-sidebar-accent-foreground shadow-sm'
177
- : 'text-sidebar-foreground/80',
178
- collapsed && 'justify-center px-2',
179
- )}
180
- >
210
+ const className = cn(
211
+ 'flex w-full items-center gap-3 rounded-md px-2.5 py-1.5 text-[13px] font-medium transition-colors',
212
+ 'hover:bg-sidebar-accent/60 hover:text-sidebar-accent-foreground',
213
+ isActive
214
+ ? 'bg-sidebar-accent font-semibold text-sidebar-accent-foreground shadow-sm'
215
+ : 'text-sidebar-foreground/80',
216
+ collapsed && 'justify-center px-2',
217
+ )
218
+
219
+ const inner = (
220
+ <>
181
221
  <Icon className="h-4 w-4 shrink-0" />
182
222
  {!collapsed && (
183
223
  <>
184
224
  <span className="flex-1 truncate text-left">{item.label}</span>
225
+ {isExternal && (
226
+ <ExternalLink className="ml-auto h-3.5 w-3.5 shrink-0 opacity-50" aria-hidden />
227
+ )}
228
+ {item.premium && (
229
+ <Crown className={cn('h-3.5 w-3.5 shrink-0 text-amber-500', !isExternal && 'ml-auto')} aria-hidden />
230
+ )}
185
231
  {item.badge !== undefined && (
186
- <span className="ml-auto rounded-full bg-primary px-2 py-0.5 text-xs font-semibold text-primary-foreground">
232
+ <span className={cn('rounded-full bg-primary px-2 py-0.5 text-xs font-semibold text-primary-foreground', !isExternal && !item.premium && 'ml-auto')}>
187
233
  {item.badge}
188
234
  </span>
189
235
  )}
190
236
  </>
191
237
  )}
238
+ </>
239
+ )
240
+
241
+ const content = isExternal ? (
242
+ <a href={item.href} target="_blank" rel="noopener noreferrer" className={className}>
243
+ {inner}
244
+ </a>
245
+ ) : (
246
+ <button onClick={() => onNavigate(item.route)} className={className}>
247
+ {inner}
192
248
  </button>
193
249
  )
194
250
 
@@ -203,6 +259,9 @@ function NavItem({
203
259
  className="fayz-glass-surface z-50 rounded-md bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md"
204
260
  >
205
261
  {item.label}
262
+ {item.premium && (
263
+ <Crown className="ml-1.5 inline h-3 w-3 text-amber-500" aria-hidden />
264
+ )}
206
265
  {item.badge !== undefined && (
207
266
  <span className="ml-2 rounded-full bg-primary px-1.5 py-0.5 text-xs text-primary-foreground">
208
267
  {item.badge}
@@ -302,19 +361,32 @@ function NavGroup({
302
361
  {item.children?.map((child) => {
303
362
  const ChildIcon = getIcon(child.icon)
304
363
  const isActive = child.route === activeRoute
305
- return (
306
- <button
307
- key={child.id}
308
- onClick={() => onNavigate(child.route)}
309
- className={cn(
310
- 'flex w-full items-center gap-2.5 rounded-md px-2 py-1.5 text-sm transition-colors',
311
- isActive
312
- ? 'font-medium text-sidebar-accent-foreground'
313
- : 'text-sidebar-muted hover:text-sidebar-accent-foreground'
314
- )}
315
- >
364
+ const childExternal = !!child.href
365
+ const childClass = cn(
366
+ 'flex w-full items-center gap-2.5 rounded-md px-2 py-1.5 text-sm transition-colors',
367
+ isActive
368
+ ? 'font-medium text-sidebar-accent-foreground'
369
+ : 'text-sidebar-muted hover:text-sidebar-accent-foreground'
370
+ )
371
+ const childInner = (
372
+ <>
316
373
  <ChildIcon className="h-3.5 w-3.5 shrink-0" />
317
- <span className="truncate">{child.label}</span>
374
+ <span className="flex-1 truncate text-left">{child.label}</span>
375
+ {childExternal && (
376
+ <ExternalLink className="h-3 w-3 shrink-0 opacity-50" aria-hidden />
377
+ )}
378
+ {child.premium && (
379
+ <Crown className="h-3 w-3 shrink-0 text-amber-500" aria-hidden />
380
+ )}
381
+ </>
382
+ )
383
+ return childExternal ? (
384
+ <a key={child.id} href={child.href} target="_blank" rel="noopener noreferrer" className={childClass}>
385
+ {childInner}
386
+ </a>
387
+ ) : (
388
+ <button key={child.id} onClick={() => onNavigate(child.route)} className={childClass}>
389
+ {childInner}
318
390
  </button>
319
391
  )
320
392
  })}
@@ -339,6 +411,9 @@ export function Sidebar({
339
411
  userMenuSlot,
340
412
  notificationSlot,
341
413
  unreadCount = 0,
414
+ sectionLabels,
415
+ navKey,
416
+ navDirection = 'forward',
342
417
  }: SidebarProps) {
343
418
  const currentPath = currentPathProp
344
419
  const setCommandPaletteOpen = useLayoutStore((s) => s.setCommandPaletteOpen)
@@ -355,6 +430,76 @@ export function Sidebar({
355
430
  onNavigate?.(route)
356
431
  }
357
432
 
433
+ const sectionHeading = (label?: string) =>
434
+ label && !collapsed ? (
435
+ <p className="px-2.5 pb-1 pt-1 text-[10px] font-semibold uppercase tracking-wider text-sidebar-muted">
436
+ {label}
437
+ </p>
438
+ ) : null
439
+
440
+ const renderItem = (item: NavigationItem) =>
441
+ item.children && item.children.length > 0 ? (
442
+ <NavGroup key={item.id} item={item} collapsed={collapsed} activeRoute={activeRoute} onNavigate={handleNavigate} />
443
+ ) : (
444
+ <NavItem
445
+ key={item.id}
446
+ item={item}
447
+ collapsed={collapsed}
448
+ isActive={item.route === activeRoute}
449
+ onNavigate={handleNavigate}
450
+ />
451
+ )
452
+
453
+ // The swappable part of the rail (top slot + both nav sections). Kept as one
454
+ // node so a context change can snapshot the outgoing rail and animate it out.
455
+ const navBody = (
456
+ <>
457
+ {topContent && (
458
+ <div className="mb-4 space-y-2">
459
+ {React.isValidElement(topContent)
460
+ ? React.cloneElement(topContent as React.ReactElement<any>, { collapsed })
461
+ : topContent}
462
+ </div>
463
+ )}
464
+ {mainItems.length > 0 && (
465
+ <div className="space-y-1">
466
+ {sectionHeading(sectionLabels?.main)}
467
+ {mainItems.map(renderItem)}
468
+ </div>
469
+ )}
470
+ {secondaryItems.length > 0 && (
471
+ <div className="mt-6 space-y-1">
472
+ {sectionHeading(sectionLabels?.secondary)}
473
+ {secondaryItems.map(renderItem)}
474
+ </div>
475
+ )}
476
+ </>
477
+ )
478
+
479
+ // Rail push: when `navKey` changes, hold the previous rail on screen for one
480
+ // animation cycle. `lastBodyRef` is refreshed after every render, so the
481
+ // snapshot is the rail exactly as it looked before the swap (correct active
482
+ // item included) rather than a stale one.
483
+ const [outgoing, setOutgoing] = React.useState<{ body: React.ReactNode; direction: 'forward' | 'back' } | null>(null)
484
+ const lastBodyRef = React.useRef<React.ReactNode>(null)
485
+ const lastKeyRef = React.useRef(navKey)
486
+ const timerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)
487
+
488
+ useIsomorphicLayoutEffect(() => {
489
+ if (lastKeyRef.current !== navKey) {
490
+ const previousBody = lastBodyRef.current
491
+ lastKeyRef.current = navKey
492
+ if (previousBody) {
493
+ setOutgoing({ body: previousBody, direction: navDirection })
494
+ if (timerRef.current) clearTimeout(timerRef.current)
495
+ timerRef.current = setTimeout(() => setOutgoing(null), RAIL_PUSH_MS)
496
+ }
497
+ }
498
+ lastBodyRef.current = navBody
499
+ })
500
+
501
+ React.useEffect(() => () => { if (timerRef.current) clearTimeout(timerRef.current) }, [])
502
+
358
503
  return (
359
504
  <Tooltip.Provider>
360
505
  <aside
@@ -396,45 +541,37 @@ export function Sidebar({
396
541
  )}
397
542
  </div>
398
543
 
399
- {/* Main Navigation */}
400
- <nav className="flex-1 space-y-1 overflow-y-auto p-2">
401
- {topContent && <div className="mb-4 space-y-2">{topContent}</div>}
402
-
403
- {mainItems.length > 0 && (
404
- <div className="space-y-1">
405
- {mainItems.map((item) =>
406
- item.children && item.children.length > 0 ? (
407
- <NavGroup key={item.id} item={item} collapsed={collapsed} activeRoute={activeRoute} onNavigate={handleNavigate} />
408
- ) : (
409
- <NavItem
410
- key={item.id}
411
- item={item}
412
- collapsed={collapsed}
413
- isActive={item.route === activeRoute}
414
- onNavigate={handleNavigate}
415
- />
416
- )
417
- )}
418
- </div>
544
+ {/* Main Navigation. During a rail swap the outgoing nav is pinned on
545
+ top of the incoming one so the two cross-slide; overflow is clipped
546
+ for the duration so neither pane can scroll the rail sideways. */}
547
+ <nav
548
+ className={cn(
549
+ 'relative flex-1 p-2',
550
+ // scrollbar-none: the rail still scrolls, but without the native
551
+ // scrollbar gutter eating into the nav items.
552
+ outgoing ? 'overflow-hidden' : 'overflow-y-auto scrollbar-none',
419
553
  )}
420
-
421
- {secondaryItems.length > 0 && (
422
- <div className="mt-6 space-y-1">
423
- {secondaryItems.map((item) =>
424
- item.children && item.children.length > 0 ? (
425
- <NavGroup key={item.id} item={item} collapsed={collapsed} activeRoute={activeRoute} onNavigate={handleNavigate} />
426
- ) : (
427
- <NavItem
428
- key={item.id}
429
- item={item}
430
- collapsed={collapsed}
431
- isActive={item.route === activeRoute}
432
- onNavigate={handleNavigate}
433
- />
434
- )
554
+ >
555
+ {outgoing && (
556
+ <div
557
+ aria-hidden
558
+ className={cn(
559
+ 'pointer-events-none absolute inset-x-2 top-2 space-y-1',
560
+ outgoing.direction === 'back' ? 'saas-rail-out-back' : 'saas-rail-out-forward',
435
561
  )}
562
+ >
563
+ {outgoing.body}
436
564
  </div>
437
565
  )}
566
+ <div
567
+ key={navKey}
568
+ className={cn(
569
+ 'space-y-1',
570
+ outgoing && (outgoing.direction === 'back' ? 'saas-rail-in-back' : 'saas-rail-in-forward'),
571
+ )}
572
+ >
573
+ {navBody}
574
+ </div>
438
575
  </nav>
439
576
 
440
577
  {/* Settings nav items */}
@@ -31,6 +31,8 @@ import {
31
31
  UserCog,
32
32
  BookOpen,
33
33
  BookOpenCheck,
34
+ GraduationCap,
35
+ Repeat,
34
36
  MessageCircle,
35
37
  Globe,
36
38
  Percent,
@@ -102,6 +104,8 @@ import {
102
104
  Inbox,
103
105
  Star,
104
106
  Zap,
107
+ Crown,
108
+ LogOut,
105
109
  type LucideIcon,
106
110
  } from 'lucide-react'
107
111
  import { cn } from '../utils/cn'
@@ -116,6 +120,9 @@ export interface NavigationItem {
116
120
  badge?: string | number
117
121
  children?: NavigationItem[]
118
122
  permission?: { feature: string; action: 'read' | 'create' | 'edit' | 'delete' }
123
+ /** Freemium discovery: role-allowed but not entitled by the current plan.
124
+ * Renders a small Crown badge; clicking leads to the plan's UpgradePrompt. */
125
+ premium?: boolean
119
126
  }
120
127
 
121
128
  export interface TopbarProps {
@@ -123,6 +130,8 @@ export interface TopbarProps {
123
130
  logo?: React.ReactNode
124
131
  user?: { fullName: string; avatarUrl?: string; email: string }
125
132
  onMenuClick?: () => void
133
+ /** Workspace/org switcher rendered right after the logo (desktop). */
134
+ orgSwitcher?: React.ReactNode
126
135
  leftContent?: React.ReactNode
127
136
  rightContent?: React.ReactNode
128
137
  currentPath?: string
@@ -132,17 +141,23 @@ export interface TopbarProps {
132
141
  onProfile?: () => void
133
142
  onSettings?: () => void
134
143
  onBilling?: () => void
144
+ /** Current-plan pill shown next to the user in the dropdown header. */
145
+ userPlan?: { label: string; paid: boolean }
146
+ /** i18n label for the billing/subscription menu item (host owns translation). */
147
+ billingLabel?: string
135
148
  userMenuExtras?: { label: string; icon?: React.ReactNode; onClick: () => void }[]
136
149
  }
137
150
 
138
151
  function TopbarUserMenu({
139
152
  user,
153
+ userPlan,
154
+ billingLabel = 'Subscription',
140
155
  onSignOut,
141
156
  onProfile,
142
157
  onSettings,
143
158
  onBilling,
144
159
  userMenuExtras,
145
- }: Pick<TopbarProps, 'user' | 'onSignOut' | 'onProfile' | 'onSettings' | 'onBilling' | 'userMenuExtras'>) {
160
+ }: Pick<TopbarProps, 'user' | 'userPlan' | 'billingLabel' | 'onSignOut' | 'onProfile' | 'onSettings' | 'onBilling' | 'userMenuExtras'>) {
146
161
  const [open, setOpen] = React.useState(false)
147
162
  if (!user) return null
148
163
  const initial = (user.fullName?.trim()?.[0] ?? user.email?.[0] ?? '?').toUpperCase()
@@ -185,12 +200,33 @@ function TopbarUserMenu({
185
200
  <div className="mt-1 space-y-0.5">
186
201
  {item('Perfil', onProfile, UserCog)}
187
202
  {item('Configurações', onSettings, Settings)}
188
- {item('Cobrança', onBilling, CreditCard)}
203
+ {onBilling && (
204
+ <button
205
+ key="billing"
206
+ onClick={() => { setOpen(false); onBilling() }}
207
+ className="flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-sm text-popover-foreground transition-colors hover:bg-muted/70"
208
+ >
209
+ <CreditCard className="h-4 w-4 text-muted-foreground" />
210
+ <span className="flex-1 text-left">{billingLabel}</span>
211
+ {userPlan && (
212
+ <span
213
+ className={
214
+ userPlan.paid
215
+ ? 'inline-flex items-center gap-1 rounded-full border border-amber-400/40 bg-amber-400/15 px-2 py-0.5 text-[10px] font-semibold text-amber-600 dark:text-amber-300'
216
+ : 'inline-flex items-center gap-1 rounded-full border border-border bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground'
217
+ }
218
+ >
219
+ {userPlan.paid ? <Crown className="h-3 w-3" /> : <Sparkles className="h-3 w-3" />}
220
+ {userPlan.label}
221
+ </span>
222
+ )}
223
+ </button>
224
+ )}
189
225
  {userMenuExtras?.map((e) => item(e.label, e.onClick))}
190
226
  {onSignOut && (
191
227
  <>
192
228
  <div className="my-1 border-t border-border" />
193
- {item('Sair', onSignOut)}
229
+ {item('Sair', onSignOut, LogOut)}
194
230
  </>
195
231
  )}
196
232
  </div>
@@ -208,7 +244,7 @@ export const ICON_MAP: Record<string, LucideIcon> = {
208
244
  Contact, Building2, ChevronDown, Filter, Plus, Shield, List, ListChecks, FolderOpen, Dog, Cat, PawPrint, Heart, LayoutTemplate, LeafyGreen, Apple, Egg, Wheat,
209
245
  ArrowDownCircle, ArrowUpCircle, ArrowLeftRight, Plug, SlidersHorizontal, TableProperties, Landmark, Receipt, TrendingUp, CircleDollarSign, Clock, AlertTriangle, Sparkles, Wallet, Warehouse, Ruler, ArrowUpRight, ArrowDownRight, UserPlus, UserX, TreePalm,
210
246
  Boxes, Box, FileCheck2, PartyPopper, Radio, CalendarDays, BadgeDollarSign, Banknote, Layers, Music, Eye, ListMusic, Disc3, UsersRound, Mic,
211
- CalendarCheck2, CalendarX, ListPlus, Map, Ban, Clock3, Inbox, UserCheck, Star, Zap, Clapperboard, Newspaper,
247
+ CalendarCheck2, CalendarX, ListPlus, Map, Ban, Clock3, Inbox, UserCheck, Star, Zap, Clapperboard, Newspaper, GraduationCap, Repeat,
212
248
  }
213
249
 
214
250
  function getIcon(name: string): LucideIcon {
@@ -282,6 +318,7 @@ export function Topbar({
282
318
  logo,
283
319
  user,
284
320
  onMenuClick,
321
+ orgSwitcher,
285
322
  leftContent,
286
323
  rightContent,
287
324
  currentPath = '/',
@@ -291,6 +328,8 @@ export function Topbar({
291
328
  onProfile,
292
329
  onSettings,
293
330
  onBilling,
331
+ userPlan,
332
+ billingLabel,
294
333
  userMenuExtras,
295
334
  }: TopbarProps) {
296
335
  const setCommandPaletteOpen = useLayoutStore((s) => s.setCommandPaletteOpen)
@@ -324,6 +363,9 @@ export function Topbar({
324
363
  <div className="hidden shrink-0 items-center md:flex">
325
364
  {logo ?? <span className="text-lg font-bold">App</span>}
326
365
  </div>
366
+ {orgSwitcher && (
367
+ <div className="hidden min-w-0 items-center md:flex">{orgSwitcher}</div>
368
+ )}
327
369
  </div>
328
370
 
329
371
  {/* Center: Search (desktop only) */}
@@ -346,6 +388,8 @@ export function Topbar({
346
388
  {rightContent}
347
389
  <TopbarUserMenu
348
390
  user={user}
391
+ userPlan={userPlan}
392
+ billingLabel={billingLabel}
349
393
  onSignOut={onSignOut}
350
394
  onProfile={onProfile}
351
395
  onSettings={onSettings}
@@ -399,6 +443,9 @@ export function Topbar({
399
443
  >
400
444
  <Icon className="h-4 w-4" />
401
445
  <span>{item.label}</span>
446
+ {item.premium && (
447
+ <Crown className="h-3 w-3 text-amber-500" aria-hidden />
448
+ )}
402
449
  {item.badge !== undefined && (
403
450
  <span className="rounded-full bg-primary px-1.5 py-0.5 text-[10px] font-semibold text-primary-foreground">
404
451
  {item.badge}
@@ -35,7 +35,12 @@ function DataTable<TData, TValue>({
35
35
  compact = false,
36
36
  }: DataTableProps<TData, TValue>) {
37
37
  const isCard = variant === 'card'
38
- const wrapperCls = isCard ? 'rounded-lg border bg-card shadow-sm overflow-x-auto' : 'rounded-lg border overflow-x-auto'
38
+ // Rounded box and scroll container must be SEPARATE elements: a scroll
39
+ // container paints its content over its own border-radius, which squared
40
+ // the corners the moment a row background met them.
41
+ const wrapperCls = isCard
42
+ ? 'overflow-hidden rounded-lg border bg-card shadow-sm'
43
+ : 'overflow-hidden rounded-lg border'
39
44
  const headerCls = isCard ? 'border-b bg-muted/50' : 'border-b bg-muted/30'
40
45
  const thCls = compact ? 'px-3 py-1.5 text-left text-[11px] font-medium text-muted-foreground' : 'px-4 py-2.5 text-left font-medium text-muted-foreground'
41
46
  const tdCls = compact ? 'px-3 py-1.5 align-middle text-xs' : 'px-4 py-3 align-middle'
@@ -55,6 +60,7 @@ function DataTable<TData, TValue>({
55
60
  if (loading) {
56
61
  return (
57
62
  <div className={wrapperCls}>
63
+ <div className="overflow-x-auto">
58
64
  <table className={tableCls}>
59
65
  <thead>
60
66
  <tr className={headerCls}>
@@ -77,12 +83,14 @@ function DataTable<TData, TValue>({
77
83
  ))}
78
84
  </tbody>
79
85
  </table>
86
+ </div>
80
87
  </div>
81
88
  )
82
89
  }
83
90
 
84
91
  return (
85
92
  <div className={wrapperCls}>
93
+ <div className="overflow-x-auto">
86
94
  <table className={tableCls}>
87
95
  <thead>
88
96
  {table.getHeaderGroups().map((headerGroup) => (
@@ -144,6 +152,7 @@ function DataTable<TData, TValue>({
144
152
  )}
145
153
  </tbody>
146
154
  </table>
155
+ </div>
147
156
  </div>
148
157
  )
149
158
  }
@@ -43,7 +43,9 @@ export interface SearchSelectProps {
43
43
  onCreate?: (query: string) => void | Promise<void>
44
44
  /** Custom render for each option */
45
45
  renderOption?: (option: SearchSelectOption) => React.ReactNode
46
- /** Load initial results when dropdown opens (calls onSearch('')) */
46
+ /** Load initial results when dropdown opens (calls onSearch('')). Default
47
+ * true — clicking lists options before any typing; pass false to require
48
+ * a query first (e.g. very large datasets with no cheap top-N). */
47
49
  loadOnOpen?: boolean
48
50
  /** Additional className for the container */
49
51
  className?: string
@@ -69,7 +71,7 @@ export function SearchSelect({
69
71
  createLabel = 'Add',
70
72
  onCreate,
71
73
  renderOption,
72
- loadOnOpen,
74
+ loadOnOpen = true,
73
75
  className,
74
76
  disabled,
75
77
  }: SearchSelectProps) {
@@ -162,6 +164,10 @@ export function SearchSelect({
162
164
  if (disabled || creating) return
163
165
  setOpen(true)
164
166
  setQuery('')
167
+ // Stale results from a previous typed query would render for the empty
168
+ // query now that the dropdown shows immediately — drop them so the
169
+ // loadOnOpen effect refetches the initial listing.
170
+ setResults([])
165
171
  setTimeout(() => inputRef.current?.focus(), 0)
166
172
  }
167
173
 
@@ -177,7 +183,9 @@ export function SearchSelect({
177
183
  }, [results])
178
184
 
179
185
  const hasValue = !!value && !!selectedLabel
180
- const showDropdown = open && query.length >= minChars
186
+ // With loadOnOpen the dropdown must show for the EMPTY query too — gating it
187
+ // on minChars silently hid the initial listing loadOnOpen had just fetched.
188
+ const showDropdown = open && (query.length >= minChars || loadOnOpen)
181
189
 
182
190
  return (
183
191
  <div ref={containerRef} className={cn('relative', className)}>
@@ -246,7 +254,7 @@ export function SearchSelect({
246
254
  </div>
247
255
  ) : results.length === 0 ? (
248
256
  <div className="px-4 py-4 text-center">
249
- <p className="text-xs text-muted-foreground">No results for "{query}"</p>
257
+ <p className="text-xs text-muted-foreground">{query ? `No results for "${query}"` : 'No results'}</p>
250
258
  {allowCreate && onCreate && (
251
259
  <button
252
260
  onClick={() => handleCreate(query)}
package/src/styles.css CHANGED
@@ -370,6 +370,7 @@ select.bg-card,
370
370
  --saas-page-enter-duration: 250ms;
371
371
  --saas-page-enter-offset: 4px;
372
372
  --saas-nav-slide-offset: 12px;
373
+ --saas-rail-duration: 260ms;
373
374
  }
374
375
 
375
376
  /* Page content transition */
@@ -425,3 +426,36 @@ select.bg-card,
425
426
  from { opacity: 0; transform: translateX(calc(-1 * var(--saas-nav-slide-offset))); }
426
427
  to { opacity: 1; transform: translateX(0); }
427
428
  }
429
+
430
+ /* Sidebar rail push — when the whole left menu swaps context (e.g. app nav →
431
+ settings nav) the incoming rail pulls in from one side while the outgoing
432
+ one slides off the other, iOS-push style. The outgoing pane is absolutely
433
+ positioned over the incoming one for the duration of the animation. */
434
+ .saas-rail-in-forward { animation: saas-rail-in-forward var(--saas-rail-duration) var(--saas-transition-easing) both; }
435
+ .saas-rail-out-forward { animation: saas-rail-out-forward var(--saas-rail-duration) var(--saas-transition-easing) both; }
436
+ .saas-rail-in-back { animation: saas-rail-in-back var(--saas-rail-duration) var(--saas-transition-easing) both; }
437
+ .saas-rail-out-back { animation: saas-rail-out-back var(--saas-rail-duration) var(--saas-transition-easing) both; }
438
+
439
+ @keyframes saas-rail-in-forward {
440
+ from { opacity: 0; transform: translateX(100%); }
441
+ to { opacity: 1; transform: translateX(0); }
442
+ }
443
+ @keyframes saas-rail-out-forward {
444
+ from { opacity: 1; transform: translateX(0); }
445
+ to { opacity: 0; transform: translateX(-35%); }
446
+ }
447
+ @keyframes saas-rail-in-back {
448
+ from { opacity: 0; transform: translateX(-100%); }
449
+ to { opacity: 1; transform: translateX(0); }
450
+ }
451
+ @keyframes saas-rail-out-back {
452
+ from { opacity: 1; transform: translateX(0); }
453
+ to { opacity: 0; transform: translateX(35%); }
454
+ }
455
+
456
+ @media (prefers-reduced-motion: reduce) {
457
+ .saas-rail-in-forward, .saas-rail-out-forward,
458
+ .saas-rail-in-back, .saas-rail-out-back {
459
+ animation-duration: 1ms;
460
+ }
461
+ }