@umituz/web-dashboard 3.2.2 → 4.0.0
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/README.md +202 -14
- package/package.json +21 -29
- package/src/domains/analytics/components/AnalyticsCard.tsx +23 -21
- package/src/domains/analytics/components/AnalyticsChart.tsx +345 -149
- package/src/domains/analytics/components/AnalyticsLayout.tsx +93 -112
- package/src/domains/analytics/components/MetricCard.tsx +42 -64
- package/src/domains/analytics/hooks/index.ts +7 -1
- package/src/domains/analytics/hooks/useAnalytics.ts +144 -139
- package/src/domains/analytics/index.ts +11 -0
- package/src/domains/analytics/services/AnalyticsEngineService.ts +103 -55
- package/src/domains/analytics/services/PerformanceService.ts +117 -69
- package/src/domains/analytics/types/analytics.ts +20 -6
- package/src/domains/analytics/types/index.ts +3 -0
- package/src/domains/analytics/utils/analytics.test.ts +183 -0
- package/src/domains/analytics/utils/analytics.ts +26 -9
- package/src/domains/analytics/utils/i18nKeys.ts +40 -0
- package/src/domains/analytics/utils/index.ts +2 -0
- package/src/domains/auth/components/AuthLayout.tsx +63 -54
- package/src/domains/auth/components/ForgotPasswordForm.tsx +96 -98
- package/src/domains/auth/components/LoginForm.tsx +167 -226
- package/src/domains/auth/components/RegisterForm.tsx +202 -272
- package/src/domains/auth/components/ResetPasswordForm.tsx +105 -151
- package/src/domains/auth/components/shared/AuthErrorBanner.tsx +35 -0
- package/src/domains/auth/components/shared/EmailInput.tsx +91 -0
- package/src/domains/auth/components/shared/PasswordInput.tsx +119 -0
- package/src/domains/auth/components/shared/PasswordStrengthIndicator.tsx +75 -0
- package/src/domains/auth/components/shared/SocialLoginButtons.tsx +58 -0
- package/src/domains/auth/components/shared/SocialProviderIcons.tsx +48 -0
- package/src/domains/auth/components/shared/index.ts +20 -0
- package/src/domains/auth/hooks/index.ts +1 -0
- package/src/domains/auth/hooks/useAuth.ts +151 -232
- package/src/domains/auth/index.ts +8 -4
- package/src/domains/auth/types/auth.ts +26 -5
- package/src/domains/auth/types/index.ts +1 -0
- package/src/domains/auth/utils/auth.test.ts +182 -0
- package/src/domains/auth/utils/auth.ts +71 -130
- package/src/domains/auth/utils/i18nKeys.ts +127 -0
- package/src/domains/auth/utils/index.ts +7 -1
- package/src/domains/auth/utils/secureToken.ts +33 -0
- package/src/domains/auth/utils/validationKeys.ts +23 -0
- package/src/domains/calendar/hooks/useCalendar.ts +145 -151
- package/src/domains/calendar/index.ts +0 -1
- package/src/domains/calendar/services/CalendarService.ts +122 -179
- package/src/domains/calendar/utils/contentItemMapper.test.ts +108 -0
- package/src/domains/calendar/utils/contentItemMapper.ts +99 -0
- package/src/domains/calendar/utils/index.ts +33 -30
- package/src/domains/layouts/components/BrandLogo.tsx +12 -9
- package/src/domains/layouts/components/DashboardHeader.tsx +78 -80
- package/src/domains/layouts/components/DashboardLayout.tsx +45 -21
- package/src/domains/layouts/components/DashboardSidebar.tsx +48 -51
- package/src/domains/layouts/types/layout.ts +7 -1
- package/src/domains/layouts/types/user.ts +3 -1
- package/src/domains/modules/build.test.tsx +97 -0
- package/src/domains/modules/build.tsx +71 -0
- package/src/domains/modules/index.ts +2 -0
- package/src/domains/modules/types.ts +29 -0
- package/src/domains/onboarding/components/AppFocusStep.tsx +99 -68
- package/src/domains/onboarding/components/OnboardingWizard.tsx +109 -183
- package/src/domains/onboarding/components/PlanStep.tsx +156 -123
- package/src/domains/onboarding/components/PlatformsStep.tsx +74 -54
- package/src/domains/onboarding/components/StepNavigation.tsx +108 -0
- package/src/domains/onboarding/components/StepProgress.tsx +62 -0
- package/src/domains/onboarding/components/UserTypeStep.tsx +59 -80
- package/src/domains/onboarding/components/index.ts +4 -0
- package/src/domains/onboarding/hooks/useOnboarding.ts +76 -66
- package/src/domains/onboarding/index.ts +1 -0
- package/src/domains/onboarding/types/onboarding.ts +57 -17
- package/src/domains/onboarding/utils/i18nKeys.ts +72 -0
- package/src/domains/onboarding/utils/index.ts +2 -0
- package/src/domains/permissions/PermissionsContext.tsx +103 -0
- package/src/domains/permissions/hasPermission.test.ts +50 -0
- package/src/domains/permissions/hasPermission.ts +38 -0
- package/src/domains/permissions/index.ts +19 -0
- package/src/domains/permissions/types.ts +35 -0
- package/src/domains/permissions/utils/i18nKeys.ts +11 -0
- package/src/domains/session/DemoLogin.tsx +50 -0
- package/src/domains/session/OrgSwitcher.tsx +110 -0
- package/src/domains/session/SessionContext.tsx +150 -0
- package/src/domains/session/inMemoryAuthAdapter.ts +53 -0
- package/src/domains/session/index.ts +18 -0
- package/src/domains/session/types.ts +47 -0
- package/src/domains/session/utils/i18nKeys.ts +12 -0
- package/src/domains/settings/components/AuditLogList.tsx +59 -0
- package/src/domains/settings/components/PermissionMatrixScreen.tsx +80 -0
- package/src/domains/settings/components/SettingsLayout.tsx +75 -60
- package/src/domains/settings/components/SettingsSection.tsx +35 -22
- package/src/domains/settings/components/UserManagementTable.tsx +87 -0
- package/src/domains/settings/components/index.ts +10 -0
- package/src/domains/settings/hooks/useSettings.ts +77 -52
- package/src/domains/settings/types/settings.ts +2 -0
- package/src/domains/settings/utils/i18nKeys.ts +18 -0
- package/src/domains/settings/utils/settings.ts +11 -4
- package/src/i18n/I18nContext.tsx +33 -0
- package/src/i18n/defaultT.test.ts +43 -0
- package/src/i18n/defaultT.ts +34 -0
- package/src/i18n/index.ts +7 -0
- package/src/i18n/types.ts +13 -0
- package/src/index.ts +1 -7
- package/src/primitives/Button.tsx +30 -0
- package/src/primitives/Checkbox.tsx +40 -0
- package/src/primitives/Input.tsx +19 -0
- package/src/primitives/Skeleton.tsx +26 -0
- package/src/primitives/cn.ts +15 -0
- package/src/primitives/index.ts +23 -0
- package/src/styles/dashboard.css +3045 -0
- package/src/styles/tokens.contract.test.ts +66 -0
- package/src/styles/tokens.css +124 -0
- package/src/domains/billing/components/BillingLayout.tsx +0 -63
- package/src/domains/billing/components/BillingPage.tsx +0 -49
- package/src/domains/billing/components/BillingPortal.tsx +0 -198
- package/src/domains/billing/components/InvoiceCard.tsx +0 -143
- package/src/domains/billing/components/PaymentMethodsList.tsx +0 -116
- package/src/domains/billing/components/PlanComparison.tsx +0 -199
- package/src/domains/billing/components/UsageCard.tsx +0 -103
- package/src/domains/billing/components/index.ts +0 -13
- package/src/domains/billing/constants/billing.ts +0 -69
- package/src/domains/billing/hooks/index.ts +0 -7
- package/src/domains/billing/hooks/useBilling.ts +0 -385
- package/src/domains/billing/index.ts +0 -73
- package/src/domains/billing/types/billing.ts +0 -342
- package/src/domains/billing/types/index.ts +0 -30
- package/src/domains/billing/utils/billing.ts +0 -360
- package/src/domains/billing/utils/index.ts +0 -31
- package/src/domains/billing/utils/polar.ts +0 -63
- package/src/domains/calendar/components/index.ts +0 -7
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { useState } from "react";
|
|
2
2
|
import { Link, useLocation } from "react-router-dom";
|
|
3
|
-
import {
|
|
3
|
+
import { useT } from "../../../i18n";
|
|
4
4
|
|
|
5
|
-
import { Button } from "
|
|
5
|
+
import { Button, cn } from "../../../primitives";
|
|
6
6
|
import { BrandLogo } from "./BrandLogo";
|
|
7
7
|
import { PenTool, Menu, ChevronLeft, ChevronDown, ChevronRight } from "lucide-react";
|
|
8
8
|
import type { DashboardSidebarProps } from "../types/layout";
|
|
@@ -19,6 +19,8 @@ interface DashboardSidebarPropsExtended extends DashboardSidebarProps {
|
|
|
19
19
|
brandTagline?: string;
|
|
20
20
|
/** Create post route */
|
|
21
21
|
createPostRoute?: string;
|
|
22
|
+
/** Whether to show the primary create button (default: true) */
|
|
23
|
+
showCreatePost?: boolean;
|
|
22
24
|
/** Auth user */
|
|
23
25
|
user?: DashboardUser;
|
|
24
26
|
}
|
|
@@ -28,6 +30,7 @@ interface DashboardSidebarPropsExtended extends DashboardSidebarProps {
|
|
|
28
30
|
*
|
|
29
31
|
* Displays collapsible sidebar with navigation menu items.
|
|
30
32
|
* Supports app-based filtering (mobile/web) and collapsible groups.
|
|
33
|
+
* Styling is entirely `udb-sidebar*` classes from the package stylesheet.
|
|
31
34
|
*
|
|
32
35
|
* @param props - Dashboard sidebar props
|
|
33
36
|
*/
|
|
@@ -38,10 +41,11 @@ export const DashboardSidebar = ({
|
|
|
38
41
|
brandName = "App",
|
|
39
42
|
brandTagline = "grow smarter",
|
|
40
43
|
createPostRoute = "/dashboard/create",
|
|
44
|
+
showCreatePost = true,
|
|
41
45
|
user,
|
|
42
46
|
}: DashboardSidebarPropsExtended) => {
|
|
43
47
|
const location = useLocation();
|
|
44
|
-
const
|
|
48
|
+
const t = useT();
|
|
45
49
|
const [collapsedGroups, setCollapsedGroups] = useState<Record<string, boolean>>({});
|
|
46
50
|
|
|
47
51
|
const toggleGroup = (title: string) => {
|
|
@@ -52,39 +56,37 @@ export const DashboardSidebar = ({
|
|
|
52
56
|
};
|
|
53
57
|
|
|
54
58
|
return (
|
|
55
|
-
<div className="
|
|
59
|
+
<div className={cn("udb-sidebar", collapsed && "udb-sidebar--collapsed")}>
|
|
56
60
|
{/* Brand Section */}
|
|
57
|
-
<div className="
|
|
61
|
+
<div className="udb-sidebar__brand">
|
|
58
62
|
<BrandLogo size={32} />
|
|
59
63
|
{!collapsed && (
|
|
60
|
-
<div className="
|
|
61
|
-
<span className="
|
|
62
|
-
<span className="
|
|
63
|
-
{brandTagline}
|
|
64
|
-
</span>
|
|
64
|
+
<div className="udb-sidebar__brand-text">
|
|
65
|
+
<span className="udb-sidebar__brand-name">{brandName}</span>
|
|
66
|
+
<span className="udb-sidebar__brand-tagline">{brandTagline}</span>
|
|
65
67
|
</div>
|
|
66
68
|
)}
|
|
67
69
|
</div>
|
|
68
70
|
|
|
69
71
|
{/* Create Button */}
|
|
70
|
-
|
|
71
|
-
<
|
|
72
|
-
<
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
</
|
|
82
|
-
</
|
|
83
|
-
|
|
72
|
+
{showCreatePost && createPostRoute && (
|
|
73
|
+
<div className="udb-sidebar__create">
|
|
74
|
+
<Link to={createPostRoute}>
|
|
75
|
+
<Button
|
|
76
|
+
variant="default"
|
|
77
|
+
className={cn("udb-sidebar__create-btn", collapsed && "udb-sidebar__create-btn--collapsed")}
|
|
78
|
+
title={collapsed ? t('sidebar.createPost') : undefined}
|
|
79
|
+
>
|
|
80
|
+
<PenTool className="udb-sidebar__create-icon" />
|
|
81
|
+
{!collapsed && <span className="udb-sidebar__create-label">{t('sidebar.createPost')}</span>}
|
|
82
|
+
</Button>
|
|
83
|
+
</Link>
|
|
84
|
+
</div>
|
|
85
|
+
)}
|
|
84
86
|
|
|
85
87
|
{/* Navigation */}
|
|
86
|
-
<nav className="
|
|
87
|
-
<div className="
|
|
88
|
+
<nav className="udb-sidebar__nav">
|
|
89
|
+
<div className="udb-sidebar__groups">
|
|
88
90
|
{sidebarGroups.map((group) => {
|
|
89
91
|
const filteredItems = filterSidebarItems(group.items, user);
|
|
90
92
|
|
|
@@ -93,22 +95,23 @@ export const DashboardSidebar = ({
|
|
|
93
95
|
const isGroupCollapsed = collapsedGroups[group.title];
|
|
94
96
|
|
|
95
97
|
return (
|
|
96
|
-
<div key={group.title} className="
|
|
98
|
+
<div key={group.title} className="udb-sidebar__group">
|
|
97
99
|
{!collapsed && (
|
|
98
100
|
<button
|
|
101
|
+
type="button"
|
|
99
102
|
onClick={() => toggleGroup(group.title)}
|
|
100
|
-
className="
|
|
103
|
+
className="udb-sidebar__group-toggle"
|
|
101
104
|
>
|
|
102
|
-
<span className="
|
|
103
|
-
{group.title
|
|
105
|
+
<span className="udb-sidebar__group-title">
|
|
106
|
+
{t(group.title, { brand: brandName })}
|
|
104
107
|
</span>
|
|
105
|
-
<
|
|
108
|
+
<span className="udb-sidebar__group-chevron">
|
|
106
109
|
{isGroupCollapsed ? (
|
|
107
|
-
<ChevronRight className="
|
|
110
|
+
<ChevronRight className="udb-sidebar__chevron-icon" />
|
|
108
111
|
) : (
|
|
109
|
-
<ChevronDown className="
|
|
112
|
+
<ChevronDown className="udb-sidebar__chevron-icon" />
|
|
110
113
|
)}
|
|
111
|
-
</
|
|
114
|
+
</span>
|
|
112
115
|
</button>
|
|
113
116
|
)}
|
|
114
117
|
|
|
@@ -118,14 +121,10 @@ export const DashboardSidebar = ({
|
|
|
118
121
|
<Link
|
|
119
122
|
key={item.path}
|
|
120
123
|
to={item.path}
|
|
121
|
-
className={
|
|
122
|
-
active
|
|
123
|
-
? "bg-sidebar-accent text-sidebar-accent-foreground shadow-sm"
|
|
124
|
-
: "text-sidebar-foreground/70 hover:bg-sidebar-accent/40 hover:text-sidebar-foreground"
|
|
125
|
-
} ${collapsed ? "justify-center" : ""}`}
|
|
124
|
+
className={cn("udb-sidebar__item", active && "udb-sidebar__item--active")}
|
|
126
125
|
title={collapsed ? t(item.label) : undefined}
|
|
127
126
|
>
|
|
128
|
-
<item.icon className=
|
|
127
|
+
<item.icon className="udb-sidebar__item-icon" />
|
|
129
128
|
{!collapsed && <span>{t(item.label)}</span>}
|
|
130
129
|
</Link>
|
|
131
130
|
);
|
|
@@ -137,17 +136,15 @@ export const DashboardSidebar = ({
|
|
|
137
136
|
</nav>
|
|
138
137
|
|
|
139
138
|
{/* Collapse Toggle */}
|
|
140
|
-
<div className="
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
<
|
|
148
|
-
|
|
149
|
-
</Button>
|
|
150
|
-
</div>
|
|
139
|
+
<div className="udb-sidebar__footer">
|
|
140
|
+
{!collapsed && (
|
|
141
|
+
<p className="udb-sidebar__footer-label">
|
|
142
|
+
{t('sidebar.system')}
|
|
143
|
+
</p>
|
|
144
|
+
)}
|
|
145
|
+
<Button variant="ghost" size="icon" onClick={() => setCollapsed(!collapsed)} className="udb-sidebar__footer-btn">
|
|
146
|
+
{collapsed ? <Menu className="udb-sidebar__footer-icon" /> : <ChevronLeft className="udb-sidebar__footer-icon" />}
|
|
147
|
+
</Button>
|
|
151
148
|
</div>
|
|
152
149
|
</div>
|
|
153
150
|
);
|
|
@@ -16,6 +16,10 @@ export interface DashboardHeaderProps {
|
|
|
16
16
|
setMobileOpen: (open: boolean) => void;
|
|
17
17
|
/** Current page title */
|
|
18
18
|
title: string;
|
|
19
|
+
/** Current resolved theme; defaults to "light" rendering when omitted */
|
|
20
|
+
theme?: "light" | "dark";
|
|
21
|
+
/** Theme toggle handler — the toggle button is hidden when omitted */
|
|
22
|
+
onToggleTheme?: () => void;
|
|
19
23
|
}
|
|
20
24
|
|
|
21
25
|
/**
|
|
@@ -34,8 +38,10 @@ export interface DashboardSidebarProps {
|
|
|
34
38
|
export interface DashboardLayoutConfig {
|
|
35
39
|
/** Sidebar groups */
|
|
36
40
|
sidebarGroups: import('./sidebar').SidebarGroup[];
|
|
37
|
-
/** Extra title mappings for routes */
|
|
41
|
+
/** Extra title mappings for routes — values may be i18n keys or literal strings */
|
|
38
42
|
extraTitleMap?: Record<string, string>;
|
|
43
|
+
/** Title used when no sidebar item or extra mapping matches the route (default: "Dashboard") */
|
|
44
|
+
defaultTitle?: string;
|
|
39
45
|
/** Brand name */
|
|
40
46
|
brandName?: string;
|
|
41
47
|
/** Brand tagline */
|
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
* Type definitions for user profile
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import type { ComponentType } from "react";
|
|
8
|
+
|
|
7
9
|
/**
|
|
8
10
|
* User profile info for header
|
|
9
11
|
*/
|
|
@@ -29,7 +31,7 @@ export interface UserNavMenuItem {
|
|
|
29
31
|
/** Display label */
|
|
30
32
|
label: string;
|
|
31
33
|
/** Icon component */
|
|
32
|
-
icon:
|
|
34
|
+
icon: ComponentType<{ className?: string }>;
|
|
33
35
|
/** Route path */
|
|
34
36
|
path: string;
|
|
35
37
|
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { Circle } from "lucide-react";
|
|
3
|
+
import { buildNav, buildRoutes } from "./build";
|
|
4
|
+
import type { DashboardModule } from "./types";
|
|
5
|
+
|
|
6
|
+
const icon = Circle as unknown as DashboardModule["icon"];
|
|
7
|
+
|
|
8
|
+
const MODULES: DashboardModule[] = [
|
|
9
|
+
{
|
|
10
|
+
path: "/dashboard",
|
|
11
|
+
labelKey: "nav.dashboard",
|
|
12
|
+
icon,
|
|
13
|
+
navGroup: "nav.overview",
|
|
14
|
+
order: 1,
|
|
15
|
+
permission: "dashboard.view",
|
|
16
|
+
element: <div>dashboard</div>,
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
path: "/sales",
|
|
20
|
+
labelKey: "nav.sales",
|
|
21
|
+
icon,
|
|
22
|
+
navGroup: "nav.commercial",
|
|
23
|
+
order: 2,
|
|
24
|
+
permission: "sales.view",
|
|
25
|
+
element: <div>sales</div>,
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
path: "/sales/:id",
|
|
29
|
+
labelKey: "nav.salesDetail",
|
|
30
|
+
icon,
|
|
31
|
+
navGroup: "nav.commercial",
|
|
32
|
+
inNav: false,
|
|
33
|
+
permission: "sales.view",
|
|
34
|
+
element: <div>sales detail</div>,
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
path: "/reports",
|
|
38
|
+
labelKey: "nav.reports",
|
|
39
|
+
icon,
|
|
40
|
+
navGroup: "nav.insight",
|
|
41
|
+
order: 1,
|
|
42
|
+
element: <div>reports</div>,
|
|
43
|
+
},
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
const allowAll = () => true;
|
|
47
|
+
const denySales = (permission: string) => permission !== "sales.view";
|
|
48
|
+
|
|
49
|
+
describe("buildRoutes", () => {
|
|
50
|
+
it("maps every module to a route config", () => {
|
|
51
|
+
const routes = buildRoutes(MODULES);
|
|
52
|
+
expect(routes.map((route) => route.path)).toEqual([
|
|
53
|
+
"/dashboard",
|
|
54
|
+
"/sales",
|
|
55
|
+
"/sales/:id",
|
|
56
|
+
"/reports",
|
|
57
|
+
]);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe("buildNav", () => {
|
|
62
|
+
it("groups by navGroup preserving first appearance and sorting by order", () => {
|
|
63
|
+
const nav = buildNav(MODULES, { can: allowAll });
|
|
64
|
+
expect(nav.map((group) => group.title)).toEqual([
|
|
65
|
+
"nav.overview",
|
|
66
|
+
"nav.commercial",
|
|
67
|
+
"nav.insight",
|
|
68
|
+
]);
|
|
69
|
+
expect(nav[0].items.map((item) => item.label)).toEqual(["nav.dashboard"]);
|
|
70
|
+
expect(nav[2].items.map((item) => item.label)).toEqual(["nav.reports"]);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("hides detail routes (inNav: false)", () => {
|
|
74
|
+
const nav = buildNav(MODULES, { can: allowAll });
|
|
75
|
+
const labels = nav.flatMap((group) => group.items.map((item) => item.path));
|
|
76
|
+
expect(labels).not.toContain("/sales/:id");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("filters modules the role cannot see", () => {
|
|
80
|
+
const nav = buildNav(MODULES, { can: denySales });
|
|
81
|
+
const paths = nav.flatMap((group) => group.items.map((item) => item.path));
|
|
82
|
+
expect(paths).toEqual(["/dashboard", "/reports"]);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("drops groups emptied by the permission filter", () => {
|
|
86
|
+
const nav = buildNav(MODULES, { can: denySales });
|
|
87
|
+
expect(nav.map((group) => group.title)).toEqual(["nav.overview", "nav.insight"]);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("keeps permissionless modules always visible", () => {
|
|
91
|
+
const denyAll = () => false;
|
|
92
|
+
const nav = buildNav(MODULES, { can: denyAll });
|
|
93
|
+
expect(nav.flatMap((group) => group.items.map((item) => item.path))).toEqual([
|
|
94
|
+
"/reports",
|
|
95
|
+
]);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* buildRoutes / buildNav — derive route configs and sidebar groups
|
|
3
|
+
* from a single DashboardModule list.
|
|
4
|
+
*
|
|
5
|
+
* - buildRoutes wraps every permissioned module's element in
|
|
6
|
+
* <RequirePermission>, so hosts only map the result to <Route>.
|
|
7
|
+
* - buildNav filters by the same permissions via `can`, hides
|
|
8
|
+
* inNav:false detail routes, groups by navGroup (first-appearance
|
|
9
|
+
* order) and sorts items by `order` (stable for ties).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { ReactNode } from "react";
|
|
13
|
+
import type { SidebarGroup } from "../layouts/types";
|
|
14
|
+
import { RequirePermission } from "../permissions";
|
|
15
|
+
import type { Permission } from "../permissions/types";
|
|
16
|
+
import type { DashboardModule } from "./types";
|
|
17
|
+
|
|
18
|
+
export interface RouteConfig {
|
|
19
|
+
path: string;
|
|
20
|
+
element: ReactNode;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const buildRoutes = (modules: readonly DashboardModule[]): RouteConfig[] =>
|
|
24
|
+
modules.map((module) => ({
|
|
25
|
+
path: module.path,
|
|
26
|
+
element: module.permission ? (
|
|
27
|
+
<RequirePermission permission={module.permission}>
|
|
28
|
+
{module.element}
|
|
29
|
+
</RequirePermission>
|
|
30
|
+
) : (
|
|
31
|
+
module.element
|
|
32
|
+
),
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
export interface BuildNavOptions {
|
|
36
|
+
/** Permission check, e.g. usePermissions().can. */
|
|
37
|
+
can: (permission: Permission) => boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const buildNav = (
|
|
41
|
+
modules: readonly DashboardModule[],
|
|
42
|
+
{ can }: BuildNavOptions,
|
|
43
|
+
): SidebarGroup[] => {
|
|
44
|
+
const visible = modules
|
|
45
|
+
.filter((module) => module.inNav !== false)
|
|
46
|
+
.filter((module) => !module.permission || can(module.permission));
|
|
47
|
+
|
|
48
|
+
// Groups keep their first-appearance order in the module list;
|
|
49
|
+
// `order` sorts items within their group.
|
|
50
|
+
const grouped = new Map<string, DashboardModule[]>();
|
|
51
|
+
for (const module of visible) {
|
|
52
|
+
const items = grouped.get(module.navGroup) ?? [];
|
|
53
|
+
items.push(module);
|
|
54
|
+
grouped.set(module.navGroup, items);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return [...grouped.entries()].map(([title, items]) => ({
|
|
58
|
+
title,
|
|
59
|
+
items: items
|
|
60
|
+
.slice()
|
|
61
|
+
.sort(
|
|
62
|
+
(a, b) =>
|
|
63
|
+
(a.order ?? Number.MAX_SAFE_INTEGER) - (b.order ?? Number.MAX_SAFE_INTEGER),
|
|
64
|
+
)
|
|
65
|
+
.map((module) => ({
|
|
66
|
+
label: module.labelKey,
|
|
67
|
+
icon: module.icon,
|
|
68
|
+
path: module.path,
|
|
69
|
+
})),
|
|
70
|
+
}));
|
|
71
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dashboard module model — the single source of truth a host app
|
|
3
|
+
* registers once per module; routes and sidebar nav are both derived
|
|
4
|
+
* from it (see build.ts). Generalizes the texora/visora pattern
|
|
5
|
+
* where MODULE_ROUTES and NAV_GROUPS were hand-kept in sync.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ReactNode } from "react";
|
|
9
|
+
import type { LucideIcon } from "lucide-react";
|
|
10
|
+
import type { Permission } from "../permissions/types";
|
|
11
|
+
|
|
12
|
+
export interface DashboardModule {
|
|
13
|
+
/** Route path (may contain params, e.g. "/sales/:id"). */
|
|
14
|
+
path: string;
|
|
15
|
+
/** i18n key (or plain label) for nav + page headers. */
|
|
16
|
+
labelKey: string;
|
|
17
|
+
/** lucide-react icon for the nav item. */
|
|
18
|
+
icon: LucideIcon;
|
|
19
|
+
/** Nav group title key — modules with the same key land in one group. */
|
|
20
|
+
navGroup: string;
|
|
21
|
+
/** Page content; permission-guarded automatically when `permission` is set. */
|
|
22
|
+
element: ReactNode;
|
|
23
|
+
/** Permission required to see/use this module (optional). */
|
|
24
|
+
permission?: Permission;
|
|
25
|
+
/** Sort weight inside its nav group (lower first, default last). */
|
|
26
|
+
order?: number;
|
|
27
|
+
/** Include in the nav (default true — set false for detail routes). */
|
|
28
|
+
inNav?: boolean;
|
|
29
|
+
}
|
|
@@ -1,109 +1,140 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
/**
|
|
2
|
+
* AppFocusStep
|
|
3
|
+
*
|
|
4
|
+
* Second step of onboarding: which platforms is the user building for.
|
|
5
|
+
* Icons are Lucide components (no emoji). State mapping is
|
|
6
|
+
* config-driven: each option declares which state field it toggles.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { Check, Smartphone, Monitor, Sparkles } from "lucide-react";
|
|
10
|
+
import { useT } from "../../../i18n";
|
|
11
|
+
import { cn } from "../../../primitives";
|
|
12
|
+
import type {
|
|
13
|
+
OnboardingState,
|
|
14
|
+
AppTypeOption,
|
|
15
|
+
} from "../types/onboarding";
|
|
16
|
+
import { ONBOARDING_KEYS } from "../utils/i18nKeys";
|
|
4
17
|
|
|
5
|
-
interface AppFocusStepProps {
|
|
6
|
-
/** Current onboarding state */
|
|
18
|
+
export interface AppFocusStepProps {
|
|
7
19
|
state: OnboardingState;
|
|
8
|
-
/** Update state function */
|
|
9
20
|
updateState: (updates: Partial<OnboardingState>) => void;
|
|
10
|
-
|
|
11
|
-
appTypes?: Array<{
|
|
12
|
-
id: string;
|
|
13
|
-
label: string;
|
|
14
|
-
description: string;
|
|
15
|
-
icon: string;
|
|
16
|
-
}>;
|
|
21
|
+
appTypes?: AppTypeOption[];
|
|
17
22
|
}
|
|
18
23
|
|
|
19
24
|
/**
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
25
|
+
* Each option toggles a `stateField` of OnboardingState. This keeps the
|
|
26
|
+
* wizard data-driven — adding a new platform doesn't require a code
|
|
27
|
+
* change in this file. Consumer-supplied options may omit `stateField`;
|
|
28
|
+
* it is then derived from the well-known ids below.
|
|
23
29
|
*/
|
|
30
|
+
type AppFocusStateField = NonNullable<AppTypeOption["stateField"]>;
|
|
31
|
+
|
|
32
|
+
const DEFAULT_STATE_FIELD_BY_ID: Record<string, AppFocusStateField> = {
|
|
33
|
+
mobile: "hasMobileApp",
|
|
34
|
+
web: "hasWebApp",
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const DEFAULT_OPTIONS: AppTypeOption[] = [
|
|
38
|
+
{
|
|
39
|
+
id: "mobile",
|
|
40
|
+
name: "Mobile App",
|
|
41
|
+
description: "iOS or Android application",
|
|
42
|
+
icon: Smartphone,
|
|
43
|
+
stateField: "hasMobileApp",
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
id: "web",
|
|
47
|
+
name: "Web App",
|
|
48
|
+
description: "Web application or website",
|
|
49
|
+
icon: Monitor,
|
|
50
|
+
stateField: "hasWebApp",
|
|
51
|
+
},
|
|
52
|
+
];
|
|
53
|
+
|
|
24
54
|
export const AppFocusStep = ({
|
|
25
55
|
state,
|
|
26
56
|
updateState,
|
|
27
57
|
appTypes,
|
|
28
58
|
}: AppFocusStepProps) => {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
59
|
+
const t = useT();
|
|
60
|
+
|
|
61
|
+
const resolveStateField = (option: AppTypeOption): AppFocusStateField | undefined =>
|
|
62
|
+
option.stateField ?? DEFAULT_STATE_FIELD_BY_ID[option.id];
|
|
63
|
+
|
|
64
|
+
const types = (appTypes ?? DEFAULT_OPTIONS).map((option) => ({
|
|
65
|
+
...option,
|
|
66
|
+
resolvedField: resolveStateField(option),
|
|
67
|
+
}));
|
|
68
|
+
|
|
69
|
+
const toggle = (option: AppTypeOption, field: AppFocusStateField) => {
|
|
70
|
+
const current = Boolean(state[field]);
|
|
71
|
+
updateState({ [field]: !current } as Partial<OnboardingState>);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const anySelected = types.some((type) =>
|
|
75
|
+
type.resolvedField ? Boolean(state[type.resolvedField]) : false,
|
|
76
|
+
);
|
|
46
77
|
|
|
47
78
|
return (
|
|
48
|
-
<div className="
|
|
49
|
-
<div className="
|
|
50
|
-
<h1 className="
|
|
51
|
-
|
|
79
|
+
<div className="udb-step">
|
|
80
|
+
<div className="udb-step__header">
|
|
81
|
+
<h1 className="udb-step__title">
|
|
82
|
+
{t(ONBOARDING_KEYS.appFocus.title)}
|
|
52
83
|
</h1>
|
|
53
|
-
<p className="
|
|
54
|
-
|
|
84
|
+
<p className="udb-step__description">
|
|
85
|
+
{t(ONBOARDING_KEYS.appFocus.description)}
|
|
55
86
|
</p>
|
|
56
87
|
</div>
|
|
57
88
|
|
|
58
|
-
<div className="
|
|
89
|
+
<div className="udb-step__options udb-step__options--list" role="group" aria-label={t(ONBOARDING_KEYS.appFocus.title)}>
|
|
59
90
|
{types.map((type) => {
|
|
60
|
-
const isSelected = type.
|
|
61
|
-
|
|
91
|
+
const isSelected = type.resolvedField
|
|
92
|
+
? Boolean(state[type.resolvedField])
|
|
93
|
+
: false;
|
|
94
|
+
const Icon = type.icon;
|
|
62
95
|
return (
|
|
63
96
|
<button
|
|
64
97
|
key={type.id}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
} else {
|
|
69
|
-
updateState({ hasWebApp: !state.hasWebApp });
|
|
70
|
-
}
|
|
71
|
-
}}
|
|
98
|
+
type="button"
|
|
99
|
+
aria-pressed={isSelected}
|
|
100
|
+
onClick={() => type.resolvedField && toggle(type, type.resolvedField)}
|
|
72
101
|
className={cn(
|
|
73
|
-
"
|
|
74
|
-
isSelected
|
|
102
|
+
"udb-choice",
|
|
103
|
+
isSelected && "udb-choice--selected",
|
|
75
104
|
)}
|
|
76
105
|
>
|
|
77
|
-
<
|
|
106
|
+
<span
|
|
78
107
|
className={cn(
|
|
79
|
-
"
|
|
80
|
-
isSelected && "
|
|
108
|
+
"udb-choice__icon-box udb-choice__icon-box--lg",
|
|
109
|
+
isSelected && "udb-choice__icon-box--on",
|
|
81
110
|
)}
|
|
82
111
|
>
|
|
83
|
-
|
|
84
|
-
</
|
|
112
|
+
<Icon className="udb-choice__icon udb-choice__icon--lg" aria-hidden="true" />
|
|
113
|
+
</span>
|
|
85
114
|
|
|
86
|
-
<
|
|
87
|
-
<
|
|
88
|
-
<
|
|
89
|
-
</
|
|
115
|
+
<span className="udb-choice__body">
|
|
116
|
+
<span className="udb-choice__label udb-choice__label--lg">{type.name}</span>
|
|
117
|
+
<span className="udb-choice__description">{type.description}</span>
|
|
118
|
+
</span>
|
|
90
119
|
|
|
91
|
-
<
|
|
120
|
+
<span
|
|
92
121
|
className={cn(
|
|
93
|
-
"
|
|
94
|
-
isSelected
|
|
122
|
+
"udb-choice__check",
|
|
123
|
+
isSelected && "udb-choice__check--on",
|
|
95
124
|
)}
|
|
125
|
+
aria-hidden="true"
|
|
96
126
|
>
|
|
97
|
-
{isSelected && <Check className="
|
|
98
|
-
</
|
|
127
|
+
{isSelected && <Check className="udb-choice__check-icon" />}
|
|
128
|
+
</span>
|
|
99
129
|
</button>
|
|
100
130
|
);
|
|
101
131
|
})}
|
|
102
132
|
</div>
|
|
103
133
|
|
|
104
|
-
{
|
|
105
|
-
<p className="
|
|
106
|
-
|
|
134
|
+
{anySelected && (
|
|
135
|
+
<p className="udb-step__hint" role="status" aria-live="polite">
|
|
136
|
+
<Sparkles className="udb-step__hint-icon" aria-hidden="true" />
|
|
137
|
+
{t(ONBOARDING_KEYS.appFocus.success)}
|
|
107
138
|
</p>
|
|
108
139
|
)}
|
|
109
140
|
</div>
|