@umituz/web-dashboard 3.2.4 → 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.
Files changed (125) hide show
  1. package/README.md +202 -14
  2. package/package.json +21 -29
  3. package/src/domains/analytics/components/AnalyticsCard.tsx +23 -21
  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 +42 -64
  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 +87 -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/settings.ts +11 -4
  93. package/src/i18n/I18nContext.tsx +33 -0
  94. package/src/i18n/defaultT.test.ts +43 -0
  95. package/src/i18n/defaultT.ts +34 -0
  96. package/src/i18n/index.ts +7 -0
  97. package/src/i18n/types.ts +13 -0
  98. package/src/index.ts +1 -7
  99. package/src/primitives/Button.tsx +30 -0
  100. package/src/primitives/Checkbox.tsx +40 -0
  101. package/src/primitives/Input.tsx +19 -0
  102. package/src/primitives/Skeleton.tsx +26 -0
  103. package/src/primitives/cn.ts +15 -0
  104. package/src/primitives/index.ts +23 -0
  105. package/src/styles/dashboard.css +3045 -0
  106. package/src/styles/tokens.contract.test.ts +66 -0
  107. package/src/styles/tokens.css +124 -0
  108. package/src/domains/billing/components/BillingLayout.tsx +0 -63
  109. package/src/domains/billing/components/BillingPage.tsx +0 -49
  110. package/src/domains/billing/components/BillingPortal.tsx +0 -198
  111. package/src/domains/billing/components/InvoiceCard.tsx +0 -143
  112. package/src/domains/billing/components/PaymentMethodsList.tsx +0 -116
  113. package/src/domains/billing/components/PlanComparison.tsx +0 -199
  114. package/src/domains/billing/components/UsageCard.tsx +0 -103
  115. package/src/domains/billing/components/index.ts +0 -13
  116. package/src/domains/billing/constants/billing.ts +0 -69
  117. package/src/domains/billing/hooks/index.ts +0 -7
  118. package/src/domains/billing/hooks/useBilling.ts +0 -385
  119. package/src/domains/billing/index.ts +0 -73
  120. package/src/domains/billing/types/billing.ts +0 -342
  121. package/src/domains/billing/types/index.ts +0 -30
  122. package/src/domains/billing/utils/billing.ts +0 -360
  123. package/src/domains/billing/utils/index.ts +0 -31
  124. package/src/domains/billing/utils/polar.ts +0 -63
  125. package/src/domains/calendar/components/index.ts +0 -7
@@ -1,182 +1,378 @@
1
1
  /**
2
- * Analytics Chart Component
2
+ * AnalyticsChart
3
3
  *
4
- * Configurable chart component using Recharts
4
+ * Dependency-free SVG chart renderer (line / area / bar / pie / donut).
5
+ * Replaced the recharts wrapper in v4 — same ChartConfig contract,
6
+ * colors from `--u-*`-friendly CSS color strings, geometry centered
7
+ * here (no magic numbers at call sites). Tooltips are native SVG
8
+ * <title> elements; unsupported types render the empty state.
5
9
  */
6
10
 
7
11
  import { useMemo } from "react";
8
- import {
9
- LineChart,
10
- BarChart,
11
- AreaChart,
12
- PieChart,
13
- ResponsiveContainer,
14
- XAxis,
15
- YAxis,
16
- CartesianGrid,
17
- Tooltip,
18
- Legend,
19
- Line,
20
- Bar,
21
- Area,
22
- Pie,
23
- Cell,
24
- } from "recharts";
25
- import { cn } from "@umituz/web-design-system/utils";
26
- import type { ChartConfig } from "../types/analytics";
12
+ import { cn } from "../../../primitives";
13
+ import { useT } from "../../../i18n";
14
+ import type { ChartConfig, ChartData, TimeSeriesData } from "../types/analytics";
27
15
  import { generateChartColors } from "../utils/analytics";
