@micha.bigler/ui-core-micha 2.13.0 → 2.15.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/WORK_ORDERS.md +4 -2
- package/dist/components/charts/BarChart.js +32 -0
- package/dist/components/charts/ChartFrame.js +50 -0
- package/dist/components/charts/LineChart.js +32 -0
- package/dist/components/charts/exportChart.js +67 -0
- package/dist/components/charts/formatters.js +23 -0
- package/dist/components/charts/palette.js +21 -0
- package/dist/i18n/chartsTranslations.js +38 -0
- package/dist/i18n/notificationsTranslations.js +2 -0
- package/dist/index.js +12 -4
- package/dist/notifications/NotificationsProvider.js +13 -0
- package/dist/notifications/PopupSurface.js +84 -0
- package/dist/onboarding/OnboardingWizard.js +3 -12
- package/dist/onboarding/WizardDialogShell.js +29 -0
- package/package.json +3 -1
- package/src/components/charts/BarChart.jsx +44 -0
- package/src/components/charts/ChartFrame.jsx +112 -0
- package/src/components/charts/LineChart.jsx +44 -0
- package/src/components/charts/exportChart.js +71 -0
- package/src/components/charts/formatters.js +39 -0
- package/src/components/charts/palette.js +24 -0
- package/src/i18n/chartsTranslations.ts +38 -0
- package/src/i18n/notificationsTranslations.ts +2 -0
- package/src/index.js +19 -4
- package/src/notifications/NotificationsProvider.jsx +21 -0
- package/src/notifications/PopupSurface.jsx +103 -0
- package/src/onboarding/OnboardingWizard.jsx +9 -23
- package/src/onboarding/WizardDialogShell.jsx +51 -0
- package/tests/BarChart.test.jsx +55 -0
- package/tests/ChartFrame.test.jsx +122 -0
- package/tests/LineChart.test.jsx +33 -0
- package/tests/NotificationsProvider.test.jsx +38 -0
- package/tests/OnboardingWizard.test.jsx +143 -0
- package/tests/PopupSurface.test.jsx +142 -0
- package/tests/chartsPalette.test.js +39 -0
- package/tests/notificationsExports.test.js +5 -0
- package/work-orders/CHART-1.md +285 -0
- package/work-orders/NOTIF-12.md +75 -23
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import React, { useId, useRef, useState } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
Alert,
|
|
4
|
+
Box,
|
|
5
|
+
Button,
|
|
6
|
+
CircularProgress,
|
|
7
|
+
Paper,
|
|
8
|
+
Stack,
|
|
9
|
+
Typography,
|
|
10
|
+
} from '@mui/material';
|
|
11
|
+
import { useTranslation } from 'react-i18next';
|
|
12
|
+
import { exportChartPng, exportChartSvg } from './exportChart';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Chart-type-agnostic chart scaffolding. Children may be an X-Charts primitive or a bespoke SVG.
|
|
16
|
+
*/
|
|
17
|
+
export function ChartFrame({
|
|
18
|
+
title,
|
|
19
|
+
subtitle,
|
|
20
|
+
toolbar,
|
|
21
|
+
loading = false,
|
|
22
|
+
error = false,
|
|
23
|
+
isEmpty = false,
|
|
24
|
+
emptyMessage,
|
|
25
|
+
minHeight,
|
|
26
|
+
aspect,
|
|
27
|
+
exportOptions = false,
|
|
28
|
+
onExportSvg,
|
|
29
|
+
onExportPng,
|
|
30
|
+
ariaLabel,
|
|
31
|
+
children,
|
|
32
|
+
variant = 'outlined',
|
|
33
|
+
sx,
|
|
34
|
+
}) {
|
|
35
|
+
const { t } = useTranslation();
|
|
36
|
+
const titleId = useId();
|
|
37
|
+
const chartRef = useRef(null);
|
|
38
|
+
const [exportError, setExportError] = useState(false);
|
|
39
|
+
const options = exportOptions === true ? {} : exportOptions || {};
|
|
40
|
+
const exportsEnabled = Boolean(exportOptions);
|
|
41
|
+
const hasCustomAriaLabel = Boolean(ariaLabel);
|
|
42
|
+
const chartLabel = ariaLabel || title;
|
|
43
|
+
|
|
44
|
+
const runExport = async (type) => {
|
|
45
|
+
setExportError(false);
|
|
46
|
+
try {
|
|
47
|
+
const filename = options.filename || 'chart';
|
|
48
|
+
const result = type === 'svg'
|
|
49
|
+
? exportChartSvg(chartRef.current, `${filename}.svg`)
|
|
50
|
+
: await exportChartPng(chartRef.current, `${filename}.png`);
|
|
51
|
+
const callback = type === 'svg' ? onExportSvg : onExportPng;
|
|
52
|
+
callback?.(result);
|
|
53
|
+
} catch (_error) {
|
|
54
|
+
setExportError(true);
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
let content = children;
|
|
59
|
+
if (loading) {
|
|
60
|
+
content = <CircularProgress aria-label={t('ChartFrame.LOADING')} />;
|
|
61
|
+
} else if (error) {
|
|
62
|
+
content = <Alert severity="error">{typeof error === 'string' ? error : t('ChartFrame.ERROR_DEFAULT')}</Alert>;
|
|
63
|
+
} else if (isEmpty) {
|
|
64
|
+
content = <Alert severity="info">{emptyMessage || t('ChartFrame.EMPTY_DEFAULT')}</Alert>;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<Paper variant={variant} sx={{ p: 2, ...sx }}>
|
|
69
|
+
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 2, mb: 2 }}>
|
|
70
|
+
<Box>
|
|
71
|
+
<Typography id={titleId} variant="h6">{title}</Typography>
|
|
72
|
+
{subtitle && <Typography variant="body2" color="text.secondary">{subtitle}</Typography>}
|
|
73
|
+
</Box>
|
|
74
|
+
{toolbar && <Box>{toolbar}</Box>}
|
|
75
|
+
</Box>
|
|
76
|
+
|
|
77
|
+
<Box
|
|
78
|
+
ref={chartRef}
|
|
79
|
+
role="img"
|
|
80
|
+
aria-label={chartLabel}
|
|
81
|
+
aria-labelledby={hasCustomAriaLabel ? undefined : titleId}
|
|
82
|
+
sx={{
|
|
83
|
+
width: '100%',
|
|
84
|
+
minHeight,
|
|
85
|
+
aspectRatio: aspect,
|
|
86
|
+
display: 'flex',
|
|
87
|
+
alignItems: 'center',
|
|
88
|
+
justifyContent: 'center',
|
|
89
|
+
}}
|
|
90
|
+
>
|
|
91
|
+
{content}
|
|
92
|
+
</Box>
|
|
93
|
+
|
|
94
|
+
{exportError && <Alert severity="error" sx={{ mt: 2 }}>{t('ChartFrame.EXPORT_ERROR')}</Alert>}
|
|
95
|
+
|
|
96
|
+
{exportsEnabled && (
|
|
97
|
+
<Stack direction="row" spacing={1} justifyContent="flex-end" sx={{ mt: 2 }}>
|
|
98
|
+
{options.svg !== false && (
|
|
99
|
+
<Button size="small" onClick={() => runExport('svg')}>
|
|
100
|
+
{t('ChartFrame.EXPORT_SVG_LABEL')}
|
|
101
|
+
</Button>
|
|
102
|
+
)}
|
|
103
|
+
{options.png !== false && (
|
|
104
|
+
<Button size="small" onClick={() => runExport('png')}>
|
|
105
|
+
{t('ChartFrame.EXPORT_PNG_LABEL')}
|
|
106
|
+
</Button>
|
|
107
|
+
)}
|
|
108
|
+
</Stack>
|
|
109
|
+
)}
|
|
110
|
+
</Paper>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Box } from '@mui/material';
|
|
3
|
+
import { LineChart as MuiLineChart } from '@mui/x-charts/LineChart';
|
|
4
|
+
import { useNeutralChartPalette } from './palette';
|
|
5
|
+
|
|
6
|
+
function labelledAxis(axes, label, defaults) {
|
|
7
|
+
const values = axes?.length ? axes : [defaults];
|
|
8
|
+
return values.map((axis) => ({ ...axis, label: axis.label || label }));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Responsive MUI X-Charts line preset. xAxisLabel and yAxisLabel (including units) are required.
|
|
13
|
+
* It supplies labelled axes, tooltips, and an automatic multi-series legend.
|
|
14
|
+
*/
|
|
15
|
+
export function LineChart({
|
|
16
|
+
series = [],
|
|
17
|
+
xAxisLabel,
|
|
18
|
+
yAxisLabel,
|
|
19
|
+
xAxis,
|
|
20
|
+
yAxis,
|
|
21
|
+
palette,
|
|
22
|
+
minHeight,
|
|
23
|
+
aspect,
|
|
24
|
+
...chartProps
|
|
25
|
+
}) {
|
|
26
|
+
const neutralPalette = useNeutralChartPalette();
|
|
27
|
+
if (!xAxisLabel || !yAxisLabel) {
|
|
28
|
+
throw new Error('LineChart requires xAxisLabel and yAxisLabel.');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<Box data-testid="line-chart-container" sx={{ width: '100%', minHeight, aspectRatio: aspect }}>
|
|
33
|
+
<MuiLineChart
|
|
34
|
+
{...chartProps}
|
|
35
|
+
series={series}
|
|
36
|
+
xAxis={labelledAxis(xAxis, xAxisLabel, { scaleType: 'point', data: [] })}
|
|
37
|
+
yAxis={labelledAxis(yAxis, yAxisLabel, { scaleType: 'linear' })}
|
|
38
|
+
colors={palette || neutralPalette.categorical}
|
|
39
|
+
hideLegend={series.length <= 1}
|
|
40
|
+
slotProps={{ tooltip: { trigger: 'axis' } }}
|
|
41
|
+
/>
|
|
42
|
+
</Box>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
function findSvg(chartContainer) {
|
|
2
|
+
const svg = chartContainer?.querySelector('svg');
|
|
3
|
+
if (!svg) throw new Error('Chart container does not contain an SVG element.');
|
|
4
|
+
return svg;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function svgBlob(chartContainer) {
|
|
8
|
+
const svg = findSvg(chartContainer).cloneNode(true);
|
|
9
|
+
svg.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
|
|
10
|
+
return new Blob([new XMLSerializer().serializeToString(svg)], { type: 'image/svg+xml;charset=utf-8' });
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function download(blob, filename) {
|
|
14
|
+
const url = URL.createObjectURL(blob);
|
|
15
|
+
const anchor = document.createElement('a');
|
|
16
|
+
anchor.href = url;
|
|
17
|
+
anchor.download = filename;
|
|
18
|
+
document.body.appendChild(anchor);
|
|
19
|
+
anchor.click();
|
|
20
|
+
anchor.remove();
|
|
21
|
+
URL.revokeObjectURL(url);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Serializes the first SVG rendered inside a chart container and downloads it. */
|
|
25
|
+
export function exportChartSvg(chartContainer, filename = 'chart.svg') {
|
|
26
|
+
const blob = svgBlob(chartContainer);
|
|
27
|
+
download(blob, filename);
|
|
28
|
+
return blob;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Rasterizes the first SVG rendered inside a chart container at two-times scale and downloads it. */
|
|
32
|
+
export function exportChartPng(chartContainer, filename = 'chart.png') {
|
|
33
|
+
const source = svgBlob(chartContainer);
|
|
34
|
+
|
|
35
|
+
return new Promise((resolve, reject) => {
|
|
36
|
+
const url = URL.createObjectURL(source);
|
|
37
|
+
const image = new Image();
|
|
38
|
+
|
|
39
|
+
image.onload = () => {
|
|
40
|
+
try {
|
|
41
|
+
const svg = findSvg(chartContainer);
|
|
42
|
+
const width = Number(svg.getAttribute('width')) || svg.clientWidth || 1;
|
|
43
|
+
const height = Number(svg.getAttribute('height')) || svg.clientHeight || 1;
|
|
44
|
+
const canvas = document.createElement('canvas');
|
|
45
|
+
canvas.width = width * 2;
|
|
46
|
+
canvas.height = height * 2;
|
|
47
|
+
const context = canvas.getContext('2d');
|
|
48
|
+
context.fillStyle = 'white';
|
|
49
|
+
context.fillRect(0, 0, canvas.width, canvas.height);
|
|
50
|
+
context.drawImage(image, 0, 0, canvas.width, canvas.height);
|
|
51
|
+
canvas.toBlob((blob) => {
|
|
52
|
+
URL.revokeObjectURL(url);
|
|
53
|
+
if (!blob) {
|
|
54
|
+
reject(new Error('Unable to create PNG export.'));
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
download(blob, filename);
|
|
58
|
+
resolve(blob);
|
|
59
|
+
}, 'image/png');
|
|
60
|
+
} catch (error) {
|
|
61
|
+
URL.revokeObjectURL(url);
|
|
62
|
+
reject(error);
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
image.onerror = () => {
|
|
66
|
+
URL.revokeObjectURL(url);
|
|
67
|
+
reject(new Error('Unable to load chart SVG for PNG export.'));
|
|
68
|
+
};
|
|
69
|
+
image.src = url;
|
|
70
|
+
});
|
|
71
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { useTranslation } from 'react-i18next';
|
|
2
|
+
|
|
3
|
+
export function formatPercentage(value, locale, options = {}) {
|
|
4
|
+
return new Intl.NumberFormat(locale, {
|
|
5
|
+
style: 'percent',
|
|
6
|
+
maximumFractionDigits: 1,
|
|
7
|
+
...options,
|
|
8
|
+
}).format(value);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function formatCompact(value, locale, options = {}) {
|
|
12
|
+
return new Intl.NumberFormat(locale, {
|
|
13
|
+
notation: 'compact',
|
|
14
|
+
maximumFractionDigits: 1,
|
|
15
|
+
...options,
|
|
16
|
+
}).format(value);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function formatRatio(value, locale, options = {}) {
|
|
20
|
+
return new Intl.NumberFormat(locale, {
|
|
21
|
+
maximumFractionDigits: 2,
|
|
22
|
+
...options,
|
|
23
|
+
}).format(value);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Creates locale-aware formatter functions for use in chart axes and tooltips. */
|
|
27
|
+
export function createChartFormatters(locale) {
|
|
28
|
+
return {
|
|
29
|
+
percentage: (value, options) => formatPercentage(value, locale, options),
|
|
30
|
+
compact: (value, options) => formatCompact(value, locale, options),
|
|
31
|
+
ratio: (value, options) => formatRatio(value, locale, options),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Resolves formatter locale from the active react-i18next instance. */
|
|
36
|
+
export function useChartFormatters() {
|
|
37
|
+
const { i18n } = useTranslation();
|
|
38
|
+
return createChartFormatters(i18n.language);
|
|
39
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { useTheme } from '@mui/material/styles';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Returns neutral chart colours derived only from the active MUI theme.
|
|
5
|
+
* Consumers may replace either array with their own domain palette.
|
|
6
|
+
*/
|
|
7
|
+
export function getNeutralChartPalette(theme) {
|
|
8
|
+
const palette = theme.palette;
|
|
9
|
+
const isDark = palette.mode === 'dark';
|
|
10
|
+
|
|
11
|
+
return {
|
|
12
|
+
categorical: isDark
|
|
13
|
+
? [palette.primary.light, palette.secondary.light, palette.info.light, palette.success.light, palette.warning.light]
|
|
14
|
+
: [palette.primary.main, palette.secondary.main, palette.info.main, palette.success.main, palette.warning.main],
|
|
15
|
+
sequential: isDark
|
|
16
|
+
? [palette.action.disabled, palette.text.secondary, palette.primary.light, palette.primary.main, palette.primary.dark]
|
|
17
|
+
: [palette.action.disabled, palette.divider, palette.text.secondary, palette.primary.light, palette.primary.main],
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Resolves the kit's neutral palette from the nearest MUI ThemeProvider. */
|
|
22
|
+
export function useNeutralChartPalette() {
|
|
23
|
+
return getNeutralChartPalette(useTheme());
|
|
24
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export const chartsTranslations = {
|
|
2
|
+
'ChartFrame.LOADING': {
|
|
3
|
+
de: 'Diagramm wird geladen.',
|
|
4
|
+
fr: 'Chargement du graphique.',
|
|
5
|
+
en: 'Loading chart.',
|
|
6
|
+
sw: 'Inapakia chati.',
|
|
7
|
+
},
|
|
8
|
+
'ChartFrame.EMPTY_DEFAULT': {
|
|
9
|
+
de: 'Keine Daten verfügbar.',
|
|
10
|
+
fr: 'Aucune donnée disponible.',
|
|
11
|
+
en: 'No data available.',
|
|
12
|
+
sw: 'Hakuna data inayopatikana.',
|
|
13
|
+
},
|
|
14
|
+
'ChartFrame.ERROR_DEFAULT': {
|
|
15
|
+
de: 'Das Diagramm konnte nicht geladen werden.',
|
|
16
|
+
fr: 'Impossible de charger le graphique.',
|
|
17
|
+
en: 'The chart could not be loaded.',
|
|
18
|
+
sw: 'Chati haikuweza kupakiwa.',
|
|
19
|
+
},
|
|
20
|
+
'ChartFrame.EXPORT_SVG_LABEL': {
|
|
21
|
+
de: 'SVG exportieren',
|
|
22
|
+
fr: 'Exporter en SVG',
|
|
23
|
+
en: 'Export SVG',
|
|
24
|
+
sw: 'Hamisha SVG',
|
|
25
|
+
},
|
|
26
|
+
'ChartFrame.EXPORT_PNG_LABEL': {
|
|
27
|
+
de: 'PNG exportieren',
|
|
28
|
+
fr: 'Exporter en PNG',
|
|
29
|
+
en: 'Export PNG',
|
|
30
|
+
sw: 'Hamisha PNG',
|
|
31
|
+
},
|
|
32
|
+
'ChartFrame.EXPORT_ERROR': {
|
|
33
|
+
de: 'Das Diagramm konnte nicht exportiert werden.',
|
|
34
|
+
fr: 'Impossible d’exporter le graphique.',
|
|
35
|
+
en: 'The chart could not be exported.',
|
|
36
|
+
sw: 'Chati haikuweza kuhamishwa.',
|
|
37
|
+
},
|
|
38
|
+
};
|
|
@@ -17,4 +17,6 @@ export const notificationsTranslations = {
|
|
|
17
17
|
'NotificationSettings.PUSH_CONFLICT': { de: 'Dieses Gerät ist bereits mit einem anderen Konto für Push-Benachrichtigungen verbunden.', fr: 'Cet appareil est déjà associé à un autre compte pour les notifications push.', en: 'This device is already associated with another account for push notifications.', sw: 'Kifaa hiki tayari kimeunganishwa na akaunti nyingine kwa arifa za push.' },
|
|
18
18
|
'NotificationSettings.PUSH_NOT_SUPPORTED': { de: 'Push-Benachrichtigungen werden von diesem Browser nicht unterstützt.', fr: 'Les notifications push ne sont pas prises en charge par ce navigateur.', en: 'Push notifications are not supported by this browser.', sw: 'Arifa za push hazitumiki na kivinjari hiki.' },
|
|
19
19
|
'NotificationSettings.IOS_HINT': { de: 'Füge diese App zuerst zum Home-Bildschirm hinzu, um Push-Benachrichtigungen zu aktivieren.', fr: 'Ajoutez d’abord cette application à l’écran d’accueil pour activer les notifications push.', en: 'Add this app to your home screen first to enable push notifications.', sw: 'Ongeza programu hii kwenye skrini yako ya mwanzo kwanza ili kuwasha arifa za push.' },
|
|
20
|
+
'PopupSurface.CLOSE': { de: 'Schliessen', fr: 'Fermer', en: 'Close', sw: 'Funga' },
|
|
21
|
+
'PopupSurface.TITLE': { de: 'Benachrichtigung', fr: 'Notification', en: 'Notification', sw: 'Arifa' },
|
|
20
22
|
};
|
package/src/index.js
CHANGED
|
@@ -39,18 +39,32 @@ export { AuthFactorRequirementCard } from './components/AuthFactorRequirementCar
|
|
|
39
39
|
export { AccessCodeSingleUseToggle } from './components/AccessCodeSingleUseToggle';
|
|
40
40
|
export { QrSignupManager } from './components/QrSignupManager';
|
|
41
41
|
|
|
42
|
-
// --- 6.
|
|
42
|
+
// --- 6. Charts ---
|
|
43
|
+
export { ChartFrame } from './components/charts/ChartFrame';
|
|
44
|
+
export { BarChart } from './components/charts/BarChart';
|
|
45
|
+
export { LineChart } from './components/charts/LineChart';
|
|
46
|
+
export { getNeutralChartPalette, useNeutralChartPalette } from './components/charts/palette';
|
|
47
|
+
export {
|
|
48
|
+
formatPercentage,
|
|
49
|
+
formatCompact,
|
|
50
|
+
formatRatio,
|
|
51
|
+
createChartFormatters,
|
|
52
|
+
useChartFormatters,
|
|
53
|
+
} from './components/charts/formatters';
|
|
54
|
+
|
|
55
|
+
// --- 7. Translations ---
|
|
43
56
|
export { authTranslations } from './i18n/authTranslations';
|
|
44
57
|
|
|
45
|
-
// ---
|
|
58
|
+
// --- 8. Notifications ---
|
|
46
59
|
export { NotificationSettings } from './notifications/NotificationSettings';
|
|
47
60
|
export * from './notifications/api';
|
|
48
61
|
export { NotificationsProvider, useNotifications } from './notifications/NotificationsProvider';
|
|
49
62
|
export { useRealtime } from './notifications/realtime';
|
|
50
63
|
export { NotificationBell } from './notifications/NotificationBell';
|
|
64
|
+
export { PopupSurface } from './notifications/PopupSurface';
|
|
51
65
|
export { getNotificationFeed, getUnreadCount, markNotifications } from './notifications/feedApi';
|
|
52
66
|
|
|
53
|
-
// ---
|
|
67
|
+
// --- 9. Onboarding ---
|
|
54
68
|
export * from './onboarding/api';
|
|
55
69
|
export { selectActiveSteps } from './onboarding/stepSelection';
|
|
56
70
|
export {
|
|
@@ -65,6 +79,7 @@ export { CompleteNameStep } from './onboarding/steps/CompleteNameStep';
|
|
|
65
79
|
export { BrowserPushStep } from './onboarding/steps/BrowserPushStep';
|
|
66
80
|
export { PwaInstallStep } from './onboarding/steps/PwaInstallStep';
|
|
67
81
|
|
|
68
|
-
// ---
|
|
82
|
+
// --- 10. Translations ---
|
|
69
83
|
export { notificationsTranslations } from './i18n/notificationsTranslations';
|
|
70
84
|
export { onboardingTranslations } from './i18n/onboardingTranslations';
|
|
85
|
+
export { chartsTranslations } from './i18n/chartsTranslations';
|
|
@@ -116,6 +116,27 @@ export function NotificationsProvider({ children, wsUrlBase }) {
|
|
|
116
116
|
}
|
|
117
117
|
|
|
118
118
|
const pushedNotification = normalizeNotificationPush(data);
|
|
119
|
+
|
|
120
|
+
// NOTIF-12 D2: a notification routed to more than one channel (e.g. chip
|
|
121
|
+
// + popup) arrives as multiple WS messages sharing one notification_id.
|
|
122
|
+
// Fold repeats into the existing feed entry (last-write-wins on content)
|
|
123
|
+
// instead of appending a duplicate and double-incrementing the badge.
|
|
124
|
+
const existingIndex = pushedNotification.notification_id == null
|
|
125
|
+
? -1
|
|
126
|
+
: notificationsRef.current.findIndex(
|
|
127
|
+
(notification) => notification.notification_id === pushedNotification.notification_id,
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
if (existingIndex !== -1) {
|
|
131
|
+
const nextNotifications = notificationsRef.current.map((notification, index) => (
|
|
132
|
+
index === existingIndex
|
|
133
|
+
? { ...notification, notification_type: pushedNotification.notification_type, content: pushedNotification.content }
|
|
134
|
+
: notification
|
|
135
|
+
));
|
|
136
|
+
replaceNotifications(nextNotifications);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
119
140
|
replaceNotifications([pushedNotification, ...notificationsRef.current]);
|
|
120
141
|
setUnreadCount((count) => count + 1);
|
|
121
142
|
}, [replaceNotifications]);
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { useTranslation } from 'react-i18next';
|
|
3
|
+
import Button from '@mui/material/Button';
|
|
4
|
+
import Stack from '@mui/material/Stack';
|
|
5
|
+
import Typography from '@mui/material/Typography';
|
|
6
|
+
import { useOnboarding } from '../onboarding/OnboardingProvider';
|
|
7
|
+
import { WizardDialogShell } from '../onboarding/WizardDialogShell';
|
|
8
|
+
import { useNotifications } from './NotificationsProvider';
|
|
9
|
+
import { DEFAULT_ENVELOPE, useRealtime } from './realtime';
|
|
10
|
+
|
|
11
|
+
function PopupStepComponent({ onDismiss, ctx }) {
|
|
12
|
+
const { t } = useTranslation();
|
|
13
|
+
const content = ctx?.content || {};
|
|
14
|
+
|
|
15
|
+
return (
|
|
16
|
+
<Stack spacing={2}>
|
|
17
|
+
{content.body_key && (
|
|
18
|
+
<Typography variant="body1">{t(content.body_key, content.params || {})}</Typography>
|
|
19
|
+
)}
|
|
20
|
+
<Button onClick={onDismiss} sx={{ alignSelf: 'flex-end' }}>
|
|
21
|
+
{t('PopupSurface.CLOSE')}
|
|
22
|
+
</Button>
|
|
23
|
+
</Stack>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const POPUP_STEP = { blocking: false, Component: PopupStepComponent };
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Renders popup-eligible notifications (NOTIF-12) through the onboarding
|
|
31
|
+
* wizard's dialog shell, reusing only the renderer (design D-F7) — status
|
|
32
|
+
* lives on NotificationRecipient via the existing feed/mark path, never in
|
|
33
|
+
* the onboarding progress store.
|
|
34
|
+
*
|
|
35
|
+
* Deliberately session-live only: it watches the raw realtime stream for
|
|
36
|
+
* `channel === "popup"` messages rather than the already-seeded feed list,
|
|
37
|
+
* since the feed carries no per-channel discriminator (see NotificationsProvider).
|
|
38
|
+
*/
|
|
39
|
+
export function PopupSurface() {
|
|
40
|
+
const { t } = useTranslation();
|
|
41
|
+
const { markSeen, markDismissed } = useNotifications();
|
|
42
|
+
const { subscribe } = useRealtime();
|
|
43
|
+
const onboarding = useOnboarding();
|
|
44
|
+
const [queue, setQueue] = useState([]);
|
|
45
|
+
const seenIdsRef = useRef(new Set());
|
|
46
|
+
|
|
47
|
+
const handleRealtimeMessage = useCallback((data) => {
|
|
48
|
+
if (data.type === 'notification.status') return;
|
|
49
|
+
if (data.channel !== 'popup') return;
|
|
50
|
+
setQueue((previous) => {
|
|
51
|
+
if (previous.some((item) => item.notification_id === data.notification_id)) return previous;
|
|
52
|
+
return [...previous, {
|
|
53
|
+
notification_id: data.notification_id,
|
|
54
|
+
// recipient_id is the id feed/mark/ actually resolves against
|
|
55
|
+
// (NotificationRecipient.pk, not Notification.pk). Fall back to
|
|
56
|
+
// notification_id only for an older dcm that predates it — matching
|
|
57
|
+
// NotificationsProvider's own normalizeNotificationPush precedent.
|
|
58
|
+
markId: data.recipient_id ?? data.notification_id,
|
|
59
|
+
content: data.content || {},
|
|
60
|
+
}];
|
|
61
|
+
});
|
|
62
|
+
}, []);
|
|
63
|
+
|
|
64
|
+
useEffect(() => subscribe(DEFAULT_ENVELOPE, handleRealtimeMessage), [subscribe, handleRealtimeMessage]);
|
|
65
|
+
|
|
66
|
+
// Any active onboarding step — blocking or not — must win: OnboardingWizard
|
|
67
|
+
// renders its own dialog for as long as onboarding.activeSteps is non-empty,
|
|
68
|
+
// and two independent MUI Dialogs must never be mounted at once.
|
|
69
|
+
const onboardingActive = Boolean(onboarding?.activeSteps?.length);
|
|
70
|
+
const activePopup = queue[0];
|
|
71
|
+
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
if (!activePopup || onboardingActive) return;
|
|
74
|
+
if (activePopup.markId == null) return;
|
|
75
|
+
if (seenIdsRef.current.has(activePopup.notification_id)) return;
|
|
76
|
+
seenIdsRef.current.add(activePopup.notification_id);
|
|
77
|
+
markSeen([activePopup.markId]);
|
|
78
|
+
}, [activePopup, onboardingActive, markSeen]);
|
|
79
|
+
|
|
80
|
+
const dismissActivePopup = useCallback(() => {
|
|
81
|
+
if (!activePopup) return;
|
|
82
|
+
if (activePopup.markId != null) markDismissed([activePopup.markId]);
|
|
83
|
+
setQueue((previous) => previous.filter((item) => item.notification_id !== activePopup.notification_id));
|
|
84
|
+
}, [activePopup, markDismissed]);
|
|
85
|
+
|
|
86
|
+
if (!activePopup || onboardingActive) return null;
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
<WizardDialogShell
|
|
90
|
+
step={POPUP_STEP}
|
|
91
|
+
stepIndex={0}
|
|
92
|
+
total={1}
|
|
93
|
+
onComplete={dismissActivePopup}
|
|
94
|
+
onDismiss={dismissActivePopup}
|
|
95
|
+
ctx={{ content: activePopup.content }}
|
|
96
|
+
title={activePopup.content.title_key
|
|
97
|
+
? t(activePopup.content.title_key, activePopup.content.params || {})
|
|
98
|
+
: t('PopupSurface.TITLE')}
|
|
99
|
+
/>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export default PopupSurface;
|
|
@@ -1,16 +1,9 @@
|
|
|
1
1
|
import React, { useContext, useEffect, useRef, useState } from 'react';
|
|
2
|
-
import { useTranslation } from 'react-i18next';
|
|
3
|
-
import Box from '@mui/material/Box';
|
|
4
|
-
import Dialog from '@mui/material/Dialog';
|
|
5
|
-
import DialogContent from '@mui/material/DialogContent';
|
|
6
|
-
import DialogTitle from '@mui/material/DialogTitle';
|
|
7
|
-
import LinearProgress from '@mui/material/LinearProgress';
|
|
8
|
-
import Typography from '@mui/material/Typography';
|
|
9
2
|
import { AuthContext } from '../auth/AuthContext';
|
|
10
3
|
import { useOnboarding } from './OnboardingProvider';
|
|
4
|
+
import { WizardDialogShell } from './WizardDialogShell';
|
|
11
5
|
|
|
12
6
|
export function OnboardingWizard() {
|
|
13
|
-
const { t } = useTranslation();
|
|
14
7
|
const onboarding = useOnboarding();
|
|
15
8
|
const authContext = useContext(AuthContext);
|
|
16
9
|
const [sessionDismissed, setSessionDismissed] = useState(() => new Set());
|
|
@@ -39,8 +32,6 @@ export function OnboardingWizard() {
|
|
|
39
32
|
|
|
40
33
|
const { dismissStep, ctx } = onboarding;
|
|
41
34
|
const total = totalRef.current || visibleSteps.length;
|
|
42
|
-
const progress = total > 1 ? Math.round((completed / total) * 100) : 0;
|
|
43
|
-
const StepComponent = currentStep.Component;
|
|
44
35
|
|
|
45
36
|
const completeCurrentStep = () => {
|
|
46
37
|
setSessionDismissed((previous) => new Set([...previous, currentStep.id]));
|
|
@@ -56,19 +47,14 @@ export function OnboardingWizard() {
|
|
|
56
47
|
};
|
|
57
48
|
|
|
58
49
|
return (
|
|
59
|
-
<
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
</DialogTitle>
|
|
68
|
-
<DialogContent sx={{ pt: 2 }}>
|
|
69
|
-
<StepComponent onComplete={completeCurrentStep} onDismiss={dismissCurrentStep} ctx={ctx} />
|
|
70
|
-
</DialogContent>
|
|
71
|
-
</Dialog>
|
|
50
|
+
<WizardDialogShell
|
|
51
|
+
step={currentStep}
|
|
52
|
+
stepIndex={completed}
|
|
53
|
+
total={total}
|
|
54
|
+
onComplete={completeCurrentStep}
|
|
55
|
+
onDismiss={dismissCurrentStep}
|
|
56
|
+
ctx={ctx}
|
|
57
|
+
/>
|
|
72
58
|
);
|
|
73
59
|
}
|
|
74
60
|
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { useTranslation } from 'react-i18next';
|
|
3
|
+
import Box from '@mui/material/Box';
|
|
4
|
+
import Dialog from '@mui/material/Dialog';
|
|
5
|
+
import DialogContent from '@mui/material/DialogContent';
|
|
6
|
+
import DialogTitle from '@mui/material/DialogTitle';
|
|
7
|
+
import LinearProgress from '@mui/material/LinearProgress';
|
|
8
|
+
import Typography from '@mui/material/Typography';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Presentational dialog shell shared by OnboardingWizard and the popup
|
|
12
|
+
* notification surface (NOTIF-12). Owns only the dialog frame, the
|
|
13
|
+
* counter/progress header, and the onComplete/onDismiss/ctx child contract —
|
|
14
|
+
* step selection, dismissal persistence, and any progress store stay with
|
|
15
|
+
* the caller.
|
|
16
|
+
*/
|
|
17
|
+
export function WizardDialogShell({ step, stepIndex, total, onComplete, onDismiss, ctx, title }) {
|
|
18
|
+
const { t } = useTranslation();
|
|
19
|
+
const progress = total > 1 ? Math.round((stepIndex / total) * 100) : 0;
|
|
20
|
+
const StepComponent = step.Component;
|
|
21
|
+
// `title` lets a non-onboarding caller (e.g. the popup surface) supply its
|
|
22
|
+
// own header instead of the onboarding-specific step counter/"Setup" copy.
|
|
23
|
+
// Omitted by OnboardingWizard, so its header text is unchanged.
|
|
24
|
+
const headerLabel = title ?? (total > 1
|
|
25
|
+
? t('Onboarding.STEP_COUNTER', { current: stepIndex + 1, total })
|
|
26
|
+
: t('Onboarding.SETUP'));
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<Dialog
|
|
30
|
+
open
|
|
31
|
+
fullWidth
|
|
32
|
+
maxWidth="sm"
|
|
33
|
+
disableEscapeKeyDown={step.blocking}
|
|
34
|
+
onClose={step.blocking ? undefined : onDismiss}
|
|
35
|
+
>
|
|
36
|
+
<DialogTitle sx={{ pb: 0 }}>
|
|
37
|
+
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
|
38
|
+
<Typography variant="subtitle2" color="text.secondary">
|
|
39
|
+
{headerLabel}
|
|
40
|
+
</Typography>
|
|
41
|
+
</Box>
|
|
42
|
+
{total > 1 && <LinearProgress variant="determinate" value={progress} sx={{ mt: 1, borderRadius: 1 }} />}
|
|
43
|
+
</DialogTitle>
|
|
44
|
+
<DialogContent sx={{ pt: 2 }}>
|
|
45
|
+
<StepComponent onComplete={onComplete} onDismiss={onDismiss} ctx={ctx} />
|
|
46
|
+
</DialogContent>
|
|
47
|
+
</Dialog>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export default WizardDialogShell;
|