@fayz-ai/ui 0.8.1 → 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.
- package/dist/{chunk-OTUH4K4B.js → chunk-2GDQUARM.js} +214 -4
- package/dist/chunk-2GDQUARM.js.map +1 -0
- package/dist/{chunk-W5O2X277.js → chunk-A7O7KGNL.js} +8 -4
- package/dist/chunk-A7O7KGNL.js.map +1 -0
- package/dist/{chunk-FY7OZNKU.js → chunk-BVF2KPTT.js} +7 -7
- package/dist/{chunk-FY7OZNKU.js.map → chunk-BVF2KPTT.js.map} +1 -1
- package/dist/{chunk-MZZMZYSE.js → chunk-ID2HQK2Q.js} +328 -51
- package/dist/chunk-ID2HQK2Q.js.map +1 -0
- package/dist/{chunk-ODY3GZ3Y.cjs → chunk-MI6FOB4O.cjs} +7 -7
- package/dist/{chunk-ODY3GZ3Y.cjs.map → chunk-MI6FOB4O.cjs.map} +1 -1
- package/dist/{chunk-7PTRAWHO.cjs → chunk-SVVKUMSL.cjs} +217 -6
- package/dist/chunk-SVVKUMSL.cjs.map +1 -0
- package/dist/{chunk-NWPMNPGR.cjs → chunk-WF3STNDX.cjs} +38 -34
- package/dist/chunk-WF3STNDX.cjs.map +1 -0
- package/dist/{chunk-XAOGMWLC.cjs → chunk-XIRJGVDK.cjs} +325 -48
- package/dist/chunk-XIRJGVDK.cjs.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 +125 -121
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -4
- 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 +20 -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/SearchCombobox.d.ts +38 -0
- package/dist/primitives/SearchCombobox.d.ts.map +1 -0
- package/dist/primitives/data-table.d.ts.map +1 -1
- package/dist/primitives/index.cjs +80 -76
- package/dist/primitives/index.d.ts +1 -0
- package/dist/primitives/index.d.ts.map +1 -1
- package/dist/primitives/index.js +2 -2
- package/package.json +3 -3
- package/src/dashboard/OnboardingWidget.tsx +10 -1
- package/src/index.ts +2 -0
- package/src/layout/AppShell.tsx +255 -7
- package/src/layout/ModulePage.tsx +32 -3
- package/src/layout/Sidebar.tsx +132 -35
- package/src/layout/Topbar.tsx +51 -4
- package/src/primitives/SearchCombobox.tsx +216 -0
- package/src/primitives/data-table.tsx +10 -1
- package/src/primitives/index.ts +1 -0
- package/src/styles.css +34 -0
- package/dist/chunk-7PTRAWHO.cjs.map +0 -1
- package/dist/chunk-MZZMZYSE.js.map +0 -1
- package/dist/chunk-NWPMNPGR.cjs.map +0 -1
- package/dist/chunk-OTUH4K4B.js.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,7 @@
|
|
|
1
1
|
import * as React from 'react'
|
|
2
2
|
import * as Tooltip from '@radix-ui/react-tooltip'
|
|
3
3
|
import {
|
|
4
|
+
Crown,
|
|
4
5
|
Home,
|
|
5
6
|
Users,
|
|
6
7
|
Settings,
|
|
@@ -82,6 +83,8 @@ import {
|
|
|
82
83
|
Inbox,
|
|
83
84
|
Star,
|
|
84
85
|
TrendingUp,
|
|
86
|
+
GraduationCap,
|
|
87
|
+
Repeat,
|
|
85
88
|
UserCheck,
|
|
86
89
|
UserPlus,
|
|
87
90
|
UserX,
|
|
@@ -89,6 +92,11 @@ import {
|
|
|
89
92
|
Boxes,
|
|
90
93
|
Clock3,
|
|
91
94
|
FileCheck2,
|
|
95
|
+
User,
|
|
96
|
+
Palette,
|
|
97
|
+
ShieldCheck,
|
|
98
|
+
SlidersHorizontal,
|
|
99
|
+
Puzzle,
|
|
92
100
|
type LucideIcon,
|
|
93
101
|
} from 'lucide-react'
|
|
94
102
|
import { cn } from '../utils/cn'
|
|
@@ -102,6 +110,10 @@ export interface NavigationItem {
|
|
|
102
110
|
section: 'main' | 'secondary' | 'settings'
|
|
103
111
|
badge?: string | number
|
|
104
112
|
permission?: { feature: string; action: 'read' | 'create' | 'edit' | 'delete' }
|
|
113
|
+
/** Freemium discovery: the user's role allows this item, but their plan does
|
|
114
|
+
* not entitle the feature. The item stays visible with a small Crown badge
|
|
115
|
+
* and, on click, leads to the plan's UpgradePrompt. */
|
|
116
|
+
premium?: boolean
|
|
105
117
|
children?: NavigationItem[]
|
|
106
118
|
}
|
|
107
119
|
|
|
@@ -135,6 +147,17 @@ export interface SidebarProps {
|
|
|
135
147
|
notificationSlot?: React.ReactNode
|
|
136
148
|
/** Unread notification count for the bell indicator */
|
|
137
149
|
unreadCount?: number
|
|
150
|
+
/** Optional headings above each nav section. Used by contextual sidebars
|
|
151
|
+
* (e.g. /settings swaps the rail for "Settings links" + "Plugins"); the
|
|
152
|
+
* normal app rail leaves them undefined and renders no headings. */
|
|
153
|
+
sectionLabels?: { main?: string; secondary?: string; settings?: string }
|
|
154
|
+
/** Identity of the current rail context. When it changes, the rail animates
|
|
155
|
+
* as a push: the new nav slides in while the previous one slides out.
|
|
156
|
+
* Leave undefined for a static rail (no swap animation). */
|
|
157
|
+
navKey?: string
|
|
158
|
+
/** Push direction for a `navKey` change. 'forward' pulls the new rail in
|
|
159
|
+
* from the right (drilling in), 'back' from the left (returning). */
|
|
160
|
+
navDirection?: 'forward' | 'back'
|
|
138
161
|
}
|
|
139
162
|
|
|
140
163
|
const ICON_MAP: Record<string, LucideIcon> = {
|
|
@@ -145,10 +168,21 @@ const ICON_MAP: Record<string, LucideIcon> = {
|
|
|
145
168
|
Building2, Filter, Plus, List, ListPlus, Search, Shield, Ban, Clock, Dog, Cat, PawPrint, Heart, FolderOpen, LayoutTemplate, LeafyGreen, Apple, Egg, Wheat,
|
|
146
169
|
PartyPopper, Radio, CalendarDays, CalendarCheck2, CalendarX, BadgeDollarSign, Banknote, Landmark, CircleDollarSign, Layers, Music, Eye, ListMusic, Disc3, UsersRound, Mic,
|
|
147
170
|
Zap, Award, LayoutDashboard, MessageSquare, Inbox, Star, TrendingUp, UserCheck, UserPlus, UserX, Workflow, Boxes, Clock3, FileCheck2,
|
|
171
|
+
GraduationCap, Repeat,
|
|
172
|
+
User, Palette, ShieldCheck, SlidersHorizontal, Puzzle,
|
|
148
173
|
}
|
|
149
174
|
|
|
150
175
|
export { ICON_MAP }
|
|
151
176
|
|
|
177
|
+
/** Kept in sync with --saas-rail-duration in styles.css. */
|
|
178
|
+
const RAIL_PUSH_MS = 260
|
|
179
|
+
|
|
180
|
+
/** Layout effect on the client, plain effect during SSR (avoids the warning).
|
|
181
|
+
* The rail push needs the layout variant: the animation classes must be on
|
|
182
|
+
* the DOM before paint, or the new rail shows one frame at its final spot. */
|
|
183
|
+
const useIsomorphicLayoutEffect =
|
|
184
|
+
typeof window !== 'undefined' ? React.useLayoutEffect : React.useEffect
|
|
185
|
+
|
|
152
186
|
function getIcon(name: string): LucideIcon {
|
|
153
187
|
return ICON_MAP[name] ?? Home
|
|
154
188
|
}
|
|
@@ -182,6 +216,9 @@ function NavItem({
|
|
|
182
216
|
{!collapsed && (
|
|
183
217
|
<>
|
|
184
218
|
<span className="flex-1 truncate text-left">{item.label}</span>
|
|
219
|
+
{item.premium && (
|
|
220
|
+
<Crown className="ml-auto h-3.5 w-3.5 shrink-0 text-amber-500" aria-hidden />
|
|
221
|
+
)}
|
|
185
222
|
{item.badge !== undefined && (
|
|
186
223
|
<span className="ml-auto rounded-full bg-primary px-2 py-0.5 text-xs font-semibold text-primary-foreground">
|
|
187
224
|
{item.badge}
|
|
@@ -203,6 +240,9 @@ function NavItem({
|
|
|
203
240
|
className="fayz-glass-surface z-50 rounded-md bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md"
|
|
204
241
|
>
|
|
205
242
|
{item.label}
|
|
243
|
+
{item.premium && (
|
|
244
|
+
<Crown className="ml-1.5 inline h-3 w-3 text-amber-500" aria-hidden />
|
|
245
|
+
)}
|
|
206
246
|
{item.badge !== undefined && (
|
|
207
247
|
<span className="ml-2 rounded-full bg-primary px-1.5 py-0.5 text-xs text-primary-foreground">
|
|
208
248
|
{item.badge}
|
|
@@ -339,6 +379,9 @@ export function Sidebar({
|
|
|
339
379
|
userMenuSlot,
|
|
340
380
|
notificationSlot,
|
|
341
381
|
unreadCount = 0,
|
|
382
|
+
sectionLabels,
|
|
383
|
+
navKey,
|
|
384
|
+
navDirection = 'forward',
|
|
342
385
|
}: SidebarProps) {
|
|
343
386
|
const currentPath = currentPathProp
|
|
344
387
|
const setCommandPaletteOpen = useLayoutStore((s) => s.setCommandPaletteOpen)
|
|
@@ -355,6 +398,70 @@ export function Sidebar({
|
|
|
355
398
|
onNavigate?.(route)
|
|
356
399
|
}
|
|
357
400
|
|
|
401
|
+
const sectionHeading = (label?: string) =>
|
|
402
|
+
label && !collapsed ? (
|
|
403
|
+
<p className="px-2.5 pb-1 pt-1 text-[10px] font-semibold uppercase tracking-wider text-sidebar-muted">
|
|
404
|
+
{label}
|
|
405
|
+
</p>
|
|
406
|
+
) : null
|
|
407
|
+
|
|
408
|
+
const renderItem = (item: NavigationItem) =>
|
|
409
|
+
item.children && item.children.length > 0 ? (
|
|
410
|
+
<NavGroup key={item.id} item={item} collapsed={collapsed} activeRoute={activeRoute} onNavigate={handleNavigate} />
|
|
411
|
+
) : (
|
|
412
|
+
<NavItem
|
|
413
|
+
key={item.id}
|
|
414
|
+
item={item}
|
|
415
|
+
collapsed={collapsed}
|
|
416
|
+
isActive={item.route === activeRoute}
|
|
417
|
+
onNavigate={handleNavigate}
|
|
418
|
+
/>
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
// The swappable part of the rail (top slot + both nav sections). Kept as one
|
|
422
|
+
// node so a context change can snapshot the outgoing rail and animate it out.
|
|
423
|
+
const navBody = (
|
|
424
|
+
<>
|
|
425
|
+
{topContent && <div className="mb-4 space-y-2">{topContent}</div>}
|
|
426
|
+
{mainItems.length > 0 && (
|
|
427
|
+
<div className="space-y-1">
|
|
428
|
+
{sectionHeading(sectionLabels?.main)}
|
|
429
|
+
{mainItems.map(renderItem)}
|
|
430
|
+
</div>
|
|
431
|
+
)}
|
|
432
|
+
{secondaryItems.length > 0 && (
|
|
433
|
+
<div className="mt-6 space-y-1">
|
|
434
|
+
{sectionHeading(sectionLabels?.secondary)}
|
|
435
|
+
{secondaryItems.map(renderItem)}
|
|
436
|
+
</div>
|
|
437
|
+
)}
|
|
438
|
+
</>
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
// Rail push: when `navKey` changes, hold the previous rail on screen for one
|
|
442
|
+
// animation cycle. `lastBodyRef` is refreshed after every render, so the
|
|
443
|
+
// snapshot is the rail exactly as it looked before the swap (correct active
|
|
444
|
+
// item included) rather than a stale one.
|
|
445
|
+
const [outgoing, setOutgoing] = React.useState<{ body: React.ReactNode; direction: 'forward' | 'back' } | null>(null)
|
|
446
|
+
const lastBodyRef = React.useRef<React.ReactNode>(null)
|
|
447
|
+
const lastKeyRef = React.useRef(navKey)
|
|
448
|
+
const timerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
449
|
+
|
|
450
|
+
useIsomorphicLayoutEffect(() => {
|
|
451
|
+
if (lastKeyRef.current !== navKey) {
|
|
452
|
+
const previousBody = lastBodyRef.current
|
|
453
|
+
lastKeyRef.current = navKey
|
|
454
|
+
if (previousBody) {
|
|
455
|
+
setOutgoing({ body: previousBody, direction: navDirection })
|
|
456
|
+
if (timerRef.current) clearTimeout(timerRef.current)
|
|
457
|
+
timerRef.current = setTimeout(() => setOutgoing(null), RAIL_PUSH_MS)
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
lastBodyRef.current = navBody
|
|
461
|
+
})
|
|
462
|
+
|
|
463
|
+
React.useEffect(() => () => { if (timerRef.current) clearTimeout(timerRef.current) }, [])
|
|
464
|
+
|
|
358
465
|
return (
|
|
359
466
|
<Tooltip.Provider>
|
|
360
467
|
<aside
|
|
@@ -396,45 +503,35 @@ export function Sidebar({
|
|
|
396
503
|
)}
|
|
397
504
|
</div>
|
|
398
505
|
|
|
399
|
-
{/* Main Navigation
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
{
|
|
404
|
-
|
|
405
|
-
|
|
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>
|
|
506
|
+
{/* Main Navigation. During a rail swap the outgoing nav is pinned on
|
|
507
|
+
top of the incoming one so the two cross-slide; overflow is clipped
|
|
508
|
+
for the duration so neither pane can scroll the rail sideways. */}
|
|
509
|
+
<nav
|
|
510
|
+
className={cn(
|
|
511
|
+
'relative flex-1 p-2',
|
|
512
|
+
outgoing ? 'overflow-hidden' : 'overflow-y-auto',
|
|
419
513
|
)}
|
|
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
|
-
)
|
|
514
|
+
>
|
|
515
|
+
{outgoing && (
|
|
516
|
+
<div
|
|
517
|
+
aria-hidden
|
|
518
|
+
className={cn(
|
|
519
|
+
'pointer-events-none absolute inset-x-2 top-2 space-y-1',
|
|
520
|
+
outgoing.direction === 'back' ? 'saas-rail-out-back' : 'saas-rail-out-forward',
|
|
435
521
|
)}
|
|
522
|
+
>
|
|
523
|
+
{outgoing.body}
|
|
436
524
|
</div>
|
|
437
525
|
)}
|
|
526
|
+
<div
|
|
527
|
+
key={navKey}
|
|
528
|
+
className={cn(
|
|
529
|
+
'space-y-1',
|
|
530
|
+
outgoing && (outgoing.direction === 'back' ? 'saas-rail-in-back' : 'saas-rail-in-forward'),
|
|
531
|
+
)}
|
|
532
|
+
>
|
|
533
|
+
{navBody}
|
|
534
|
+
</div>
|
|
438
535
|
</nav>
|
|
439
536
|
|
|
440
537
|
{/* 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}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import React, { useState, useEffect, useRef, useCallback } from 'react'
|
|
2
|
+
import { createPortal } from 'react-dom'
|
|
3
|
+
import { Search, Plus } from 'lucide-react'
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Reusable search combobox with keyboard navigation + optional create
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
export interface ComboboxOption {
|
|
10
|
+
id: string
|
|
11
|
+
label: string
|
|
12
|
+
subtitle?: string
|
|
13
|
+
price?: number
|
|
14
|
+
data?: Record<string, unknown>
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface SearchComboboxProps {
|
|
18
|
+
value: string
|
|
19
|
+
onChange: (value: string) => void
|
|
20
|
+
onSelect: (option: ComboboxOption) => void
|
|
21
|
+
options: ComboboxOption[]
|
|
22
|
+
placeholder?: string
|
|
23
|
+
autoFocus?: boolean
|
|
24
|
+
onBlurEmpty?: () => void
|
|
25
|
+
renderRight?: (option: ComboboxOption) => React.ReactNode
|
|
26
|
+
/** Show a "+ Create ..." option when search has no exact match */
|
|
27
|
+
allowCreate?: boolean
|
|
28
|
+
/** Called when user clicks the create option. Receives the current search text. */
|
|
29
|
+
onCreateNew?: (searchText: string) => void
|
|
30
|
+
/** Label for the create option (default: 'Create') */
|
|
31
|
+
createLabel?: string
|
|
32
|
+
className?: string
|
|
33
|
+
/** Minimal style — no border/bg, just underline on focus. For inline use. */
|
|
34
|
+
minimal?: boolean
|
|
35
|
+
/** Inline label shown when collapsed. Click reveals the search input. */
|
|
36
|
+
inlineLabel?: string
|
|
37
|
+
/** Icon for the inline label: 'search' (default) or 'plus' (for "add" actions) */
|
|
38
|
+
inlineIcon?: 'search' | 'plus'
|
|
39
|
+
/** Show a loading skeleton in the dropdown while results are being fetched */
|
|
40
|
+
loading?: boolean
|
|
41
|
+
/** Called when the input gains focus */
|
|
42
|
+
onFocus?: () => void
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function SearchCombobox({
|
|
46
|
+
value, onChange, onSelect, options, placeholder, autoFocus, onBlurEmpty,
|
|
47
|
+
renderRight, allowCreate, onCreateNew, createLabel = 'Create', className, minimal, inlineLabel, inlineIcon = 'search',
|
|
48
|
+
loading, onFocus: onFocusProp,
|
|
49
|
+
}: SearchComboboxProps) {
|
|
50
|
+
const [open, setOpen] = useState(false)
|
|
51
|
+
const [activeIndex, setActiveIndex] = useState(-1)
|
|
52
|
+
const [expanded, setExpanded] = useState(!inlineLabel)
|
|
53
|
+
const [dropRect, setDropRect] = useState<DOMRect | null>(null)
|
|
54
|
+
const inputRef = useRef<HTMLInputElement>(null)
|
|
55
|
+
const wrapperRef = useRef<HTMLDivElement>(null)
|
|
56
|
+
const listRef = useRef<HTMLDivElement>(null)
|
|
57
|
+
|
|
58
|
+
// When value is set externally (e.g. edit mode), auto-expand; collapse when cleared
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
if (value && inlineLabel) setExpanded(true)
|
|
61
|
+
if (!value && inlineLabel && !open) setExpanded(false)
|
|
62
|
+
}, [value, open])
|
|
63
|
+
|
|
64
|
+
function updateRect() {
|
|
65
|
+
if (wrapperRef.current) setDropRect(wrapperRef.current.getBoundingClientRect())
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Show create option only when dropdown is open, has text, and no exact match
|
|
69
|
+
const showCreate = open && allowCreate && onCreateNew && value.trim().length > 0 &&
|
|
70
|
+
!options.some((o) => o.label.toLowerCase() === value.trim().toLowerCase())
|
|
71
|
+
|
|
72
|
+
const totalItems = options.length + (showCreate ? 1 : 0)
|
|
73
|
+
|
|
74
|
+
useEffect(() => { setActiveIndex(-1) }, [options])
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
if ((options.length > 0 || showCreate || loading) && document.activeElement === inputRef.current) setOpen(true)
|
|
77
|
+
}, [options, showCreate, loading])
|
|
78
|
+
|
|
79
|
+
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
|
80
|
+
if (!open || totalItems === 0) {
|
|
81
|
+
if (e.key === 'ArrowDown' && totalItems > 0) {
|
|
82
|
+
e.preventDefault()
|
|
83
|
+
setOpen(true)
|
|
84
|
+
setActiveIndex(0)
|
|
85
|
+
}
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
switch (e.key) {
|
|
90
|
+
case 'ArrowDown':
|
|
91
|
+
e.preventDefault()
|
|
92
|
+
setActiveIndex((prev) => (prev + 1) % totalItems)
|
|
93
|
+
break
|
|
94
|
+
case 'ArrowUp':
|
|
95
|
+
e.preventDefault()
|
|
96
|
+
setActiveIndex((prev) => (prev <= 0 ? totalItems - 1 : prev - 1))
|
|
97
|
+
break
|
|
98
|
+
case 'Enter':
|
|
99
|
+
e.preventDefault()
|
|
100
|
+
if (activeIndex >= 0 && activeIndex < options.length) {
|
|
101
|
+
onSelect(options[activeIndex])
|
|
102
|
+
setOpen(false)
|
|
103
|
+
setActiveIndex(-1)
|
|
104
|
+
} else if (showCreate && activeIndex === options.length) {
|
|
105
|
+
onCreateNew?.(value.trim())
|
|
106
|
+
setOpen(false)
|
|
107
|
+
setActiveIndex(-1)
|
|
108
|
+
}
|
|
109
|
+
break
|
|
110
|
+
case 'Escape':
|
|
111
|
+
e.preventDefault()
|
|
112
|
+
setOpen(false)
|
|
113
|
+
setActiveIndex(-1)
|
|
114
|
+
break
|
|
115
|
+
}
|
|
116
|
+
}, [open, options, totalItems, activeIndex, onSelect, showCreate, onCreateNew, value])
|
|
117
|
+
|
|
118
|
+
useEffect(() => {
|
|
119
|
+
if (activeIndex < 0 || !listRef.current) return
|
|
120
|
+
const items = listRef.current.children
|
|
121
|
+
if (items[activeIndex]) (items[activeIndex] as HTMLElement).scrollIntoView({ block: 'nearest' })
|
|
122
|
+
}, [activeIndex])
|
|
123
|
+
|
|
124
|
+
function handleBlur() {
|
|
125
|
+
setTimeout(() => {
|
|
126
|
+
setOpen(false)
|
|
127
|
+
setActiveIndex(-1)
|
|
128
|
+
if (!value && onBlurEmpty) onBlurEmpty()
|
|
129
|
+
if (!value && inlineLabel) setExpanded(false)
|
|
130
|
+
}, 200)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const hasDropdown = open && (options.length > 0 || showCreate || loading)
|
|
134
|
+
|
|
135
|
+
// Collapsed inline label — click to expand
|
|
136
|
+
if (!expanded && inlineLabel) {
|
|
137
|
+
const isPlus = inlineIcon === 'plus'
|
|
138
|
+
return (
|
|
139
|
+
<button type="button" onClick={() => { setExpanded(true); setTimeout(() => inputRef.current?.focus(), 50) }}
|
|
140
|
+
className={`flex items-center gap-1.5 w-full text-left text-sm py-1.5 pl-2 transition-colors ${
|
|
141
|
+
isPlus ? 'text-primary hover:text-primary/80' : 'text-muted-foreground hover:text-foreground'
|
|
142
|
+
} ${className ?? ''}`}>
|
|
143
|
+
{isPlus && <Plus className="h-3.5 w-3.5 shrink-0" />}
|
|
144
|
+
{inlineLabel}
|
|
145
|
+
</button>
|
|
146
|
+
)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return (
|
|
150
|
+
<div ref={wrapperRef} className={`relative ${className ?? ''}`}>
|
|
151
|
+
{!minimal && (
|
|
152
|
+
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground pointer-events-none" />
|
|
153
|
+
)}
|
|
154
|
+
<input
|
|
155
|
+
ref={inputRef}
|
|
156
|
+
type="text"
|
|
157
|
+
value={value}
|
|
158
|
+
onChange={(e) => { onChange(e.target.value); updateRect(); setOpen(true) }}
|
|
159
|
+
onFocus={() => { updateRect(); onFocusProp?.(); if (options.length > 0 || showCreate || loading) setOpen(true) }}
|
|
160
|
+
onBlur={handleBlur}
|
|
161
|
+
onKeyDown={handleKeyDown}
|
|
162
|
+
placeholder={placeholder}
|
|
163
|
+
autoFocus={autoFocus}
|
|
164
|
+
className={minimal
|
|
165
|
+
? 'w-full bg-transparent pl-2 pr-3 py-1.5 text-sm placeholder:text-muted-foreground focus:outline-none border-b border-transparent hover:border-border focus:border-foreground transition-colors'
|
|
166
|
+
: 'w-full rounded-lg border bg-background pl-8 pr-3 py-2 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring'
|
|
167
|
+
}
|
|
168
|
+
role="combobox"
|
|
169
|
+
aria-expanded={hasDropdown}
|
|
170
|
+
aria-autocomplete="list"
|
|
171
|
+
aria-activedescendant={activeIndex >= 0 ? `combobox-opt-${activeIndex}` : undefined}
|
|
172
|
+
/>
|
|
173
|
+
{hasDropdown && dropRect && createPortal(
|
|
174
|
+
<div ref={listRef} role="listbox" data-modal-passthrough
|
|
175
|
+
style={{ position: 'fixed', left: dropRect.left, top: dropRect.bottom + 4, width: dropRect.width, zIndex: 9999, pointerEvents: 'auto' }}
|
|
176
|
+
className="fayz-glass-surface max-h-52 overflow-y-auto rounded-lg border bg-popover shadow-lg py-0.5 animate-in fade-in zoom-in-95 duration-100">
|
|
177
|
+
{loading && options.length === 0 && (
|
|
178
|
+
<div className="flex items-center gap-2 px-3 py-2 text-sm text-muted-foreground">
|
|
179
|
+
<svg className="h-3.5 w-3.5 animate-spin shrink-0" viewBox="0 0 16 16" fill="none">
|
|
180
|
+
<circle cx="8" cy="8" r="6.5" stroke="currentColor" strokeWidth="1.5" opacity="0.25" />
|
|
181
|
+
<path d="M14.5 8a6.5 6.5 0 00-6.5-6.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
|
182
|
+
</svg>
|
|
183
|
+
<span>Buscando…</span>
|
|
184
|
+
</div>
|
|
185
|
+
)}
|
|
186
|
+
{options.map((opt, i) => (
|
|
187
|
+
<div key={opt.id} id={`combobox-opt-${i}`} role="option" aria-selected={i === activeIndex}
|
|
188
|
+
onMouseDown={(e) => { e.preventDefault(); onSelect(opt); setOpen(false) }}
|
|
189
|
+
onMouseEnter={() => setActiveIndex(i)}
|
|
190
|
+
className={`flex w-full items-center gap-2 px-3 py-2 text-sm cursor-pointer transition-colors ${
|
|
191
|
+
i === activeIndex ? 'bg-primary/10 text-primary' : 'hover:bg-muted/50'
|
|
192
|
+
}`}>
|
|
193
|
+
<div className="flex-1 min-w-0">
|
|
194
|
+
<p className="truncate font-medium">{opt.label}</p>
|
|
195
|
+
{opt.subtitle && <p className="truncate text-[11px] text-muted-foreground">{opt.subtitle}</p>}
|
|
196
|
+
</div>
|
|
197
|
+
{renderRight && <div className="shrink-0">{renderRight(opt)}</div>}
|
|
198
|
+
</div>
|
|
199
|
+
))}
|
|
200
|
+
{showCreate && (
|
|
201
|
+
<div id={`combobox-opt-${options.length}`} role="option" aria-selected={activeIndex === options.length}
|
|
202
|
+
onMouseDown={(e) => { e.preventDefault(); onCreateNew?.(value.trim()); setOpen(false) }}
|
|
203
|
+
onMouseEnter={() => setActiveIndex(options.length)}
|
|
204
|
+
className={`flex w-full items-center gap-2 px-3 py-2 text-sm cursor-pointer transition-colors border-t ${
|
|
205
|
+
activeIndex === options.length ? 'bg-primary/10 text-primary' : 'hover:bg-muted/50'
|
|
206
|
+
}`}>
|
|
207
|
+
<Plus className="h-3.5 w-3.5 shrink-0" />
|
|
208
|
+
<span>{createLabel} “<span className="font-medium">{value.trim()}</span>”</span>
|
|
209
|
+
</div>
|
|
210
|
+
)}
|
|
211
|
+
</div>,
|
|
212
|
+
document.body,
|
|
213
|
+
)}
|
|
214
|
+
</div>
|
|
215
|
+
)
|
|
216
|
+
}
|
|
@@ -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
|
}
|
package/src/primitives/index.ts
CHANGED
|
@@ -83,3 +83,4 @@ export { Popover, PopoverTrigger, PopoverContent } from './popover'
|
|
|
83
83
|
export { SegmentedControl, type SegmentedControlProps, type SegmentedOption } from './segmented-control'
|
|
84
84
|
export { Markdown, renderMarkdown, type MarkdownProps } from './markdown'
|
|
85
85
|
export { MarkdownEditor, type MarkdownEditorProps } from './markdown-editor'
|
|
86
|
+
export { SearchCombobox, type ComboboxOption } from './SearchCombobox'
|