@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.
- package/dist/{chunk-XAOGMWLC.cjs → chunk-7ZQQZD4Q.cjs} +354 -80
- package/dist/chunk-7ZQQZD4Q.cjs.map +1 -0
- package/dist/{chunk-IEYCOJTY.cjs → chunk-B3BTZDTA.cjs} +10 -42
- package/dist/chunk-B3BTZDTA.cjs.map +1 -0
- package/dist/{chunk-O5F4LLSL.js → chunk-DYMBFHD7.js} +8 -7
- package/dist/chunk-DYMBFHD7.js.map +1 -0
- package/dist/{chunk-FY7OZNKU.js → chunk-G6OKZ7WL.js} +35 -8
- package/dist/chunk-G6OKZ7WL.js.map +1 -0
- package/dist/{chunk-W5O2X277.js → chunk-M34ACV5E.js} +27 -4
- package/dist/chunk-M34ACV5E.js.map +1 -0
- package/dist/{chunk-NWPMNPGR.cjs → chunk-MUGRP5DD.cjs} +57 -34
- package/dist/chunk-MUGRP5DD.cjs.map +1 -0
- package/dist/{chunk-ODY3GZ3Y.cjs → chunk-NZV47HWO.cjs} +36 -7
- package/dist/chunk-NZV47HWO.cjs.map +1 -0
- package/dist/{chunk-J3IUFUNR.js → chunk-R4LPSXTT.js} +8 -38
- package/dist/chunk-R4LPSXTT.js.map +1 -0
- package/dist/{chunk-TDGSY5W6.cjs → chunk-R4SQ2GVD.cjs} +8 -7
- package/dist/chunk-R4SQ2GVD.cjs.map +1 -0
- package/dist/{chunk-MZZMZYSE.js → chunk-VVQ45ZDZ.js} +357 -83
- package/dist/chunk-VVQ45ZDZ.js.map +1 -0
- package/dist/dashboard/OnboardingWidget.d.ts.map +1 -1
- package/dist/dashboard/index.cjs +26 -26
- package/dist/dashboard/index.js +2 -2
- package/dist/index.cjs +137 -137
- package/dist/index.js +5 -5
- package/dist/layout/AppShell.d.ts +41 -1
- package/dist/layout/AppShell.d.ts.map +1 -1
- package/dist/layout/ModulePage.d.ts +9 -6
- package/dist/layout/ModulePage.d.ts.map +1 -1
- package/dist/layout/Sidebar.d.ts +24 -1
- package/dist/layout/Sidebar.d.ts.map +1 -1
- package/dist/layout/Topbar.d.ts +13 -1
- package/dist/layout/Topbar.d.ts.map +1 -1
- package/dist/layout/index.cjs +19 -19
- package/dist/layout/index.js +1 -1
- package/dist/primitives/data-table.d.ts.map +1 -1
- package/dist/primitives/index.cjs +83 -83
- package/dist/primitives/index.js +2 -2
- package/dist/primitives/search-select.d.ts +3 -1
- package/dist/primitives/search-select.d.ts.map +1 -1
- package/dist/theme/index.cjs +8 -8
- package/dist/theme/index.d.ts.map +1 -1
- package/dist/theme/index.js +1 -1
- package/package.json +3 -3
- package/src/dashboard/OnboardingWidget.tsx +41 -1
- package/src/layout/AppShell.tsx +255 -7
- package/src/layout/ModulePage.tsx +32 -3
- package/src/layout/Sidebar.tsx +198 -61
- package/src/layout/Topbar.tsx +51 -4
- package/src/primitives/data-table.tsx +10 -1
- package/src/primitives/search-select.tsx +12 -4
- package/src/styles.css +34 -0
- package/src/theme/index.ts +9 -6
- package/dist/chunk-FY7OZNKU.js.map +0 -1
- package/dist/chunk-IEYCOJTY.cjs.map +0 -1
- package/dist/chunk-J3IUFUNR.js.map +0 -1
- package/dist/chunk-MZZMZYSE.js.map +0 -1
- package/dist/chunk-NWPMNPGR.cjs.map +0 -1
- package/dist/chunk-O5F4LLSL.js.map +0 -1
- package/dist/chunk-ODY3GZ3Y.cjs.map +0 -1
- package/dist/chunk-TDGSY5W6.cjs.map +0 -1
- package/dist/chunk-W5O2X277.js.map +0 -1
- package/dist/chunk-XAOGMWLC.cjs.map +0 -1
package/src/layout/Sidebar.tsx
CHANGED
|
@@ -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
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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=
|
|
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
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
{
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
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
|
-
{
|
|
422
|
-
<div
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
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 */}
|
package/src/layout/Topbar.tsx
CHANGED
|
@@ -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
|
-
{
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|