@umituz/web-dashboard 3.2.4 → 4.1.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.
Files changed (126) hide show
  1. package/README.md +228 -14
  2. package/package.json +23 -30
  3. package/src/domains/analytics/components/AnalyticsCard.tsx +43 -42
  4. package/src/domains/analytics/components/AnalyticsChart.tsx +345 -149
  5. package/src/domains/analytics/components/AnalyticsLayout.tsx +93 -112
  6. package/src/domains/analytics/components/MetricCard.tsx +29 -87
  7. package/src/domains/analytics/hooks/index.ts +7 -1
  8. package/src/domains/analytics/hooks/useAnalytics.ts +144 -139
  9. package/src/domains/analytics/index.ts +11 -0
  10. package/src/domains/analytics/services/AnalyticsEngineService.ts +103 -55
  11. package/src/domains/analytics/services/PerformanceService.ts +117 -69
  12. package/src/domains/analytics/types/analytics.ts +20 -6
  13. package/src/domains/analytics/types/index.ts +3 -0
  14. package/src/domains/analytics/utils/analytics.test.ts +183 -0
  15. package/src/domains/analytics/utils/analytics.ts +26 -9
  16. package/src/domains/analytics/utils/i18nKeys.ts +40 -0
  17. package/src/domains/analytics/utils/index.ts +2 -0
  18. package/src/domains/auth/components/AuthLayout.tsx +63 -54
  19. package/src/domains/auth/components/ForgotPasswordForm.tsx +96 -98
  20. package/src/domains/auth/components/LoginForm.tsx +167 -226
  21. package/src/domains/auth/components/RegisterForm.tsx +202 -272
  22. package/src/domains/auth/components/ResetPasswordForm.tsx +105 -151
  23. package/src/domains/auth/components/shared/AuthErrorBanner.tsx +35 -0
  24. package/src/domains/auth/components/shared/EmailInput.tsx +91 -0
  25. package/src/domains/auth/components/shared/PasswordInput.tsx +119 -0
  26. package/src/domains/auth/components/shared/PasswordStrengthIndicator.tsx +75 -0
  27. package/src/domains/auth/components/shared/SocialLoginButtons.tsx +58 -0
  28. package/src/domains/auth/components/shared/SocialProviderIcons.tsx +48 -0
  29. package/src/domains/auth/components/shared/index.ts +20 -0
  30. package/src/domains/auth/hooks/index.ts +1 -0
  31. package/src/domains/auth/hooks/useAuth.ts +151 -232
  32. package/src/domains/auth/index.ts +8 -4
  33. package/src/domains/auth/types/auth.ts +26 -5
  34. package/src/domains/auth/types/index.ts +1 -0
  35. package/src/domains/auth/utils/auth.test.ts +182 -0
  36. package/src/domains/auth/utils/auth.ts +71 -130
  37. package/src/domains/auth/utils/i18nKeys.ts +127 -0
  38. package/src/domains/auth/utils/index.ts +7 -1
  39. package/src/domains/auth/utils/secureToken.ts +33 -0
  40. package/src/domains/auth/utils/validationKeys.ts +23 -0
  41. package/src/domains/calendar/hooks/useCalendar.ts +145 -151
  42. package/src/domains/calendar/index.ts +0 -1
  43. package/src/domains/calendar/services/CalendarService.ts +122 -179
  44. package/src/domains/calendar/utils/contentItemMapper.test.ts +108 -0
  45. package/src/domains/calendar/utils/contentItemMapper.ts +99 -0
  46. package/src/domains/calendar/utils/index.ts +33 -30
  47. package/src/domains/layouts/components/BrandLogo.tsx +12 -9
  48. package/src/domains/layouts/components/DashboardHeader.tsx +78 -80
  49. package/src/domains/layouts/components/DashboardLayout.tsx +45 -21
  50. package/src/domains/layouts/components/DashboardSidebar.tsx +48 -51
  51. package/src/domains/layouts/types/layout.ts +7 -1
  52. package/src/domains/layouts/types/user.ts +3 -1
  53. package/src/domains/modules/build.test.tsx +97 -0
  54. package/src/domains/modules/build.tsx +71 -0
  55. package/src/domains/modules/index.ts +2 -0
  56. package/src/domains/modules/types.ts +29 -0
  57. package/src/domains/onboarding/components/AppFocusStep.tsx +99 -68
  58. package/src/domains/onboarding/components/OnboardingWizard.tsx +109 -183
  59. package/src/domains/onboarding/components/PlanStep.tsx +156 -123
  60. package/src/domains/onboarding/components/PlatformsStep.tsx +74 -54
  61. package/src/domains/onboarding/components/StepNavigation.tsx +108 -0
  62. package/src/domains/onboarding/components/StepProgress.tsx +62 -0
  63. package/src/domains/onboarding/components/UserTypeStep.tsx +59 -80
  64. package/src/domains/onboarding/components/index.ts +4 -0
  65. package/src/domains/onboarding/hooks/useOnboarding.ts +76 -66
  66. package/src/domains/onboarding/index.ts +1 -0
  67. package/src/domains/onboarding/types/onboarding.ts +57 -17
  68. package/src/domains/onboarding/utils/i18nKeys.ts +72 -0
  69. package/src/domains/onboarding/utils/index.ts +2 -0
  70. package/src/domains/permissions/PermissionsContext.tsx +103 -0
  71. package/src/domains/permissions/hasPermission.test.ts +50 -0
  72. package/src/domains/permissions/hasPermission.ts +38 -0
  73. package/src/domains/permissions/index.ts +19 -0
  74. package/src/domains/permissions/types.ts +35 -0
  75. package/src/domains/permissions/utils/i18nKeys.ts +11 -0
  76. package/src/domains/session/DemoLogin.tsx +50 -0
  77. package/src/domains/session/OrgSwitcher.tsx +110 -0
  78. package/src/domains/session/SessionContext.tsx +150 -0
  79. package/src/domains/session/inMemoryAuthAdapter.ts +53 -0
  80. package/src/domains/session/index.ts +18 -0
  81. package/src/domains/session/types.ts +47 -0
  82. package/src/domains/session/utils/i18nKeys.ts +12 -0
  83. package/src/domains/settings/components/AuditLogList.tsx +59 -0
  84. package/src/domains/settings/components/PermissionMatrixScreen.tsx +80 -0
  85. package/src/domains/settings/components/SettingsLayout.tsx +75 -60
  86. package/src/domains/settings/components/SettingsSection.tsx +35 -22
  87. package/src/domains/settings/components/UserManagementTable.tsx +69 -0
  88. package/src/domains/settings/components/index.ts +10 -0
  89. package/src/domains/settings/hooks/useSettings.ts +77 -52
  90. package/src/domains/settings/types/settings.ts +2 -0
  91. package/src/domains/settings/utils/i18nKeys.ts +18 -0
  92. package/src/domains/settings/utils/index.ts +1 -0
  93. package/src/domains/settings/utils/settings.ts +11 -4
  94. package/src/i18n/I18nContext.tsx +33 -0
  95. package/src/i18n/defaultT.test.ts +43 -0
  96. package/src/i18n/defaultT.ts +34 -0
  97. package/src/i18n/index.ts +7 -0
  98. package/src/i18n/types.ts +13 -0
  99. package/src/index.ts +1 -7
  100. package/src/primitives/Button.tsx +24 -0
  101. package/src/primitives/Checkbox.tsx +17 -0
  102. package/src/primitives/Input.tsx +23 -0
  103. package/src/primitives/Skeleton.tsx +6 -0
  104. package/src/primitives/cn.ts +10 -0
  105. package/src/primitives/index.ts +23 -0
  106. package/src/styles/dashboard.css +2731 -0
  107. package/src/styles/tokens.contract.test.ts +91 -0
  108. package/src/styles/tokens.css +116 -0
  109. package/src/domains/billing/components/BillingLayout.tsx +0 -63
  110. package/src/domains/billing/components/BillingPage.tsx +0 -49
  111. package/src/domains/billing/components/BillingPortal.tsx +0 -198
  112. package/src/domains/billing/components/InvoiceCard.tsx +0 -143
  113. package/src/domains/billing/components/PaymentMethodsList.tsx +0 -116
  114. package/src/domains/billing/components/PlanComparison.tsx +0 -199
  115. package/src/domains/billing/components/UsageCard.tsx +0 -103
  116. package/src/domains/billing/components/index.ts +0 -13
  117. package/src/domains/billing/constants/billing.ts +0 -69
  118. package/src/domains/billing/hooks/index.ts +0 -7
  119. package/src/domains/billing/hooks/useBilling.ts +0 -385
  120. package/src/domains/billing/index.ts +0 -73
  121. package/src/domains/billing/types/billing.ts +0 -342
  122. package/src/domains/billing/types/index.ts +0 -30
  123. package/src/domains/billing/utils/billing.ts +0 -360
  124. package/src/domains/billing/utils/index.ts +0 -31
  125. package/src/domains/billing/utils/polar.ts +0 -63
  126. 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 { useTranslation } from "react-i18next";