16
+ import { ANALYTICS_KEYS } from "../utils/i18nKeys";
17
+
18
+ const VIEWBOX_W = 600;
19
+ const DEFAULT_CHART_HEIGHT_PX = 300;
20
+ const DEFAULT_SERIES_COUNT = 5;
21
+ const PAD = { top: 10, right: 12, bottom: 24, left: 44 } as const;
22
+ const Y_TICKS = 4;
23
+ const X_LABEL_SAMPLE = 8;
24
+ const LINE_STROKE_WIDTH = 2;
25
+ const DOT_RADIUS = 3;
26
+ const AREA_FILL_OPACITY = 0.25;
27
+
28
+ interface ChartRow {
29
+ label: string;
30
+ values: number[];
31
+ }
32
+
33
+ interface PieSlice {
34
+ label: string;
35
+ value: number;
36
+ fraction: number;
37
+ }
38
+
39
+ const compactFmt = new Intl.NumberFormat(undefined, {
40
+ notation: "compact",
41
+ maximumFractionDigits: 1,
42
+ });
43
+
44
+ const toNumber = (value: unknown): number => {
45
+ const num = Number(value);
46
+ return Number.isFinite(num) ? num : 0;
47
+ };
48
+
49
+ /** Normalize config.data into labeled rows of series values. */
50
+ const toRows = (
51
+ data: TimeSeriesData[] | ChartData[],
52
+ keys: string[],
53
+ xAxisKey?: string,
54
+ ): ChartRow[] =>
55
+ data.map((point) => ({
56
+ label: String(point[xAxisKey ?? "date"] ?? point.label ?? ""),
57
+ values: keys.map((key) => toNumber(point[key])),
58
+ }));
59
+
60
+ const rowsDomain = (rows: ChartRow[]): { min: number; max: number } => {
61
+ const all = rows.flatMap((row) => row.values);
62
+ const dataMin = all.length > 0 ? Math.min(...all) : 0;
63
+ const dataMax = all.length > 0 ? Math.max(...all) : 1;
64
+ const min = Math.min(0, dataMin);
65
+ const max = dataMax === min ? min + 1 : dataMax;
66
+ return { min, max };
67
+ };
68
+
69
+ /** Keep at most `X_LABEL_SAMPLE` x labels evenly spaced. */
70
+ const shouldShowLabel = (index: number, count: number): boolean => {
71
+ if (count <= X_LABEL_SAMPLE) return true;
72
+ const step = Math.ceil((count - 1) / (X_LABEL_SAMPLE - 1));
73
+ return index % step === 0 || index === count - 1;
74
+ };
75
+
76
+ const polarPoint = (cx: number, cy: number, r: number, angle: number): [number, number] => [
77
+ cx + r * Math.cos(angle),
78
+ cy + r * Math.sin(angle),
79
+ ];
28
80
 
29
81
  interface AnalyticsChartProps {
30
- /** Chart configuration */
31
82
  config: ChartConfig;
32
- /** Custom class name */
33
83
  className?: string;
34
- /** Custom height */
35
84
  height?: number | string;
36
85
  }
37
86
 
