@fayz-ai/ui 0.8.2 → 0.8.3

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 (46) hide show
  1. package/dist/{chunk-J3IUFUNR.js → chunk-2GDQUARM.js} +3 -3
  2. package/dist/{chunk-J3IUFUNR.js.map → chunk-2GDQUARM.js.map} +1 -1
  3. package/dist/{chunk-W5O2X277.js → chunk-A7O7KGNL.js} +8 -4
  4. package/dist/chunk-A7O7KGNL.js.map +1 -0
  5. package/dist/{chunk-FY7OZNKU.js → chunk-BVF2KPTT.js} +7 -7
  6. package/dist/{chunk-FY7OZNKU.js.map → chunk-BVF2KPTT.js.map} +1 -1
  7. package/dist/{chunk-MZZMZYSE.js → chunk-ID2HQK2Q.js} +328 -51
  8. package/dist/chunk-ID2HQK2Q.js.map +1 -0
  9. package/dist/{chunk-ODY3GZ3Y.cjs → chunk-MI6FOB4O.cjs} +7 -7
  10. package/dist/{chunk-ODY3GZ3Y.cjs.map → chunk-MI6FOB4O.cjs.map} +1 -1
  11. package/dist/{chunk-IEYCOJTY.cjs → chunk-SVVKUMSL.cjs} +6 -6
  12. package/dist/{chunk-IEYCOJTY.cjs.map → chunk-SVVKUMSL.cjs.map} +1 -1
  13. package/dist/{chunk-NWPMNPGR.cjs → chunk-WF3STNDX.cjs} +38 -34
  14. package/dist/chunk-WF3STNDX.cjs.map +1 -0
  15. package/dist/{chunk-XAOGMWLC.cjs → chunk-XIRJGVDK.cjs} +325 -48
  16. package/dist/chunk-XIRJGVDK.cjs.map +1 -0
  17. package/dist/dashboard/OnboardingWidget.d.ts.map +1 -1
  18. package/dist/dashboard/index.cjs +26 -26
  19. package/dist/dashboard/index.js +2 -2
  20. package/dist/index.cjs +122 -122
  21. package/dist/index.js +4 -4
  22. package/dist/layout/AppShell.d.ts +41 -1
  23. package/dist/layout/AppShell.d.ts.map +1 -1
  24. package/dist/layout/ModulePage.d.ts +9 -6
  25. package/dist/layout/ModulePage.d.ts.map +1 -1
  26. package/dist/layout/Sidebar.d.ts +20 -1
  27. package/dist/layout/Sidebar.d.ts.map +1 -1
  28. package/dist/layout/Topbar.d.ts +13 -1
  29. package/dist/layout/Topbar.d.ts.map +1 -1
  30. package/dist/layout/index.cjs +19 -19
  31. package/dist/layout/index.js +1 -1
  32. package/dist/primitives/data-table.d.ts.map +1 -1
  33. package/dist/primitives/index.cjs +77 -77
  34. package/dist/primitives/index.js +2 -2
  35. package/package.json +3 -3
  36. package/src/dashboard/OnboardingWidget.tsx +10 -1
  37. package/src/layout/AppShell.tsx +255 -7
  38. package/src/layout/ModulePage.tsx +32 -3
  39. package/src/layout/Sidebar.tsx +132 -35
  40. package/src/layout/Topbar.tsx +51 -4
  41. package/src/primitives/data-table.tsx +10 -1
  42. package/src/styles.css +34 -0
  43. package/dist/chunk-MZZMZYSE.js.map +0 -1
  44. package/dist/chunk-NWPMNPGR.cjs.map +0 -1
  45. package/dist/chunk-W5O2X277.js.map +0 -1
  46. package/dist/chunk-XAOGMWLC.cjs.map +0 -1