3
+ import { useT } from "../../../i18n";
4
4
 
5
- import { Button } from "@umituz/web-design-system/atoms";
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 { t } = useTranslation();
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="flex h-full flex-col">
59
+ <div className={cn("udb-sidebar", collapsed && "udb-sidebar--collapsed")}>
56
60
  {/* Brand Section */}
57
- <div className="flex h-16 items-center gap-3 border-b border-sidebar-border px-4 transition-all duration-300">
61
+ <div className="udb-sidebar__brand">
58
62
  <BrandLogo size={32} />
59
63
  {!collapsed && (
60
- <div className="flex flex-col -gap-1">
61
- <span className="text-2xl font-black text-sidebar-foreground tracking-tighter leading-none">{brandName}</span>
62
- <span className="text-[11px] font-bold text-primary/70 lowercase tracking-tight mt-2 ml-1 select-none underline decoration-primary/40 underline-offset-[6px] decoration-2">
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
- <div className="px-3 py-4 border-b border-sidebar-border/50">
71
- <Link to={createPostRoute}>
72
- <Button
73
- variant="default"
74
- className={`w-full gap-3 shadow-glow transition-all active:scale-95 group overflow-hidden rounded-xl ${
75
- collapsed ? "px-0 justify-center h-10 w-10 mx-auto" : "justify-start px-4 h-11"
76
- }`}
77
- title={collapsed ? t('sidebar.createPost') : undefined}
78
- >
79
- <PenTool className={`shrink-0 transition-transform duration-300 ${collapsed ? "h-5 w-5" : "h-4 w-4 group-hover:scale-110"}`} />
80
- {!collapsed && <span className="font-bold tracking-tight">{t('sidebar.createPost')}</span>}
81
- </Button>
82
- </Link>
83
- </div>
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="flex-1 overflow-y-auto px-2 py-3 scrollbar-hide">
87
- <div className="space-y-6">
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="space-y-1">
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="w-full flex items-center justify-between px-3 py-2 mb-1 rounded-lg hover:bg-sidebar-accent/30 transition-all duration-200 group/header"
103
+ className="udb-sidebar__group-toggle"
101
104
  >
102
- <span className="text-[10px] font-bold uppercase tracking-widest text-sidebar-foreground/40 group-hover/header:text-sidebar-foreground/70 transition-colors">
103
- {group.title === "sidebar.ai" ? `${brandName} AI` : t(group.title)}
105
+ <span className="udb-sidebar__group-title">
106
+ {t(group.title, { brand: brandName })}
104
107
  </span>
105
- <div className="flex-shrink-0">
108
+ <span className="udb-sidebar__group-chevron">
106
109
  {isGroupCollapsed ? (
107
- <ChevronRight className="h-3.5 w-3.5 text-sidebar-foreground/30 transition-transform duration-200 group-hover/header:text-sidebar-foreground/50" />
110
+ <ChevronRight className="udb-sidebar__chevron-icon" />
108
111
  ) : (
109
- <ChevronDown className="h-3.5 w-3.5 text-sidebar-foreground/30 transition-transform duration-200 group-hover/header:text-sidebar-foreground/50" />
112
+ <ChevronDown className="udb-sidebar__chevron-icon" />
110
113
  )}
111
- </div>
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={`flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-all duration-200 ${
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={`h-4 w-4 shrink-0 transition-transform duration-200 ${active && "scale-110"}`} />
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="border-t border-sidebar-border p-3">
141
- <div className={`flex items-center ${collapsed ? "justify-center" : "justify-between"}`}>
142
- {!collapsed && (
143
- <p className="text-[10px] uppercase tracking-wider text-sidebar-foreground/40 font-bold px-2">
144
- {t('sidebar.system')}
145
- </p>
146
- )}
147
- <Button variant="ghost" size="icon" onClick={() => setCollapsed(!collapsed)} className="text-sidebar-foreground/70">
148
- {collapsed ? <Menu className="h-4 w-4" /> : <ChevronLeft className="h-4 w-4" />}
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: React.ComponentType<{ className?: string }>;
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,2 @@
1
+ export { buildRoutes, buildNav, type RouteConfig, type BuildNavOptions } from "./build";
2
+ export { type DashboardModule } from "./types";
@@ -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
- import { Check } from "lucide-react";
2
- import { cn } from "@umituz/web-design-system/utils";
3
- import type { OnboardingState } from "../types/onboarding";
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
- /** Custom app type options */
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
- * App Focus Selection Step
21
- *
22
- * Second step - select which app types to focus on (mobile/web)
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
- // Default app types
30
- const defaultAppTypes = [
31
- {
32
- id: "mobile",
33
- label: "Mobile App",
34
- description: "iOS or Android application",
35
- icon: "📱",
36
- },
37
- {
38
- id: "web",
39
- label: "Web App",
40
- description: "Web application or website",
41
- icon: "💻",
42
- },
43
- ];
44
-
45
- const types = appTypes || defaultAppTypes;
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="w-full max-w-xl">
49
- <div className="text-center mb-10">
50
- <h1 className="text-3xl md:text-4xl font-extrabold text-foreground mb-3">
51
- What type of app are you building?
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="text-muted-foreground">
54
- Select all that apply - you can change this later
84
+ <p className="udb-step__description">
85
+ {t(ONBOARDING_KEYS.appFocus.description)}
55
86
  </p>
56
87
  </div>
57
88
 
58
- <div className="grid grid-cols-1 gap-4">
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.id === "mobile" ? state.hasMobileApp : state.hasWebApp;
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
- onClick={() => {
66
- if (type.id === "mobile") {
67
- updateState({ hasMobileApp: !state.hasMobileApp });
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
- "w-full flex items-center gap-4 p-6 rounded-2xl border bg-background text-left transition-all",
74
- isSelected ? "border-primary ring-2 ring-primary/20 bg-primary/5" : "border-border hover:border-primary/40"
102
+ "udb-choice",
103
+ isSelected && "udb-choice--selected",
75
104
  )}
76
105
  >
77
- <div
106
+ <span
78
107
  className={cn(
79
- "w-12 h-12 rounded-2xl bg-muted flex items-center justify-center text-2xl shrink-0 transition-transform",
80
- isSelected && "scale-110"
108
+ "udb-choice__icon-box udb-choice__icon-box--lg",
109
+ isSelected && "udb-choice__icon-box--on",
81
110
  )}
82
111
  >
83
- {type.icon}
84
- </div>
112
+ <Icon className="udb-choice__icon udb-choice__icon--lg" aria-hidden="true" />
113
+ </span>
85
114
 
86
- <div className="flex-1">
87
- <p className="font-bold text-foreground text-lg">{type.label}</p>
88
- <p className="text-sm text-muted-foreground">{type.description}</p>
89
- </div>
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
- <div
120
+ <span
92
121
  className={cn(
93
- "w-6 h-6 rounded-full border-2 flex items-center justify-center shrink-0 transition-colors",
94
- isSelected ? "border-primary bg-primary text-white" : "border-muted-foreground/30"
122
+ "udb-choice__check",
123
+ isSelected && "udb-choice__check--on",
95
124
  )}
125
+ aria-hidden="true"
96
126
  >
97
- {isSelected && <Check className="h-4 w-4" />}
98
- </div>
127
+ {isSelected && <Check className="udb-choice__check-icon" />}
128
+ </span>
99
129
  </button>
100
130
  );
101
131
  })}
102
132
  </div>
103
133
 
104
- {(state.hasMobileApp || state.hasWebApp) && (
105
- <p className="mt-8 text-center text-sm text-muted-foreground animate-in fade-in slide-in-from-bottom-2 duration-500">
106
- ✨ Great choice! We'll tailor your experience accordingly.
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>