@fayz-ai/ui 0.6.0 → 0.6.6
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-7TBDPNLY.cjs → chunk-5QFB6AIQ.cjs} +40 -11
- package/dist/chunk-5QFB6AIQ.cjs.map +1 -0
- package/dist/{chunk-GE6XUREM.cjs → chunk-DJHS2XRW.cjs} +185 -21
- package/dist/chunk-DJHS2XRW.cjs.map +1 -0
- package/dist/{chunk-CZ6X6PZT.js → chunk-LO5QZO7F.js} +192 -28
- package/dist/chunk-LO5QZO7F.js.map +1 -0
- package/dist/{chunk-GFH5HOCO.js → chunk-XLBGB7IW.js} +40 -12
- package/dist/chunk-XLBGB7IW.js.map +1 -0
- package/dist/dashboard/DashboardCanvas.d.ts +5 -1
- package/dist/dashboard/DashboardCanvas.d.ts.map +1 -1
- package/dist/dashboard/GoldCard.d.ts +19 -0
- package/dist/dashboard/GoldCard.d.ts.map +1 -0
- package/dist/dashboard/KpiCard.d.ts.map +1 -1
- package/dist/dashboard/index.cjs +28 -24
- package/dist/dashboard/index.d.ts +1 -0
- package/dist/dashboard/index.d.ts.map +1 -1
- package/dist/dashboard/index.js +1 -1
- package/dist/index.cjs +46 -42
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/layout/AppShell.d.ts +27 -6
- package/dist/layout/AppShell.d.ts.map +1 -1
- package/dist/layout/Sidebar.d.ts.map +1 -1
- package/dist/layout/Topbar.d.ts.map +1 -1
- package/dist/layout/index.cjs +17 -17
- package/dist/layout/index.d.ts +1 -1
- package/dist/layout/index.d.ts.map +1 -1
- package/dist/layout/index.js +1 -1
- package/package.json +3 -6
- package/src/dashboard/DashboardCanvas.tsx +13 -2
- package/src/dashboard/GoldCard.tsx +38 -0
- package/src/dashboard/KpiCard.tsx +4 -2
- package/src/dashboard/index.ts +1 -0
- package/src/index.ts +6 -0
- package/src/layout/AppShell.tsx +240 -20
- package/src/layout/ModulePage.tsx +2 -2
- package/src/layout/Sidebar.tsx +32 -6
- package/src/layout/Topbar.tsx +46 -17
- package/src/layout/index.ts +9 -1
- package/dist/chunk-7TBDPNLY.cjs.map +0 -1
- package/dist/chunk-CZ6X6PZT.js.map +0 -1
- package/dist/chunk-GE6XUREM.cjs.map +0 -1
- package/dist/chunk-GFH5HOCO.js.map +0 -1
package/dist/layout/index.cjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
var
|
|
3
|
+
var chunkDJHS2XRW_cjs = require('../chunk-DJHS2XRW.cjs');
|
|
4
4
|
var chunkW4XXXJRW_cjs = require('../chunk-W4XXXJRW.cjs');
|
|
5
5
|
require('../chunk-VVVZO2LE.cjs');
|
|
6
6
|
|
|
@@ -8,67 +8,67 @@ require('../chunk-VVVZO2LE.cjs');
|
|
|
8
8
|
|
|
9
9
|
Object.defineProperty(exports, "AppShell", {
|
|
10
10
|
enumerable: true,
|
|
11
|
-
get: function () { return
|
|
11
|
+
get: function () { return chunkDJHS2XRW_cjs.AppShell; }
|
|
12
12
|
});
|
|
13
13
|
Object.defineProperty(exports, "BackStyleProvider", {
|
|
14
14
|
enumerable: true,
|
|
15
|
-
get: function () { return
|
|
15
|
+
get: function () { return chunkDJHS2XRW_cjs.BackStyleProvider; }
|
|
16
16
|
});
|
|
17
17
|
Object.defineProperty(exports, "ICON_MAP", {
|
|
18
18
|
enumerable: true,
|
|
19
|
-
get: function () { return
|
|
19
|
+
get: function () { return chunkDJHS2XRW_cjs.ICON_MAP; }
|
|
20
20
|
});
|
|
21
21
|
Object.defineProperty(exports, "ModuleLayoutProvider", {
|
|
22
22
|
enumerable: true,
|
|
23
|
-
get: function () { return
|
|
23
|
+
get: function () { return chunkDJHS2XRW_cjs.ModuleLayoutProvider; }
|
|
24
24
|
});
|
|
25
25
|
Object.defineProperty(exports, "ModulePage", {
|
|
26
26
|
enumerable: true,
|
|
27
|
-
get: function () { return
|
|
27
|
+
get: function () { return chunkDJHS2XRW_cjs.ModulePage; }
|
|
28
28
|
});
|
|
29
29
|
Object.defineProperty(exports, "NavTransitionProvider", {
|
|
30
30
|
enumerable: true,
|
|
31
|
-
get: function () { return
|
|
31
|
+
get: function () { return chunkDJHS2XRW_cjs.NavTransitionProvider; }
|
|
32
32
|
});
|
|
33
33
|
Object.defineProperty(exports, "PageHeaderActions", {
|
|
34
34
|
enumerable: true,
|
|
35
|
-
get: function () { return
|
|
35
|
+
get: function () { return chunkDJHS2XRW_cjs.PageHeaderActions; }
|
|
36
36
|
});
|
|
37
37
|
Object.defineProperty(exports, "PageTransition", {
|
|
38
38
|
enumerable: true,
|
|
39
|
-
get: function () { return
|
|
39
|
+
get: function () { return chunkDJHS2XRW_cjs.PageTransition; }
|
|
40
40
|
});
|
|
41
41
|
Object.defineProperty(exports, "Sidebar", {
|
|
42
42
|
enumerable: true,
|
|
43
|
-
get: function () { return
|
|
43
|
+
get: function () { return chunkDJHS2XRW_cjs.Sidebar; }
|
|
44
44
|
});
|
|
45
45
|
Object.defineProperty(exports, "SubpageHeader", {
|
|
46
46
|
enumerable: true,
|
|
47
|
-
get: function () { return
|
|
47
|
+
get: function () { return chunkDJHS2XRW_cjs.SubpageHeader; }
|
|
48
48
|
});
|
|
49
49
|
Object.defineProperty(exports, "Topbar", {
|
|
50
50
|
enumerable: true,
|
|
51
|
-
get: function () { return
|
|
51
|
+
get: function () { return chunkDJHS2XRW_cjs.Topbar; }
|
|
52
52
|
});
|
|
53
53
|
Object.defineProperty(exports, "useBackStyle", {
|
|
54
54
|
enumerable: true,
|
|
55
|
-
get: function () { return
|
|
55
|
+
get: function () { return chunkDJHS2XRW_cjs.useBackStyle; }
|
|
56
56
|
});
|
|
57
57
|
Object.defineProperty(exports, "useModuleHeaderActionsSlot", {
|
|
58
58
|
enumerable: true,
|
|
59
|
-
get: function () { return
|
|
59
|
+
get: function () { return chunkDJHS2XRW_cjs.useModuleHeaderActionsSlot; }
|
|
60
60
|
});
|
|
61
61
|
Object.defineProperty(exports, "useModuleHeaderSlot", {
|
|
62
62
|
enumerable: true,
|
|
63
|
-
get: function () { return
|
|
63
|
+
get: function () { return chunkDJHS2XRW_cjs.useModuleHeaderSlot; }
|
|
64
64
|
});
|
|
65
65
|
Object.defineProperty(exports, "useModuleLayout", {
|
|
66
66
|
enumerable: true,
|
|
67
|
-
get: function () { return
|
|
67
|
+
get: function () { return chunkDJHS2XRW_cjs.useModuleLayout; }
|
|
68
68
|
});
|
|
69
69
|
Object.defineProperty(exports, "useNavTransition", {
|
|
70
70
|
enumerable: true,
|
|
71
|
-
get: function () { return
|
|
71
|
+
get: function () { return chunkDJHS2XRW_cjs.useNavTransition; }
|
|
72
72
|
});
|
|
73
73
|
Object.defineProperty(exports, "SaveBar", {
|
|
74
74
|
enumerable: true,
|
package/dist/layout/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { AppShell, type AppShellProps, type AppShellUser } from './AppShell';
|
|
1
|
+
export { AppShell, type AppShellProps, type AppShellUser, type BottomNavItem, type BottomNavRouteItem, type BottomNavActionItem, type MobileHeaderVariant, } from './AppShell';
|
|
2
2
|
export { Sidebar, type SidebarProps, type SidebarUser, type NavigationItem, ICON_MAP } from './Sidebar';
|
|
3
3
|
export { Topbar, type TopbarProps } from './Topbar';
|
|
4
4
|
export { ModulePage, SubpageHeader, ModuleLayoutProvider, useModuleLayout, PageHeaderActions, BackStyleProvider, useBackStyle, type ModulePageProps, type ModuleNavItem, type ModuleNavVariant, type SubpageHeaderProps, type BackButtonStyle, } from './ModulePage';
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/layout/index.ts"],"names":[],"mappings":"AAAA,OAAO,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/layout/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,QAAQ,EACR,KAAK,aAAa,EAClB,KAAK,YAAY,EACjB,KAAK,aAAa,EAClB,KAAK,kBAAkB,EACvB,KAAK,mBAAmB,EACxB,KAAK,mBAAmB,GACzB,MAAM,YAAY,CAAA;AACnB,OAAO,EAAE,OAAO,EAAE,KAAK,YAAY,EAAE,KAAK,WAAW,EAAE,KAAK,cAAc,EAAE,QAAQ,EAAE,MAAM,WAAW,CAAA;AACvG,OAAO,EAAE,MAAM,EAAE,KAAK,WAAW,EAAE,MAAM,UAAU,CAAA;AACnD,OAAO,EACL,UAAU,EACV,aAAa,EACb,oBAAoB,EACpB,eAAe,EACf,iBAAiB,EACjB,iBAAiB,EACjB,YAAY,EACZ,KAAK,eAAe,EACpB,KAAK,aAAa,EAClB,KAAK,gBAAgB,EACrB,KAAK,kBAAkB,EACvB,KAAK,eAAe,GACrB,MAAM,cAAc,CAAA;AACrB,OAAO,EAAE,mBAAmB,EAAE,0BAA0B,EAAE,MAAM,YAAY,CAAA;AAC5E,OAAO,EAAE,OAAO,EAAE,eAAe,EAAE,UAAU,EAAE,cAAc,EAAE,KAAK,mBAAmB,EAAE,MAAM,WAAW,CAAA;AAC1G,OAAO,EACL,cAAc,EACd,qBAAqB,EACrB,gBAAgB,EAChB,KAAK,aAAa,GACnB,MAAM,kBAAkB,CAAA"}
|
package/dist/layout/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { AppShell, BackStyleProvider, ICON_MAP, ModuleLayoutProvider, ModulePage, NavTransitionProvider, PageHeaderActions, PageTransition, Sidebar, SubpageHeader, Topbar, useBackStyle, useModuleHeaderActionsSlot, useModuleHeaderSlot, useModuleLayout, useNavTransition } from '../chunk-
|
|
1
|
+
export { AppShell, BackStyleProvider, ICON_MAP, ModuleLayoutProvider, ModulePage, NavTransitionProvider, PageHeaderActions, PageTransition, Sidebar, SubpageHeader, Topbar, useBackStyle, useModuleHeaderActionsSlot, useModuleHeaderSlot, useModuleLayout, useNavTransition } from '../chunk-LO5QZO7F.js';
|
|
2
2
|
export { SaveBar, SaveBarProvider, useBackHandler, useSaveBar } from '../chunk-E526FHZ4.js';
|
|
3
3
|
import '../chunk-DNYVEOAX.js';
|
|
4
4
|
//# sourceMappingURL=index.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fayz-ai/ui",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.6",
|
|
4
4
|
"description": "Fayz SDK UI — Radix + Tailwind component library with CRUD engine and layout shells",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.cjs",
|
|
@@ -81,8 +81,8 @@
|
|
|
81
81
|
"sonner": "^1.5.0",
|
|
82
82
|
"tailwind-merge": "^2.4.0",
|
|
83
83
|
"zustand": "^4.5.0",
|
|
84
|
-
"@fayz-ai/core": "^0.6.
|
|
85
|
-
"@fayz-ai/auth": "^0.6.
|
|
84
|
+
"@fayz-ai/core": "^0.6.6",
|
|
85
|
+
"@fayz-ai/auth": "^0.6.6"
|
|
86
86
|
},
|
|
87
87
|
"devDependencies": {
|
|
88
88
|
"@types/react": "^18.3.0",
|
|
@@ -96,9 +96,6 @@
|
|
|
96
96
|
"typescript": "^5.5.0"
|
|
97
97
|
},
|
|
98
98
|
"license": "MIT",
|
|
99
|
-
"publishConfig": {
|
|
100
|
-
"access": "public"
|
|
101
|
-
},
|
|
102
99
|
"scripts": {
|
|
103
100
|
"build": "tsup && tsc --emitDeclarationOnly --declaration --declarationMap --noEmit false",
|
|
104
101
|
"dev": "tsup --watch",
|
|
@@ -90,6 +90,10 @@ export interface DashboardCanvasProps {
|
|
|
90
90
|
showHeader?: boolean
|
|
91
91
|
/** App-level curation of which widgets show, their order and span. */
|
|
92
92
|
appLayout?: DashboardLayoutConfig
|
|
93
|
+
/** App-level widgets injected into this surface's grid without a plugin — e.g.
|
|
94
|
+
* an "Ask the assistant" CTA. Merged with the registry widgets, then laid out
|
|
95
|
+
* (respects each widget's span/defaultOrder). Filtered to this surface. */
|
|
96
|
+
extraWidgets?: DashboardWidgetDef[]
|
|
93
97
|
/** Show a shared time-range control (sticky, top-right). Widgets read it via
|
|
94
98
|
* useDashboardRange(). Pass `true` for defaults or an options object. */
|
|
95
99
|
range?: boolean | DashboardRangeOptions
|
|
@@ -115,11 +119,18 @@ export interface DashboardRangeOptions {
|
|
|
115
119
|
}
|
|
116
120
|
|
|
117
121
|
export function DashboardCanvas({
|
|
118
|
-
surface, domain, title, subtitle, showHeader = true, appLayout, range,
|
|
122
|
+
surface, domain, title, subtitle, showHeader = true, appLayout, extraWidgets, range,
|
|
119
123
|
customizable = true, customizeLabel, initialVisibility, onNavigate, className,
|
|
120
124
|
}: DashboardCanvasProps) {
|
|
121
125
|
const runtime = usePluginRuntime()
|
|
122
|
-
const
|
|
126
|
+
const registryWidgets = getDashboardWidgets(runtime, { surface, domain })
|
|
127
|
+
const widgets = React.useMemo<DashboardWidgetDef[]>(() => {
|
|
128
|
+
const extra = (extraWidgets ?? []).filter((w) => {
|
|
129
|
+
if (!(w.surfaces ?? ['home', 'plugin-home']).includes(surface)) return false
|
|
130
|
+
return !domain || w.domain === domain
|
|
131
|
+
})
|
|
132
|
+
return extra.length ? [...registryWidgets, ...extra] : registryWidgets
|
|
133
|
+
}, [registryWidgets, extraWidgets, surface, domain])
|
|
123
134
|
const rangeOpts: DashboardRangeOptions | null = range ? (range === true ? {} : range) : null
|
|
124
135
|
|
|
125
136
|
const forceAll = (initialVisibility ?? (surface === 'plugin-home' ? 'all' : 'default')) === 'all'
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import { cn } from '../utils/cn'
|
|
3
|
+
|
|
4
|
+
export interface GoldCardProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
5
|
+
/**
|
|
6
|
+
* Brand-colored hero variant — `bg-primary text-primary-foreground` with a
|
|
7
|
+
* soft branded shadow. Use for the headline card (e.g. a balance hero). When
|
|
8
|
+
* false (default) it's a plain surface card (`bg-card` + border).
|
|
9
|
+
*/
|
|
10
|
+
branded?: boolean
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* GoldCard — a generic, reusable rounded card wrapper for dashboard surfaces.
|
|
15
|
+
*
|
|
16
|
+
* The `branded` variant paints the card in the app's primary color so a single
|
|
17
|
+
* hero (balance, headline KPI, CTA) pops against the neutral grid. Both variants
|
|
18
|
+
* are internally full-width so they reflow cleanly inside the responsive
|
|
19
|
+
* DashboardGrid (full-width on mobile, cell-width on desktop).
|
|
20
|
+
*/
|
|
21
|
+
export const GoldCard = React.forwardRef<HTMLDivElement, GoldCardProps>(
|
|
22
|
+
({ branded = false, className, children, ...rest }, ref) => (
|
|
23
|
+
<div
|
|
24
|
+
ref={ref}
|
|
25
|
+
className={cn(
|
|
26
|
+
'h-full rounded-3xl p-5',
|
|
27
|
+
branded
|
|
28
|
+
? 'bg-primary text-primary-foreground shadow-lg shadow-primary/20'
|
|
29
|
+
: 'border border-border bg-card text-card-foreground shadow-sm',
|
|
30
|
+
className,
|
|
31
|
+
)}
|
|
32
|
+
{...rest}
|
|
33
|
+
>
|
|
34
|
+
{children}
|
|
35
|
+
</div>
|
|
36
|
+
),
|
|
37
|
+
)
|
|
38
|
+
GoldCard.displayName = 'GoldCard'
|
|
@@ -222,8 +222,10 @@ export function KpiCard(props: KpiCardProps) {
|
|
|
222
222
|
}
|
|
223
223
|
|
|
224
224
|
return (
|
|
225
|
-
|
|
226
|
-
|
|
225
|
+
// h-full so cards fill the (stretch) grid cell and every card in a row matches
|
|
226
|
+
// the tallest — labels/subtitles of different lengths no longer misalign them.
|
|
227
|
+
<Card className="h-full">
|
|
228
|
+
<CardContent className="flex h-full flex-col p-4">
|
|
227
229
|
<div className="mb-2 flex items-center justify-between gap-2">
|
|
228
230
|
<span className="text-sm text-muted-foreground">{label}</span>
|
|
229
231
|
<span className="text-muted-foreground">{renderIcon(icon)}</span>
|
package/src/dashboard/index.ts
CHANGED
|
@@ -10,6 +10,7 @@ export { resolveDashboardLayout, type LaidOutWidget } from './layout'
|
|
|
10
10
|
export { useDashboardPreferences, type DashboardPreferencesState } from './preferences'
|
|
11
11
|
export { DashboardCustomizeMenu, type DashboardCustomizeMenuProps } from './DashboardCustomizeMenu'
|
|
12
12
|
export { DashboardGrid, type DashboardGridItem, type DashboardGridProps } from './DashboardGrid'
|
|
13
|
+
export { GoldCard, type GoldCardProps } from './GoldCard'
|
|
13
14
|
export {
|
|
14
15
|
DashboardRangeProvider,
|
|
15
16
|
DashboardRangeControl,
|
package/src/index.ts
CHANGED
|
@@ -105,6 +105,10 @@ export {
|
|
|
105
105
|
AppShell,
|
|
106
106
|
type AppShellProps,
|
|
107
107
|
type AppShellUser,
|
|
108
|
+
type BottomNavItem,
|
|
109
|
+
type BottomNavRouteItem,
|
|
110
|
+
type BottomNavActionItem,
|
|
111
|
+
type MobileHeaderVariant,
|
|
108
112
|
Sidebar,
|
|
109
113
|
type SidebarProps,
|
|
110
114
|
type SidebarUser,
|
|
@@ -179,6 +183,8 @@ export {
|
|
|
179
183
|
DashboardGrid,
|
|
180
184
|
type DashboardGridItem,
|
|
181
185
|
type DashboardGridProps,
|
|
186
|
+
GoldCard,
|
|
187
|
+
type GoldCardProps,
|
|
182
188
|
DashboardRangeProvider,
|
|
183
189
|
DashboardRangeControl,
|
|
184
190
|
useDashboardRange,
|
package/src/layout/AppShell.tsx
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
import * as React from 'react'
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
Menu,
|
|
4
|
+
Home,
|
|
5
|
+
Receipt,
|
|
6
|
+
ArrowLeftRight,
|
|
7
|
+
Calendar,
|
|
8
|
+
CreditCard,
|
|
9
|
+
Settings,
|
|
10
|
+
MessageCircle,
|
|
11
|
+
Wallet,
|
|
12
|
+
PiggyBank,
|
|
13
|
+
Plus,
|
|
14
|
+
type LucideIcon,
|
|
15
|
+
} from 'lucide-react'
|
|
3
16
|
import { Sidebar, type NavigationItem, type SidebarUser, type SidebarProps } from './Sidebar'
|
|
4
17
|
import { Topbar } from './Topbar'
|
|
5
18
|
import { SaveBar, SaveBarProvider } from './SaveBar'
|
|
@@ -35,6 +48,41 @@ export interface AppShellUser {
|
|
|
35
48
|
email: string
|
|
36
49
|
}
|
|
37
50
|
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// Bottom-nav item model
|
|
53
|
+
//
|
|
54
|
+
// Two kinds share the mobile tab bar:
|
|
55
|
+
// - route (default): a normal tab that navigates to `route`.
|
|
56
|
+
// - action: a NON-route item rendered as a raised, elevated circular center
|
|
57
|
+
// button (Mobills style). It fires `onBottomNavAction(id)` instead of
|
|
58
|
+
// navigating — e.g. a global quick-add "+".
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
export interface BottomNavRouteItem {
|
|
62
|
+
kind?: 'route'
|
|
63
|
+
label: string
|
|
64
|
+
icon: string
|
|
65
|
+
route: string
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface BottomNavActionItem {
|
|
69
|
+
kind: 'action'
|
|
70
|
+
/** Identifier passed to onBottomNavAction. */
|
|
71
|
+
id: string
|
|
72
|
+
icon: string
|
|
73
|
+
/** Optional accessible label. */
|
|
74
|
+
label?: string
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export type BottomNavItem = BottomNavRouteItem | BottomNavActionItem
|
|
78
|
+
|
|
79
|
+
/** Mobile header treatment (small screens, <md). Desktop is unaffected.
|
|
80
|
+
* - 'minimal' : a small top header bar (the default / today's behavior).
|
|
81
|
+
* - 'transparent' : NO header bar; content is edge-to-edge with a floating
|
|
82
|
+
* profile avatar pinned top-right (calls onProfile).
|
|
83
|
+
* - 'hidden' : no header bar and no floating avatar. */
|
|
84
|
+
export type MobileHeaderVariant = 'transparent' | 'minimal' | 'hidden'
|
|
85
|
+
|
|
38
86
|
export interface AppShellProps {
|
|
39
87
|
/** Layout variant */
|
|
40
88
|
variant: 'sidebar' | 'topbar' | 'minimal'
|
|
@@ -58,7 +106,11 @@ export interface AppShellProps {
|
|
|
58
106
|
topbarEnd?: React.ReactNode
|
|
59
107
|
sidebarTopContent?: React.ReactNode
|
|
60
108
|
sidebarFooterContent?: React.ReactNode
|
|
61
|
-
bottomNav?:
|
|
109
|
+
bottomNav?: BottomNavItem[]
|
|
110
|
+
/** Fired when a bottom-nav `action` item (e.g. the center "+") is tapped. */
|
|
111
|
+
onBottomNavAction?: (id: string) => void
|
|
112
|
+
/** Mobile header treatment (<md). Default: 'minimal'. Desktop unaffected. */
|
|
113
|
+
mobileHeader?: MobileHeaderVariant
|
|
62
114
|
/** Rendered in sidebar bottom user slot */
|
|
63
115
|
userMenuSlot?: React.ReactNode
|
|
64
116
|
/** Rendered in sidebar bottom notification slot */
|
|
@@ -93,6 +145,8 @@ function SidebarLayout({
|
|
|
93
145
|
userMenuSlot,
|
|
94
146
|
notificationSlot,
|
|
95
147
|
unreadCount = 0,
|
|
148
|
+
hasBottomNav = false,
|
|
149
|
+
mobileHeader = 'minimal',
|
|
96
150
|
}: {
|
|
97
151
|
navigation?: NavigationItem[]
|
|
98
152
|
logo?: React.ReactNode
|
|
@@ -115,6 +169,11 @@ function SidebarLayout({
|
|
|
115
169
|
userMenuSlot?: React.ReactNode
|
|
116
170
|
notificationSlot?: React.ReactNode
|
|
117
171
|
unreadCount?: number
|
|
172
|
+
/** When a mobile bottom-nav bar is present, the mobile hamburger is
|
|
173
|
+
* suppressed so the bottom nav is the primary mobile navigation. */
|
|
174
|
+
hasBottomNav?: boolean
|
|
175
|
+
/** Mobile header treatment (<md). Desktop keeps its top bar untouched. */
|
|
176
|
+
mobileHeader?: MobileHeaderVariant
|
|
118
177
|
}) {
|
|
119
178
|
const sidebarCollapsed = useLayoutStore((s) => s.sidebarCollapsed)
|
|
120
179
|
const setSidebarCollapsed = useLayoutStore((s) => s.setSidebarCollapsed)
|
|
@@ -126,7 +185,13 @@ function SidebarLayout({
|
|
|
126
185
|
<SaveBarProvider>
|
|
127
186
|
<ModuleHeaderSlotContext.Provider value={headerSlot}>
|
|
128
187
|
<ModuleHeaderActionsSlotContext.Provider value={actionsSlot}>
|
|
129
|
-
<div className=
|
|
188
|
+
<div className={cn(
|
|
189
|
+
'flex h-screen overflow-hidden bg-background',
|
|
190
|
+
// Frame mode: the inset gap around the framed card takes the sidebar (menu)
|
|
191
|
+
// color, so the white card reads as a panel floating on the same tone as the
|
|
192
|
+
// left menu. The card's own md:m-2 margin reveals this behind it.
|
|
193
|
+
frame && 'md:bg-sidebar',
|
|
194
|
+
)}>
|
|
130
195
|
{/* Desktop Sidebar — always flush / full-height (GoHighLevel style) */}
|
|
131
196
|
<div className="hidden md:flex">
|
|
132
197
|
<Sidebar
|
|
@@ -158,16 +223,27 @@ function SidebarLayout({
|
|
|
158
223
|
'relative flex flex-1 flex-col overflow-hidden',
|
|
159
224
|
frame && 'md:m-2 md:rounded-xl md:border md:border-border md:bg-card'
|
|
160
225
|
)}>
|
|
161
|
-
{/* Top bar — mobile hamburger + module header slot (title + sub-nav) + actions
|
|
162
|
-
|
|
226
|
+
{/* Top bar — mobile hamburger + module header slot (title + sub-nav) + actions.
|
|
227
|
+
On mobile the bar is suppressed for the 'transparent' / 'hidden' header
|
|
228
|
+
treatments (content goes edge-to-edge); desktop keeps it always. The
|
|
229
|
+
portal targets below stay mounted (display:none on mobile) so module
|
|
230
|
+
pages can still portal their sub-nav on desktop without crashing. */}
|
|
231
|
+
<div className={cn(
|
|
232
|
+
'flex min-h-14 shrink-0 items-center justify-between gap-3 border-b border-border bg-card px-4 py-2',
|
|
233
|
+
mobileHeader !== 'minimal' && 'hidden md:flex',
|
|
234
|
+
)}>
|
|
163
235
|
<div className="flex min-w-0 flex-1 items-center gap-3">
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
236
|
+
{/* Mobile hamburger — suppressed when a bottom-nav bar owns mobile
|
|
237
|
+
navigation (mobile-first apps). Desktop sidebar is untouched. */}
|
|
238
|
+
{!hasBottomNav && (
|
|
239
|
+
<button
|
|
240
|
+
onClick={() => setMobileMenuOpen(true)}
|
|
241
|
+
className="inline-flex items-center justify-center rounded-md p-2 text-muted-foreground hover:bg-accent md:hidden"
|
|
242
|
+
aria-label="Open menu"
|
|
243
|
+
>
|
|
244
|
+
<Menu className="h-5 w-5" />
|
|
245
|
+
</button>
|
|
246
|
+
)}
|
|
171
247
|
{pageTitle && <h2 className="font-semibold text-lg">{pageTitle}</h2>}
|
|
172
248
|
{topbarStart}
|
|
173
249
|
{/* Module pages (tabs variant) portal their sub-nav here */}
|
|
@@ -181,7 +257,7 @@ function SidebarLayout({
|
|
|
181
257
|
</div>
|
|
182
258
|
|
|
183
259
|
{/* Page content */}
|
|
184
|
-
<main className="flex-1 overflow-y-auto">
|
|
260
|
+
<main className="flex-1 overflow-y-auto pb-16 md:pb-0">
|
|
185
261
|
{children}
|
|
186
262
|
</main>
|
|
187
263
|
|
|
@@ -236,6 +312,7 @@ function TopbarLayout({
|
|
|
236
312
|
currentPath,
|
|
237
313
|
topbarStart,
|
|
238
314
|
topbarEnd,
|
|
315
|
+
hasBottomNav = false,
|
|
239
316
|
}: {
|
|
240
317
|
navigation?: NavigationItem[]
|
|
241
318
|
logo?: React.ReactNode
|
|
@@ -251,6 +328,9 @@ function TopbarLayout({
|
|
|
251
328
|
currentPath?: string
|
|
252
329
|
topbarStart?: React.ReactNode
|
|
253
330
|
topbarEnd?: React.ReactNode
|
|
331
|
+
/** When a mobile bottom-nav bar is present, the mobile hamburger is
|
|
332
|
+
* suppressed so the bottom nav is the primary mobile navigation. */
|
|
333
|
+
hasBottomNav?: boolean
|
|
254
334
|
}) {
|
|
255
335
|
const [mobileMenuOpen, setMobileMenuOpen] = React.useState(false)
|
|
256
336
|
|
|
@@ -271,10 +351,12 @@ function TopbarLayout({
|
|
|
271
351
|
userMenuExtras={userMenuExtras}
|
|
272
352
|
leftContent={topbarStart}
|
|
273
353
|
rightContent={topbarEnd}
|
|
274
|
-
onMenuClick={() => setMobileMenuOpen(true)}
|
|
354
|
+
onMenuClick={hasBottomNav ? undefined : () => setMobileMenuOpen(true)}
|
|
275
355
|
/>
|
|
276
|
-
{/* Frame inset/border only from md up, so mobile stays edge-to-edge.
|
|
277
|
-
|
|
356
|
+
{/* Frame inset/border only from md up, so mobile stays edge-to-edge. The
|
|
357
|
+
inset gap behind the framed card is the header color (bg-sidebar), so the
|
|
358
|
+
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')}>
|
|
278
360
|
<div className={cn('min-h-full', frame && 'md:rounded-xl md:border md:border-border md:bg-card')}>
|
|
279
361
|
{children}
|
|
280
362
|
</div>
|
|
@@ -345,7 +427,7 @@ function MinimalLayout({
|
|
|
345
427
|
</div>
|
|
346
428
|
{headerEnd && <div className="flex items-center gap-2">{headerEnd}</div>}
|
|
347
429
|
</header>
|
|
348
|
-
<main className="flex-1 overflow-y-auto">
|
|
430
|
+
<main className="flex-1 overflow-y-auto pb-16 md:pb-0">
|
|
349
431
|
{children}
|
|
350
432
|
</main>
|
|
351
433
|
</div>
|
|
@@ -426,6 +508,111 @@ function MobileOverlay({ onClose, onNavigate, ...sidebarProps }: MobileOverlayPr
|
|
|
426
508
|
)
|
|
427
509
|
}
|
|
428
510
|
|
|
511
|
+
// ---------------------------------------------------------------------------
|
|
512
|
+
// Mobile bottom tab bar — rendered for every variant on small screens.
|
|
513
|
+
// ui must NOT import from saas, so the icon lookup is a small local map (a
|
|
514
|
+
// mirror of the saas icon convention). Unknown icon names fall back to Home.
|
|
515
|
+
// ---------------------------------------------------------------------------
|
|
516
|
+
|
|
517
|
+
const BOTTOM_NAV_ICONS: Record<string, LucideIcon> = {
|
|
518
|
+
Home,
|
|
519
|
+
Receipt,
|
|
520
|
+
ArrowLeftRight,
|
|
521
|
+
Calendar,
|
|
522
|
+
CreditCard,
|
|
523
|
+
Settings,
|
|
524
|
+
Menu,
|
|
525
|
+
MessageCircle,
|
|
526
|
+
Wallet,
|
|
527
|
+
PiggyBank,
|
|
528
|
+
Plus,
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// Floating profile avatar — mobile-only (md:hidden), pinned top-right over the
|
|
532
|
+
// content. Used with the 'transparent' mobile header treatment so there is no
|
|
533
|
+
// header bar but the user still has a one-tap route into their profile.
|
|
534
|
+
function FloatingProfileAvatar({ user, onClick }: { user?: AppShellUser; onClick?: () => void }) {
|
|
535
|
+
if (!user) return null
|
|
536
|
+
const label = user.fullName || user.email
|
|
537
|
+
return (
|
|
538
|
+
<button
|
|
539
|
+
type="button"
|
|
540
|
+
onClick={onClick}
|
|
541
|
+
aria-label="Profile"
|
|
542
|
+
className="fixed right-3 top-3 z-40 md:hidden"
|
|
543
|
+
>
|
|
544
|
+
{user.avatarUrl ? (
|
|
545
|
+
<img
|
|
546
|
+
src={user.avatarUrl}
|
|
547
|
+
alt=""
|
|
548
|
+
className="h-9 w-9 rounded-full object-cover shadow-md ring-2 ring-background"
|
|
549
|
+
/>
|
|
550
|
+
) : (
|
|
551
|
+
<span className="flex h-9 w-9 items-center justify-center rounded-full bg-primary text-sm font-semibold text-primary-foreground shadow-md ring-2 ring-background">
|
|
552
|
+
{(label || '?').charAt(0).toUpperCase()}
|
|
553
|
+
</span>
|
|
554
|
+
)}
|
|
555
|
+
</button>
|
|
556
|
+
)
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function MobileBottomNav({
|
|
560
|
+
items,
|
|
561
|
+
currentPath,
|
|
562
|
+
onNavigate,
|
|
563
|
+
onAction,
|
|
564
|
+
}: {
|
|
565
|
+
items: NonNullable<AppShellProps['bottomNav']>
|
|
566
|
+
currentPath?: string
|
|
567
|
+
onNavigate?: (route: string) => void
|
|
568
|
+
onAction?: (id: string) => void
|
|
569
|
+
}) {
|
|
570
|
+
const path = currentPath ?? ''
|
|
571
|
+
return (
|
|
572
|
+
<nav className="fixed inset-x-0 bottom-0 z-50 flex h-16 items-center justify-around border-t border-border bg-background md:hidden">
|
|
573
|
+
{items.map((item) => {
|
|
574
|
+
// Elevated center ACTION button (Mobills style) — raised above the bar,
|
|
575
|
+
// circular, primary-filled; fires onBottomNavAction instead of routing.
|
|
576
|
+
if (item.kind === 'action') {
|
|
577
|
+
const ActionIcon = BOTTOM_NAV_ICONS[item.icon] ?? Plus
|
|
578
|
+
return (
|
|
579
|
+
<div key={item.id} className="flex flex-1 items-start justify-center">
|
|
580
|
+
<button
|
|
581
|
+
type="button"
|
|
582
|
+
onClick={() => onAction?.(item.id)}
|
|
583
|
+
aria-label={item.label ?? 'Add'}
|
|
584
|
+
className="-mt-6 flex h-14 w-14 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg shadow-primary/30 ring-4 ring-background transition-transform active:scale-95"
|
|
585
|
+
>
|
|
586
|
+
<ActionIcon className="h-6 w-6" />
|
|
587
|
+
</button>
|
|
588
|
+
</div>
|
|
589
|
+
)
|
|
590
|
+
}
|
|
591
|
+
const isActive =
|
|
592
|
+
item.route === '/'
|
|
593
|
+
? path === '/' || path === ''
|
|
594
|
+
: path === item.route || path.startsWith(item.route + '/')
|
|
595
|
+
const Icon = BOTTOM_NAV_ICONS[item.icon] ?? Home
|
|
596
|
+
return (
|
|
597
|
+
<button
|
|
598
|
+
key={item.route}
|
|
599
|
+
type="button"
|
|
600
|
+
onClick={() => onNavigate?.(item.route)}
|
|
601
|
+
className={cn(
|
|
602
|
+
'flex flex-1 flex-col items-center justify-center gap-0.5 px-1 py-1 transition-colors',
|
|
603
|
+
isActive ? 'text-primary' : 'text-muted-foreground',
|
|
604
|
+
)}
|
|
605
|
+
aria-current={isActive ? 'page' : undefined}
|
|
606
|
+
>
|
|
607
|
+
<Icon className="h-5 w-5" />
|
|
608
|
+
<span className="text-xs">{item.label}</span>
|
|
609
|
+
</button>
|
|
610
|
+
)
|
|
611
|
+
})}
|
|
612
|
+
</nav>
|
|
613
|
+
)
|
|
614
|
+
}
|
|
615
|
+
|
|
429
616
|
// ---------------------------------------------------------------------------
|
|
430
617
|
// AppShell — main entry point
|
|
431
618
|
// ---------------------------------------------------------------------------
|
|
@@ -450,13 +637,32 @@ export function AppShell({
|
|
|
450
637
|
topbarEnd,
|
|
451
638
|
sidebarTopContent,
|
|
452
639
|
sidebarFooterContent,
|
|
640
|
+
bottomNav,
|
|
641
|
+
onBottomNavAction,
|
|
642
|
+
mobileHeader = 'minimal',
|
|
453
643
|
userMenuSlot,
|
|
454
644
|
notificationSlot,
|
|
455
645
|
unreadCount = 0,
|
|
456
646
|
}: AppShellProps) {
|
|
647
|
+
const hasBottomNav = !!bottomNav?.length
|
|
648
|
+
const bottomBar = hasBottomNav ? (
|
|
649
|
+
<MobileBottomNav
|
|
650
|
+
items={bottomNav!}
|
|
651
|
+
currentPath={currentPath}
|
|
652
|
+
onNavigate={onNavigate}
|
|
653
|
+
onAction={onBottomNavAction}
|
|
654
|
+
/>
|
|
655
|
+
) : null
|
|
656
|
+
// Transparent mobile header → no bar, a floating avatar top-right instead.
|
|
657
|
+
const floatingAvatar =
|
|
658
|
+
mobileHeader === 'transparent' && user ? (
|
|
659
|
+
<FloatingProfileAvatar user={user} onClick={onProfile} />
|
|
660
|
+
) : null
|
|
661
|
+
|
|
662
|
+
let layoutElement: React.ReactNode
|
|
457
663
|
switch (variant) {
|
|
458
664
|
case 'sidebar':
|
|
459
|
-
|
|
665
|
+
layoutElement = (
|
|
460
666
|
<SidebarLayout
|
|
461
667
|
navigation={navigation}
|
|
462
668
|
logo={logo}
|
|
@@ -478,13 +684,16 @@ export function AppShell({
|
|
|
478
684
|
userMenuSlot={userMenuSlot}
|
|
479
685
|
notificationSlot={notificationSlot}
|
|
480
686
|
unreadCount={unreadCount}
|
|
687
|
+
hasBottomNav={hasBottomNav}
|
|
688
|
+
mobileHeader={mobileHeader}
|
|
481
689
|
>
|
|
482
690
|
{children}
|
|
483
691
|
</SidebarLayout>
|
|
484
692
|
)
|
|
693
|
+
break
|
|
485
694
|
|
|
486
695
|
case 'topbar':
|
|
487
|
-
|
|
696
|
+
layoutElement = (
|
|
488
697
|
<TopbarLayout
|
|
489
698
|
navigation={navigation}
|
|
490
699
|
logo={logo}
|
|
@@ -499,21 +708,32 @@ export function AppShell({
|
|
|
499
708
|
currentPath={currentPath}
|
|
500
709
|
topbarStart={topbarStart}
|
|
501
710
|
topbarEnd={topbarEnd}
|
|
711
|
+
hasBottomNav={hasBottomNav}
|
|
502
712
|
>
|
|
503
713
|
{children}
|
|
504
714
|
</TopbarLayout>
|
|
505
715
|
)
|
|
716
|
+
break
|
|
506
717
|
|
|
507
718
|
case 'minimal':
|
|
508
|
-
|
|
719
|
+
layoutElement = (
|
|
509
720
|
<MinimalLayout logo={logo} user={user} headerEnd={topbarEnd}>
|
|
510
721
|
{children}
|
|
511
722
|
</MinimalLayout>
|
|
512
723
|
)
|
|
724
|
+
break
|
|
513
725
|
|
|
514
726
|
default: {
|
|
515
727
|
const _exhaustive: never = variant
|
|
516
728
|
return null
|
|
517
729
|
}
|
|
518
730
|
}
|
|
731
|
+
|
|
732
|
+
return (
|
|
733
|
+
<>
|
|
734
|
+
{layoutElement}
|
|
735
|
+
{floatingAvatar}
|
|
736
|
+
{bottomBar}
|
|
737
|
+
</>
|
|
738
|
+
)
|
|
519
739
|
}
|
|
@@ -139,7 +139,7 @@ function MobileTabs({ nav }: { nav: ModuleNavItem[] }) {
|
|
|
139
139
|
|
|
140
140
|
return (
|
|
141
141
|
<div className="md:hidden -mx-1 mb-4 no-print">
|
|
142
|
-
<div ref={scrollRef} className="flex gap-1 px-1 pb-2 overflow-x-auto scrollbar-
|
|
142
|
+
<div ref={scrollRef} className="flex gap-1 px-1 pb-2 overflow-x-auto scrollbar-none">
|
|
143
143
|
{nav.map((item) => {
|
|
144
144
|
const Icon = item.icon ? (ICON_MAP[item.icon] ?? null) : null
|
|
145
145
|
const hasChildren = item.children && item.children.length > 0
|
|
@@ -340,7 +340,7 @@ function ModuleHeader({ nav }: { nav: ModuleNavItem[] }) {
|
|
|
340
340
|
|
|
341
341
|
const content = (
|
|
342
342
|
<div className="flex w-full min-w-0 items-center gap-4">
|
|
343
|
-
<div className="flex min-w-0 items-center gap-0.5 overflow-x-auto scrollbar-
|
|
343
|
+
<div className="flex min-w-0 items-center gap-0.5 overflow-x-auto scrollbar-none">
|
|
344
344
|
{nav.map((item) => {
|
|
345
345
|
const Icon = item.icon ? (ICON_MAP[item.icon] ?? null) : null
|
|
346
346
|
const hasChildren = item.children && item.children.length > 0
|