@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.
Files changed (38) hide show
  1. package/WORK_ORDERS.md +4 -2
  2. package/dist/components/charts/BarChart.js +32 -0
  3. package/dist/components/charts/ChartFrame.js +50 -0
  4. package/dist/components/charts/LineChart.js +32 -0
  5. package/dist/components/charts/exportChart.js +67 -0
  6. package/dist/components/charts/formatters.js +23 -0
  7. package/dist/components/charts/palette.js +21 -0
  8. package/dist/i18n/chartsTranslations.js +38 -0
  9. package/dist/i18n/notificationsTranslations.js +2 -0
  10. package/dist/index.js +12 -4
  11. package/dist/notifications/NotificationsProvider.js +13 -0
  12. package/dist/notifications/PopupSurface.js +84 -0
  13. package/dist/onboarding/OnboardingWizard.js +3 -12
  14. package/dist/onboarding/WizardDialogShell.js +29 -0
  15. package/package.json +3 -1
  16. package/src/components/charts/BarChart.jsx +44 -0
  17. package/src/components/charts/ChartFrame.jsx +112 -0
  18. package/src/components/charts/LineChart.jsx +44 -0
  19. package/src/components/charts/exportChart.js +71 -0
  20. package/src/components/charts/formatters.js +39 -0
  21. package/src/components/charts/palette.js +24 -0
  22. package/src/i18n/chartsTranslations.ts +38 -0
  23. package/src/i18n/notificationsTranslations.ts +2 -0
  24. package/src/index.js +19 -4
  25. package/src/notifications/NotificationsProvider.jsx +21 -0
  26. package/src/notifications/PopupSurface.jsx +103 -0
  27. package/src/onboarding/OnboardingWizard.jsx +9 -23
  28. package/src/onboarding/WizardDialogShell.jsx +51 -0
  29. package/tests/BarChart.test.jsx +55 -0
  30. package/tests/ChartFrame.test.jsx +122 -0
  31. package/tests/LineChart.test.jsx +33 -0
  32. package/tests/NotificationsProvider.test.jsx +38 -0
  33. package/tests/OnboardingWizard.test.jsx +143 -0
  34. package/tests/PopupSurface.test.jsx +142 -0
  35. package/tests/chartsPalette.test.js +39 -0
  36. package/tests/notificationsExports.test.js +5 -0
  37. package/work-orders/CHART-1.md +285 -0
  38. 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. Translations ---
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
- // --- 7. Notifications ---
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
- // --- 8. Onboarding ---
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
- // --- 9. Translations ---
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
- <Dialog open fullWidth maxWidth="sm" disableEscapeKeyDown={currentStep.blocking} onClose={currentStep.blocking ? undefined : dismissCurrentStep}>
60
- <DialogTitle sx={{ pb: 0 }}>
61
- <Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
62
- <Typography variant="subtitle2" color="text.secondary">
63
- {total > 1 ? t('Onboarding.STEP_COUNTER', { current: completed + 1, total }) : t('Onboarding.SETUP')}
64
- </Typography>
65
- </Box>
66
- {total > 1 && <LinearProgress variant="determinate" value={progress} sx={{ mt: 1, borderRadius: 1 }} />}
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;