@@ -98,6 +98,10 @@ export interface AppShellProps {
98
98
  onProfile?: () => void
99
99
  onSettings?: () => void
100
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
101
105
  pageTitle?: string
102
106
  currentPath?: string
103
107
  userMenuExtras?: { label: string; icon?: React.ReactNode; onClick: () => void }[]
@@ -117,6 +121,35 @@ export interface AppShellProps {
117
121
  notificationSlot?: React.ReactNode
118
122
  /** Unread notification count */
119
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
120
153
  }
121
154
 
122
155
  // ---------------------------------------------------------------------------
@@ -147,6 +180,9 @@ function SidebarLayout({
147
180
  unreadCount = 0,
148
181
  hasBottomNav = false,
149
182
  mobileHeader = 'minimal',
183
+ sidebarSectionLabels,
184
+ sidebarNavKey,
185
+ sidebarNavDirection,
150
186
  }: {
151
187
  navigation?: NavigationItem[]
152
188
  logo?: React.ReactNode
@@ -174,6 +210,9 @@ function SidebarLayout({
174
210
  hasBottomNav?: boolean
175
211
  /** Mobile header treatment (<md). Desktop keeps its top bar untouched. */
176
212
  mobileHeader?: MobileHeaderVariant
213
+ sidebarSectionLabels?: AppShellProps['sidebarSectionLabels']
214
+ sidebarNavKey?: string
215
+ sidebarNavDirection?: 'forward' | 'back'
177
216
  }) {
178
217
  const sidebarCollapsed = useLayoutStore((s) => s.sidebarCollapsed)
179
218
  const setSidebarCollapsed = useLayoutStore((s) => s.setSidebarCollapsed)
@@ -213,6 +252,9 @@ function SidebarLayout({
213
252
  userMenuSlot={userMenuSlot}
214
253
  notificationSlot={notificationSlot}
215
254
  unreadCount={unreadCount}
255
+ sectionLabels={sidebarSectionLabels}
256
+ navKey={sidebarNavKey}
257
+ navDirection={sidebarNavDirection}
216
258
  />
217
259
  </div>
218
260
 
@@ -257,7 +299,7 @@ function SidebarLayout({
257
299
  </div>
258
300
 
259
301
  {/* Page content */}
260
- <main className="flex-1 overflow-y-auto pb-16 md:pb-0">
302
+ <main className="min-w-0 flex-1 overflow-y-auto pb-16 md:pb-0">
261
303
  {children}
262
304
  </main>
263
305
 
@@ -307,11 +349,15 @@ function TopbarLayout({
307
349
  onProfile,
308
350
  onSettings,
309
351
  onBilling,
352
+ userPlan,
353
+ billingLabel,
310
354
  userMenuExtras,
355
+ orgSwitcher,
311
356
  frame,
312
357
  currentPath,
313
358
  topbarStart,
314
359
  topbarEnd,
360
+ notificationSlot,
315
361
  hasBottomNav = false,
316
362
  }: {
317
363
  navigation?: NavigationItem[]
@@ -323,11 +369,17 @@ function TopbarLayout({
323
369
  onProfile?: () => void
324
370
  onSettings?: () => void
325
371
  onBilling?: () => void
372
+ userPlan?: AppShellProps['userPlan']
373
+ billingLabel?: AppShellProps['billingLabel']
326
374
  userMenuExtras?: AppShellProps['userMenuExtras']
375
+ /** Workspace switcher — rendered beside the logo (desktop) and in the mobile drawer. */
376
+ orgSwitcher?: React.ReactNode
327
377
  frame?: boolean
328
378
  currentPath?: string
329
379
  topbarStart?: React.ReactNode
330
380
  topbarEnd?: React.ReactNode
381
+ /** Bell/inbox slot — in the topbar layout it renders with the right-side actions. */
382
+ notificationSlot?: React.ReactNode
331
383
  /** When a mobile bottom-nav bar is present, the mobile hamburger is
332
384
  * suppressed so the bottom nav is the primary mobile navigation. */
333
385
  hasBottomNav?: boolean
@@ -348,17 +400,25 @@ function TopbarLayout({
348
400
  onProfile={onProfile}
349
401
  onSettings={onSettings}
350
402
  onBilling={onBilling}
403
+ userPlan={userPlan}
404
+ billingLabel={billingLabel}
351
405
  userMenuExtras={userMenuExtras}
406
+ orgSwitcher={orgSwitcher}
352
407
  leftContent={topbarStart}
353
- rightContent={topbarEnd}
408
+ rightContent={(topbarEnd || notificationSlot) ? <>{topbarEnd}{notificationSlot}</> : undefined}
354
409
  onMenuClick={hasBottomNav ? undefined : () => setMobileMenuOpen(true)}
355
410
  />
356
411
  {/* Frame inset/border only from md up, so mobile stays edge-to-edge. The
357
412
  inset gap behind the framed card is the header color (bg-sidebar), so the
358
413
  white card reads as a panel floating on the same navy as the topbar. */}
359
- <main className={cn('flex-1 overflow-y-auto pb-16 md:pb-0', frame && 'md:bg-sidebar md:p-2')}>
360
- <div className={cn('min-h-full', frame && 'md:rounded-xl md:border md:border-border md:bg-card')}>
361
- {children}
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>
362
422
  </div>
363
423
  </main>
364
424
 
@@ -378,6 +438,7 @@ function TopbarLayout({
378
438
  onProfile={onProfile}
379
439
  onSettings={onSettings}
380
440
  onBilling={onBilling}
441
+ orgSwitcher={orgSwitcher}
381
442
  userMenuSlot={user ? <DrawerUserRow user={user} /> : undefined}
382
443
  onClose={() => setMobileMenuOpen(false)}
383
444
  />
@@ -427,7 +488,7 @@ function MinimalLayout({
427
488
  </div>
428
489
  {headerEnd && <div className="flex items-center gap-2">{headerEnd}</div>}
429
490
  </header>
430
- <main className="flex-1 overflow-y-auto pb-16 md:pb-0">
491
+ <main className="min-w-0 flex-1 overflow-y-auto pb-16 md:pb-0">
431
492
  {children}
432
493
  </main>
433
494
  </div>
@@ -629,6 +690,8 @@ export function AppShell({
629
690
  onProfile,
630
691
  onSettings,
631
692
  onBilling,
693
+ userPlan,
694
+ billingLabel,
632
695
  pageTitle,
633
696
  currentPath,
634
697
  userMenuExtras,
@@ -643,6 +706,16 @@ export function AppShell({
643
706
  userMenuSlot,
644
707
  notificationSlot,
645
708
  unreadCount = 0,
709
+ sidebarSectionLabels,
710
+ sidebarNavKey,
711
+ sidebarNavDirection,
712
+ rightRail,
713
+ rightRailOpen = true,
714
+ defaultRightRailWidth,
715
+ onRightRailWidthChange,
716
+ rightRailMinWidth,
717
+ rightRailMaxWidth,
718
+ rightRailResizeLabel,
646
719
  }: AppShellProps) {
647
720
  const hasBottomNav = !!bottomNav?.length
648
721
  const bottomBar = hasBottomNav ? (
@@ -686,6 +759,9 @@ export function AppShell({
686
759
  unreadCount={unreadCount}
687
760
  hasBottomNav={hasBottomNav}
688
761
  mobileHeader={mobileHeader}
762
+ sidebarSectionLabels={sidebarSectionLabels}
763
+ sidebarNavKey={sidebarNavKey}
764
+ sidebarNavDirection={sidebarNavDirection}
689
765
  >
690
766
  {children}
691
767
  </SidebarLayout>
@@ -703,11 +779,15 @@ export function AppShell({
703
779
  onProfile={onProfile}
704
780
  onSettings={onSettings}
705
781
  onBilling={onBilling}
782
+ userPlan={userPlan}
783
+ billingLabel={billingLabel}
706
784
  userMenuExtras={userMenuExtras}
785
+ orgSwitcher={orgSwitcher}
707
786
  frame={contentFrame}
708
787
  currentPath={currentPath}
709
788
  topbarStart={topbarStart}
710
789
  topbarEnd={topbarEnd}
790
+ notificationSlot={notificationSlot}
711
791
  hasBottomNav={hasBottomNav}
712
792
  >
713
793
  {children}
@@ -731,9 +811,177 @@ export function AppShell({
731
811
 
732
812
  return (
733
813
  <>
734
- {layoutElement}
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
+ )}
735
830
  {floatingAvatar}
736
831
  {bottomBar}
737
832
  </>
738
833
  )
739
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
+ }
@@ -1,6 +1,6 @@
1
1
  import * as React from 'react'
2
2
  import { createPortal } from 'react-dom'
3
- import { ChevronDown, ChevronRight, ChevronLeft } from 'lucide-react'
3
+ import { ChevronDown, ChevronRight, ChevronLeft, Crown } from 'lucide-react'
4
4
  import { useTranslation } from '@fayz-ai/core'
5
5
  import { cn } from '../utils/cn'
6
6
  import { ICON_MAP } from './Topbar'
@@ -28,7 +28,31 @@ export interface ModuleNavItem {
28
28
  icon?: string
29
29
  active?: boolean
30
30
  onClick?: () => void
31
- children?: { id: string; label: string; active?: boolean; onClick?: () => void }[]
31
+ /** Feature id for the access gate. When set, `useModuleNavAccess` (in
32
+ * @fayz-ai/saas) resolves this link's role (hide) and plan (Crown) state. */
33
+ feature?: string
34
+ /** Freemium discovery: the user's role allows this link but their plan does
35
+ * not entitle `feature`. Renders a small Crown badge (same vocabulary as the
36
+ * Sidebar top-level nav); the click still navigates and the gated CONTENT
37
+ * shows the UpgradePrompt. Typically set by `useModuleNavAccess`. */
38
+ premium?: boolean
39
+ children?: ModuleNavItem[]
40
+ }
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // PremiumBadge — the small Crown affordance shared by every module-nav renderer
44
+ // (rail, mobile tabs, GHL header). Same vocabulary as the Sidebar top-level nav
45
+ // so a plan-gated sub-module link reads identically wherever it appears.
46
+ // ---------------------------------------------------------------------------
47
+
48
+ function PremiumBadge({ className }: { className?: string }) {
49
+ const t = useTranslation()
50
+ const tip = t('upgrade.badgeTooltip')
51
+ return (
52
+ <span title={tip} aria-label={tip} className={cn('inline-flex shrink-0', className)}>
53
+ <Crown className="h-3.5 w-3.5 text-amber-500" aria-hidden />
54
+ </span>
55
+ )
32
56
  }
33
57
 
34
58
  export interface ModulePageProps {
@@ -92,6 +116,7 @@ function NavItem({ item }: { item: ModuleNavItem }) {
92
116
  >
93
117
  {Icon && <Icon className="h-4 w-4 shrink-0" />}
94
118
  <span className="flex-1 text-left truncate">{item.label}</span>
119
+ {item.premium && <PremiumBadge className="ml-1" />}
95
120
  {hasChildren && (
96
121
  expanded
97
122
  ? <ChevronDown className="h-3.5 w-3.5 shrink-0 opacity-50" />
@@ -112,7 +137,8 @@ function NavItem({ item }: { item: ModuleNavItem }) {
112
137
  : 'text-muted-foreground hover:text-foreground hover:bg-muted/50'
113
138
  )}
114
139
  >
115
- {child.label}
140
+ <span className="flex-1 text-left truncate">{child.label}</span>
141
+ {child.premium && <PremiumBadge className="ml-1" />}
116
142
  </button>
117
143
  ))}
118
144
  </div>
@@ -168,6 +194,7 @@ function MobileTabs({ nav }: { nav: ModuleNavItem[] }) {
168
194
  >
169
195
  {Icon && <Icon className="h-3 w-3" />}
170
196
  {item.label}
197
+ {item.premium && <PremiumBadge />}
171
198
  {hasChildren && <ChevronDown className={cn('h-2.5 w-2.5 transition-transform', isExpanded && 'rotate-180')} />}
172
199
  </button>
173
200
  )
@@ -193,6 +220,7 @@ function MobileTabs({ nav }: { nav: ModuleNavItem[] }) {
193
220
  )}
194
221
  >
195
222
  {child.label}
223
+ {child.premium && <PremiumBadge />}
196
224
  </button>
197
225
  ))}
198
226
  </div>
@@ -359,6 +387,7 @@ function ModuleHeader({ nav }: { nav: ModuleNavItem[] }) {
359
387
  >
360
388
  {Icon && <Icon className="h-4 w-4" />}
361
389
  {item.label}
390
+ {item.premium && <PremiumBadge />}
362
391
  </button>
363
392
  )
364
393
  })}