38
87
  export const AnalyticsChart = ({ config, className, height }: AnalyticsChartProps) => {
88
+ const t = useT();
89
+
90
+ const chartHeight = height ?? config.height ?? DEFAULT_CHART_HEIGHT_PX;
91
+ const viewBoxH = typeof chartHeight === "number" ? chartHeight : DEFAULT_CHART_HEIGHT_PX;
92
+
93
+ const keys = useMemo(
94
+ () => config.yAxisKeys ?? ["value"],
95
+ [config.yAxisKeys],
96
+ );
97
+
39
98
  const colors = useMemo(
40
- () => config.colors || generateChartColors(config.yAxisKeys?.length || 5),
41
- [config.colors, config.yAxisKeys]
99
+ () => config.colors ?? generateChartColors(keys.length || DEFAULT_SERIES_COUNT),
100
+ [config.colors, keys.length],
42
101
  );
43
102
 
44
- const renderChart = () => {
45
- const commonProps = {
46
- data: config.data,
47
- margin: { top: 10, right: 10, left: 10, bottom: 10 },
48
- };
103
+ const rows = useMemo(
104
+ () => toRows(config.data as TimeSeriesData[] | ChartData[], keys, config.xAxisKey),
105
+ [config.data, keys, config.xAxisKey],
106
+ );
49
107
 
50
- switch (config.type) {
51
- case "line":
52
- return (
53
- <LineChart {...commonProps}>
54
- {config.showGrid && <CartesianGrid strokeDasharray="3 3" className="stroke-border/50" />}
55
- <XAxis
56
- dataKey={config.xAxisKey}
57
- className="text-xs text-muted-foreground"
58
- axisLine={false}
59
- tickLine={false}
60
- />
61
- <YAxis
62
- className="text-xs text-muted-foreground"
63
- axisLine={false}
64
- tickLine={false}
65
- />
66
- {config.showTooltip && <Tooltip />}
67
- {config.showLegend && <Legend />}
68
- {config.yAxisKeys?.map((key, index) => (
69
- <Line
70
- key={key}
71
- type="monotone"
72
- dataKey={key}
73
- stroke={colors[index]}
74
- strokeWidth={2}
75
- dot={{ r: 4 }}
76
- />
77
- ))}
78
- </LineChart>
79
- );
108
+ const slices = useMemo<PieSlice[]>(() => {
109
+ const entries = config.data as ChartData[];
110
+ const total = entries.reduce((sum, entry) => sum + toNumber(entry.value), 0);
111
+ return entries.map((entry) => ({
112
+ label: String(entry.label ?? ""),
113
+ value: toNumber(entry.value),
114
+ fraction: total > 0 ? toNumber(entry.value) / total : 0,
115
+ }));
116
+ }, [config.data]);
80
117
 
81
- case "bar":
82
- return (
83
- <BarChart {...commonProps}>
84
- {config.showGrid && <CartesianGrid strokeDasharray="3 3" className="stroke-border/50" />}
85
- <XAxis
86
- dataKey={config.xAxisKey}
87
- className="text-xs text-muted-foreground"
88
- axisLine={false}
89
- tickLine={false}
90
- />
91
- <YAxis
92
- className="text-xs text-muted-foreground"
93
- axisLine={false}
94
- tickLine={false}
95
- />
96
- {config.showTooltip && <Tooltip />}
97
- {config.showLegend && <Legend />}
98
- {config.yAxisKeys?.map((key, index) => (
99
- <Bar key={key} dataKey={key} fill={colors[index]} radius={[4, 4, 0, 0]} />
100
- ))}
101
- </BarChart>
102
- );
118
+ const legendItems = useMemo(() => {
119
+ if (config.type === "pie" || config.type === "donut") {
120
+ return slices.map((slice, index) => ({
121
+ label: `${slice.label} (${Math.round(slice.fraction * 100)}%)`,
122
+ color: colors[index % colors.length],
123
+ }));
124
+ }
125
+ return keys.map((key, index) => ({ label: key, color: colors[index % colors.length] }));
126
+ }, [config.type, keys, slices, colors]);
103
127
 
104
- case "area":
105
- return (
106
- <AreaChart {...commonProps}>
107
- {config.showGrid && <CartesianGrid strokeDasharray="3 3" className="stroke-border/50" />}
108
- <XAxis
109
- dataKey={config.xAxisKey}
110
- className="text-xs text-muted-foreground"
111
- axisLine={false}
112
- tickLine={false}
113
- />
114
- <YAxis
115
- className="text-xs text-muted-foreground"
116
- axisLine={false}
117
- tickLine={false}
118
- />
119
- {config.showTooltip && <Tooltip />}
120
- {config.showLegend && <Legend />}
121
- {config.yAxisKeys?.map((key, index) => (
122
- <Area
123
- key={key}
124
- type="monotone"
125
- dataKey={key}
126
- stroke={colors[index]}
127
- fill={colors[index]}
128
- fillOpacity={0.3}
129
- />
130
- ))}
131
- </AreaChart>
132
- );
128
+ const renderUnsupported = () => (
129
+ <div className="udb-chart__empty" role="alert">
130
+ {t(ANALYTICS_KEYS.chart.noData)}: {config.type}
131
+ </div>
132
+ );
133
133
 
134
- case "pie":
135
- case "donut":
136
- return (
137
- <PieChart {...commonProps}>
138
- <Pie
139
- data={config.data}
140
- cx="50%"
141
- cy="50%"
142
- labelLine={false}
143
- label={({ name, percent }) => `${name}: ${(percent * 100).toFixed(0)}%`}
144
- outerRadius={80}
145
- innerRadius={config.type === "donut" ? 40 : 0}
146
- dataKey="value"
134
+ const renderCartesian = () => {
135
+ if (rows.length === 0) return renderUnsupported();
136
+
137
+ const { min, max } = rowsDomain(rows);
138
+ const plotW = VIEWBOX_W - PAD.left - PAD.right;
139
+ const plotH = viewBoxH - PAD.top - PAD.bottom;
140
+ const xAt = (index: number) =>
141
+ PAD.left + (rows.length === 1 ? plotW / 2 : (index / (rows.length - 1)) * plotW);
142
+ const yAt = (value: number) =>
143
+ PAD.top + plotH - ((value - min) / (max - min)) * plotH;
144
+
145
+ const seriesPath = (values: number[]) =>
146
+ values
147
+ .map((value, index) => `${index === 0 ? "M" : "L"}${xAt(index).toFixed(1)},${yAt(value).toFixed(1)}`)
148
+ .join(" ");
149
+
150
+ const areaPath = (values: number[]) =>
151
+ values.length === 0
152
+ ? ""
153
+ : `${seriesPath(values)} L${xAt(values.length - 1).toFixed(1)},${yAt(min).toFixed(1)} L${xAt(0).toFixed(1)},${yAt(min).toFixed(1)} Z`;
154
+
155
+ return (
156
+ <svg
157
+ className="udb-chart__svg"
158
+ viewBox={`0 0 ${VIEWBOX_W} ${viewBoxH}`}
159
+ width="100%"
160
+ height="100%"
161
+ role="img"
162
+ >
163
+ {config.showGrid !== false &&
164
+ Array.from({ length: Y_TICKS + 1 }, (_, tick) => {
165
+ const value = min + ((max - min) / Y_TICKS) * tick;
166
+ const y = yAt(value);
167
+ return (
168
+ <g key={tick}>
169
+ <line
170
+ className="udb-chart__grid"
171
+ x1={PAD.left}
172
+ x2={VIEWBOX_W - PAD.right}
173
+ y1={y}
174
+ y2={y}
175
+ />
176
+ <text className="udb-chart__axis" x={PAD.left - 6} y={y + 4} textAnchor="end">
177
+ {compactFmt.format(value)}
178
+ </text>
179
+ </g>
180
+ );
181
+ })}
182
+
183
+ {rows.map((row, index) =>
184
+ shouldShowLabel(index, rows.length) ? (
185
+ <text
186
+ key={`x-${row.label}-${index}`}
187
+ className="udb-chart__axis"
188
+ x={xAt(index)}
189
+ y={viewBoxH - 6}
190
+ textAnchor="middle"
147
191
  >
148
- {config.data.map((entry: any, index: number) => (
149
- <Cell key={`cell-${index}`} fill={colors[index % colors.length]} />
150
- ))}
151
- </Pie>
152
- {config.showTooltip && <Tooltip />}
153
- {config.showLegend && <Legend />}
154
- </PieChart>
155
- );
192
+ {row.label}
193
+ </text>
194
+ ) : null,
195
+ )}
156
196
 
157
- default:
158
- return (
159
- <div className="flex items-center justify-center h-full text-muted-foreground">
160
- Unsupported chart type: {config.type}
161
- </div>
162
- );
197
+ {(config.type === "line" || config.type === "area") &&
198
+ keys.map((key, seriesIndex) => {
199
+ const values = rows.map((row) => row.values[seriesIndex]);
200
+ const color = colors[seriesIndex % colors.length];
201
+ return (
202
+ <g key={key}>
203
+ {config.type === "area" && (
204
+ <path d={areaPath(values)} fill={color} fillOpacity={AREA_FILL_OPACITY} />
205
+ )}
206
+ <path
207
+ d={seriesPath(values)}
208
+ fill="none"
209
+ stroke={color}
210
+ strokeWidth={LINE_STROKE_WIDTH}
211
+ strokeLinecap="round"
212
+ strokeLinejoin="round"
213
+ />
214
+ {config.showTooltip !== false &&
215
+ values.map((value, index) => (
216
+ <circle
217
+ key={`${key}-${index}`}
218
+ cx={xAt(index)}
219
+ cy={yAt(value)}
220
+ r={DOT_RADIUS}
221
+ fill={color}
222
+ >
223
+ <title>{`${rows[index].label} — ${key}: ${value}`}</title>
224
+ </circle>
225
+ ))}
226
+ </g>
227
+ );
228
+ })}
229
+
230
+ {config.type === "bar" &&
231
+ rows.map((row, rowIndex) => {
232
+ const band = plotW / rows.length;
233
+ const barW = (band * 0.6) / keys.length;
234
+ const zero = yAt(Math.max(min, 0));
235
+ return (
236
+ <g key={`${row.label}-${rowIndex}`}>
237
+ {keys.map((key, seriesIndex) => {
238
+ const value = row.values[seriesIndex];
239
+ const x = PAD.left + rowIndex * band + band * 0.2 + seriesIndex * barW;
240
+ const y = yAt(Math.max(value, 0));
241
+ const height = Math.max(Math.abs(yAt(value) - zero), 1);
242
+ const color = colors[seriesIndex % colors.length];
243
+ return (
244
+ <rect
245
+ key={key}
246
+ x={x}
247
+ y={Math.min(y, zero)}
248
+ width={barW - 1}
249
+ height={height}
250
+ rx={2}
251
+ fill={color}
252
+ >
253
+ <title>{`${row.label} — ${key}: ${value}`}</title>
254
+ </rect>
255
+ );
256
+ })}
257
+ </g>
258
+ );
259
+ })}
260
+ </svg>
261
+ );
262
+ };
263
+
264
+ const renderPie = (donut: boolean) => {
265
+ if (slices.length === 0) return renderUnsupported();
266
+
267
+ const cx = VIEWBOX_W / 2;
268
+ const cy = viewBoxH / 2;
269
+ const outerR = Math.min(viewBoxH / 2 - PAD.top, 120);
270
+
271
+ if (donut) {
272
+ const ringR = outerR * 0.7;
273
+ const strokeWidth = outerR - ringR;
274
+ const circumference = 2 * Math.PI * ringR;
275
+ let offset = 0;
276
+ return (
277
+ <svg
278
+ className="udb-chart__svg"
279
+ viewBox={`0 0 ${VIEWBOX_W} ${viewBoxH}`}
280
+ width="100%"
281
+ height="100%"
282
+ role="img"
283
+ >
284
+ <g transform={`rotate(-90 ${cx} ${cy})`}>
285
+ {slices.map((slice, index) => {
286
+ const dash = slice.fraction * circumference;
287
+ const element = (
288
+ <circle
289
+ key={`${slice.label}-${index}`}
290
+ cx={cx}
291
+ cy={cy}
292
+ r={ringR}
293
+ fill="none"
294
+ stroke={colors[index % colors.length]}
295
+ strokeWidth={strokeWidth}
296
+ strokeDasharray={`${dash} ${circumference - dash}`}
297
+ strokeDashoffset={-offset}
298
+ >
299
+ <title>{`${slice.label}: ${Math.round(slice.fraction * 100)}%`}</title>
300
+ </circle>
301
+ );
302
+ offset += dash;
303
+ return element;
304
+ })}
305
+ </g>
306
+ </svg>
307
+ );
163
308
  }
309
+
310
+ let angle = -Math.PI / 2;
311
+ return (
312
+ <svg
313
+ className="udb-chart__svg"
314
+ viewBox={`0 0 ${VIEWBOX_W} ${viewBoxH}`}
315
+ width="100%"
316
+ height="100%"
317
+ role="img"
318
+ >
319
+ {slices.map((slice, index) => {
320
+ const sweep = slice.fraction * 2 * Math.PI;
321
+ const [x0, y0] = polarPoint(cx, cy, outerR, angle);
322
+ const [x1, y1] = polarPoint(cx, cy, outerR, angle + sweep);
323
+ const largeArc = sweep > Math.PI ? 1 : 0;
324
+ const path =
325
+ slice.fraction >= 1
326
+ ? `M${cx - outerR},${cy} A${outerR},${outerR} 0 1 0 ${cx + outerR},${cy} A${outerR},${outerR} 0 1 0 ${cx - outerR},${cy} Z`
327
+ : `M${cx},${cy} L${x0.toFixed(1)},${y0.toFixed(1)} A${outerR},${outerR} 0 ${largeArc} 1 ${x1.toFixed(1)},${y1.toFixed(1)} Z`;
328
+ angle += sweep;
329
+ return (
330
+ <path key={`${slice.label}-${index}`} d={path} fill={colors[index % colors.length]}>
331
+ <title>{`${slice.label}: ${Math.round(slice.fraction * 100)}%`}</title>
332
+ </path>
333
+ );
334
+ })}
335
+ </svg>
336
+ );
164
337
  };
165
338
 
166
- const chartHeight = height || config.height || 300;
339
+ const renderChart = () => {
340
+ switch (config.type) {
341
+ case "line":
342
+ case "area":
343
+ case "bar":
344
+ return renderCartesian();
345
+ case "pie":
346
+ return renderPie(false);
347
+ case "donut":
348
+ return renderPie(true);
349
+ default:
350
+ return renderUnsupported();
351
+ }
352
+ };
167
353
 
168
354
  return (
169
355
  <div
170
- className={cn(
171
- "w-full",
172
- config.aspectRatio || "aspect-video",
173
- className
174
- )}
175
- style={{ height: typeof chartHeight === "number" ? `${chartHeight}px` : chartHeight }}
356
+ className={cn("udb-chart", config.aspectRatio, className)}
357
+ style={{
358
+ height: typeof chartHeight === "number" ? `${chartHeight}px` : chartHeight,
359
+ }}
176
360
  >
177
- <ResponsiveContainer width="100%" height="100%">
178
- {renderChart()}
179
- </ResponsiveContainer>
361
+ {renderChart()}
362
+ {config.showLegend && legendItems.length > 0 && (
363
+ <div className="udb-chart__legend">
364
+ {legendItems.map((item) => (
365
+ <span key={item.label} className="udb-chart__chip">
366
+ <span
367
+ className="udb-chart__chip-dot"
368
+ style={{ background: item.color }}
369
+ aria-hidden="true"
370
+ />
371
+ {item.label}
372
+ </span>
373
+ ))}
374
+ </div>
375
+ )}
180
376
  </div>
181
377
  );
182
378
  };