@micha.bigler/ui-core-micha 2.4.5 → 2.6.1

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 (33) hide show
  1. package/.github/workflows/publish.yml +4 -0
  2. package/dist/components/UserListComponent.js +81 -71
  3. package/dist/i18n/notificationsTranslations.js +17 -0
  4. package/dist/i18n/onboardingTranslations.js +17 -0
  5. package/dist/index.js +14 -0
  6. package/dist/notifications/NotificationSettings.js +148 -0
  7. package/dist/notifications/api.js +41 -0
  8. package/dist/notifications/serviceWorker/sw.js +49 -0
  9. package/dist/onboarding/OnboardingProvider.js +133 -0
  10. package/dist/onboarding/OnboardingWizard.js +47 -0
  11. package/dist/onboarding/api.js +10 -0
  12. package/dist/onboarding/stepSelection.js +18 -0
  13. package/dist/onboarding/steps/BrowserPushStep.js +49 -0
  14. package/dist/onboarding/steps/CompleteNameStep.js +37 -0
  15. package/dist/onboarding/steps/CookieConsentStep.js +31 -0
  16. package/package.json +7 -4
  17. package/src/components/UserListComponent.jsx +196 -122
  18. package/src/i18n/notificationsTranslations.ts +17 -0
  19. package/src/i18n/onboardingTranslations.ts +17 -0
  20. package/src/index.js +22 -0
  21. package/src/notifications/NotificationSettings.jsx +189 -0
  22. package/src/notifications/api.js +49 -0
  23. package/src/notifications/serviceWorker/sw.js +50 -0
  24. package/src/onboarding/OnboardingProvider.jsx +153 -0
  25. package/src/onboarding/OnboardingWizard.jsx +66 -0
  26. package/src/onboarding/api.js +13 -0
  27. package/src/onboarding/stepSelection.js +16 -0
  28. package/src/onboarding/steps/BrowserPushStep.jsx +63 -0
  29. package/src/onboarding/steps/CompleteNameStep.jsx +46 -0
  30. package/src/onboarding/steps/CookieConsentStep.jsx +39 -0
  31. package/tests/NotificationSettings.test.jsx +82 -0
  32. package/tests/notificationsApi.test.js +41 -0
  33. package/tests/stepSelection.test.js +53 -0
@@ -0,0 +1,189 @@
1
+ import React, { useEffect, useState } from 'react';
2
+ import { useTranslation } from 'react-i18next';
3
+ import Alert from '@mui/material/Alert';
4
+ import Box from '@mui/material/Box';
5
+ import Button from '@mui/material/Button';
6
+ import CircularProgress from '@mui/material/CircularProgress';
7
+ import Divider from '@mui/material/Divider';
8
+ import FormControlLabel from '@mui/material/FormControlLabel';
9
+ import Switch from '@mui/material/Switch';
10
+ import Typography from '@mui/material/Typography';
11
+ import NotificationsIcon from '@mui/icons-material/Notifications';
12
+ import {
13
+ getNotificationPreferences,
14
+ getVapidPublicKey,
15
+ patchNotificationPreferences,
16
+ removePushSubscription,
17
+ savePushSubscription,
18
+ urlBase64ToUint8Array,
19
+ } from './api';
20
+
21
+ function getPushSupport() {
22
+ return typeof navigator !== 'undefined'
23
+ && typeof window !== 'undefined'
24
+ && 'serviceWorker' in navigator
25
+ && 'PushManager' in window;
26
+ }
27
+
28
+ function getIosInstallState() {
29
+ if (typeof navigator === 'undefined' || typeof window === 'undefined') return false;
30
+ const isIos = /iphone|ipad|ipod/i.test(navigator.userAgent || '');
31
+ const standalone = window.matchMedia?.('(display-mode: standalone)').matches
32
+ || window.navigator.standalone === true;
33
+ return isIos && !standalone;
34
+ }
35
+
36
+ export function NotificationSettings() {
37
+ const { t } = useTranslation();
38
+ const [preferences, setPreferences] = useState(null);
39
+ const [loading, setLoading] = useState(true);
40
+ const [savingEmail, setSavingEmail] = useState(false);
41
+ const [savingPush, setSavingPush] = useState(false);
42
+ const [pushSubscribed, setPushSubscribed] = useState(false);
43
+ const [error, setError] = useState('');
44
+ const [conflict, setConflict] = useState('');
45
+
46
+ const pushSupported = getPushSupport();
47
+ const iosNeedsInstall = getIosInstallState();
48
+
49
+ useEffect(() => {
50
+ let cancelled = false;
51
+
52
+ getNotificationPreferences()
53
+ .then((data) => {
54
+ if (!cancelled) setPreferences(data);
55
+ })
56
+ .catch(() => {
57
+ if (!cancelled) setError(t('NotificationSettings.LOAD_ERROR'));
58
+ })
59
+ .finally(() => {
60
+ if (!cancelled) setLoading(false);
61
+ });
62
+
63
+ return () => { cancelled = true; };
64
+ }, [t]);
65
+
66
+ useEffect(() => {
67
+ let cancelled = false;
68
+ if (!pushSupported) return undefined;
69
+
70
+ navigator.serviceWorker.ready
71
+ .then((registration) => registration.pushManager.getSubscription())
72
+ .then((subscription) => {
73
+ if (!cancelled) setPushSubscribed(Boolean(subscription));
74
+ })
75
+ .catch(() => {
76
+ if (!cancelled) setPushSubscribed(false);
77
+ });
78
+
79
+ return () => { cancelled = true; };
80
+ }, [pushSupported]);
81
+
82
+ const handleEmailToggle = async (event) => {
83
+ const email_opt_in = event.target.checked;
84
+ setSavingEmail(true);
85
+ setError('');
86
+ try {
87
+ const updated = await patchNotificationPreferences({ email_opt_in });
88
+ setPreferences(updated);
89
+ } catch {
90
+ setError(t('NotificationSettings.SAVE_ERROR'));
91
+ } finally {
92
+ setSavingEmail(false);
93
+ }
94
+ };
95
+
96
+ const handleEnablePush = async () => {
97
+ setSavingPush(true);
98
+ setError('');
99
+ setConflict('');
100
+ try {
101
+ const permission = await Notification.requestPermission();
102
+ if (permission !== 'granted') {
103
+ setError(t('NotificationSettings.PUSH_DENIED'));
104
+ return;
105
+ }
106
+ const vapidPublicKey = await getVapidPublicKey();
107
+ const registration = await navigator.serviceWorker.ready;
108
+ const subscription = await registration.pushManager.subscribe({
109
+ userVisibleOnly: true,
110
+ applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
111
+ });
112
+ await savePushSubscription(subscription.toJSON(), navigator.userAgent);
113
+ const updated = await patchNotificationPreferences({ push_opt_in: true });
114
+ setPreferences(updated);
115
+ setPushSubscribed(true);
116
+ } catch (requestError) {
117
+ if (requestError?.response?.status === 409) {
118
+ setConflict(t('NotificationSettings.PUSH_CONFLICT'));
119
+ } else {
120
+ setError(t('NotificationSettings.PUSH_ERROR'));
121
+ }
122
+ } finally {
123
+ setSavingPush(false);
124
+ }
125
+ };
126
+
127
+ const handleDisablePush = async () => {
128
+ setSavingPush(true);
129
+ setError('');
130
+ setConflict('');
131
+ try {
132
+ const registration = await navigator.serviceWorker.ready;
133
+ const subscription = await registration.pushManager.getSubscription();
134
+ if (subscription) {
135
+ const endpoint = subscription.endpoint;
136
+ await subscription.unsubscribe();
137
+ await removePushSubscription({ endpoint });
138
+ }
139
+ setPushSubscribed(false);
140
+ } catch {
141
+ setError(t('NotificationSettings.PUSH_ERROR'));
142
+ } finally {
143
+ setSavingPush(false);
144
+ }
145
+ };
146
+
147
+ if (loading) {
148
+ return <Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}><CircularProgress /></Box>;
149
+ }
150
+
151
+ return (
152
+ <Box sx={{ maxWidth: 520 }}>
153
+ <Typography variant="h6" gutterBottom sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
154
+ <NotificationsIcon />
155
+ {t('NotificationSettings.TITLE')}
156
+ </Typography>
157
+ <Typography variant="body2" color="text.secondary" gutterBottom>
158
+ {t('NotificationSettings.SUBTITLE')}
159
+ </Typography>
160
+ {error && <Alert severity="error" sx={{ mb: 2 }} onClose={() => setError('')}>{error}</Alert>}
161
+ {conflict && <Alert severity="warning" sx={{ mb: 2 }} onClose={() => setConflict('')}>{conflict}</Alert>}
162
+
163
+ <Box sx={{ py: 2 }}>
164
+ <FormControlLabel
165
+ control={<Switch checked={Boolean(preferences?.email_opt_in)} onChange={handleEmailToggle} disabled={savingEmail} />}
166
+ label={<Box><Typography variant="body1">{t('NotificationSettings.EMAIL_LABEL')}</Typography><Typography variant="caption" color="text.secondary">{t('NotificationSettings.EMAIL_HINT')}</Typography></Box>}
167
+ labelPlacement="end"
168
+ sx={{ alignItems: 'flex-start', ml: 0 }}
169
+ />
170
+ </Box>
171
+
172
+ <Divider />
173
+
174
+ <Box sx={{ py: 2 }}>
175
+ <Typography variant="body1" gutterBottom>{t('NotificationSettings.PUSH_LABEL')}</Typography>
176
+ <Typography variant="caption" color="text.secondary" display="block" sx={{ mb: 1.5 }}>{t('NotificationSettings.PUSH_HINT')}</Typography>
177
+ {iosNeedsInstall && <Alert severity="info" sx={{ mb: 1.5 }}>{t('NotificationSettings.IOS_HINT')}</Alert>}
178
+ {!pushSupported && !iosNeedsInstall && <Alert severity="warning">{t('NotificationSettings.PUSH_NOT_SUPPORTED')}</Alert>}
179
+ {pushSupported && !iosNeedsInstall && (pushSubscribed ? (
180
+ <Button variant="outlined" color="error" onClick={handleDisablePush} disabled={savingPush} startIcon={savingPush ? <CircularProgress size={16} /> : undefined}>{t('NotificationSettings.PUSH_DISABLE')}</Button>
181
+ ) : (
182
+ <Button variant="contained" onClick={handleEnablePush} disabled={savingPush} startIcon={savingPush ? <CircularProgress size={16} /> : undefined}>{t('NotificationSettings.PUSH_ENABLE')}</Button>
183
+ ))}
184
+ </Box>
185
+ </Box>
186
+ );
187
+ }
188
+
189
+ export default NotificationSettings;
@@ -0,0 +1,49 @@
1
+ import apiClient from '../auth/apiClient';
2
+
3
+ const PREFERENCES_URL = '/api/notifications/preferences/';
4
+ const PUSH_SUBSCRIPTION_URL = `${PREFERENCES_URL}push-subscription/`;
5
+
6
+ export async function getNotificationPreferences() {
7
+ const response = await apiClient.get(PREFERENCES_URL);
8
+ return response.data;
9
+ }
10
+
11
+ export async function patchNotificationPreferences(patch) {
12
+ const response = await apiClient.patch(PREFERENCES_URL, patch);
13
+ return response.data;
14
+ }
15
+
16
+ export async function getVapidPublicKey() {
17
+ const response = await apiClient.get(`${PREFERENCES_URL}vapid-public-key/`);
18
+ return response.data?.vapidPublicKey;
19
+ }
20
+
21
+ export async function listPushSubscriptions() {
22
+ const response = await apiClient.get(PUSH_SUBSCRIPTION_URL);
23
+ return response.data;
24
+ }
25
+
26
+ export async function savePushSubscription(subscription, ua) {
27
+ const response = await apiClient.post(PUSH_SUBSCRIPTION_URL, { subscription, ua });
28
+ return response.data;
29
+ }
30
+
31
+ export async function removePushSubscription(subscriptionIdentifier) {
32
+ const response = await apiClient.delete(PUSH_SUBSCRIPTION_URL, { data: subscriptionIdentifier });
33
+ return response.data;
34
+ }
35
+
36
+ export function urlBase64ToUint8Array(base64String) {
37
+ const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
38
+ const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
39
+ const decoder = typeof window !== 'undefined' && typeof window.atob === 'function'
40
+ ? window.atob
41
+ : typeof atob === 'function'
42
+ ? atob
43
+ : null;
44
+
45
+ if (!decoder) throw new Error('Base64 decoding is unavailable in this environment.');
46
+
47
+ const raw = decoder(base64);
48
+ return Uint8Array.from([...raw].map((character) => character.charCodeAt(0)));
49
+ }
@@ -0,0 +1,50 @@
1
+ /*
2
+ * Canonical service-worker source. Copy this file verbatim to each consuming
3
+ * application's public/sw.js so it is served from that application's origin root.
4
+ */
5
+ const resolveSameOriginUrl = (url) => {
6
+ try {
7
+ const resolvedUrl = new URL(url || '/', self.location.origin);
8
+ if (resolvedUrl.origin !== self.location.origin) return '/';
9
+
10
+ return `${resolvedUrl.pathname}${resolvedUrl.search}${resolvedUrl.hash}`;
11
+ } catch {
12
+ return '/';
13
+ }
14
+ };
15
+
16
+ self.addEventListener('push', (event) => {
17
+ if (!event.data) return;
18
+
19
+ let payload;
20
+ try {
21
+ payload = event.data.json();
22
+ } catch {
23
+ payload = { body: event.data.text() };
24
+ }
25
+
26
+ const options = {
27
+ body: payload.body || '',
28
+ data: { url: resolveSameOriginUrl(payload.url) },
29
+ };
30
+ if (payload.icon) options.icon = payload.icon;
31
+ if (payload.badge) options.badge = payload.badge;
32
+
33
+ event.waitUntil(self.registration.showNotification(payload.title || 'Notification', options));
34
+ });
35
+
36
+ self.addEventListener('notificationclick', (event) => {
37
+ event.notification.close();
38
+ const url = resolveSameOriginUrl(event.notification.data?.url);
39
+
40
+ event.waitUntil(
41
+ clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => {
42
+ for (const client of clientList) {
43
+ if (client.url.includes(self.location.origin) && 'focus' in client) {
44
+ return Promise.resolve(client.navigate(url)).then(() => client.focus());
45
+ }
46
+ }
47
+ return clients.openWindow ? clients.openWindow(url) : undefined;
48
+ }),
49
+ );
50
+ });
@@ -0,0 +1,153 @@
1
+ import React, {
2
+ createContext,
3
+ useCallback,
4
+ useContext,
5
+ useEffect,
6
+ useMemo,
7
+ useState,
8
+ } from 'react';
9
+ import { AuthContext } from '../auth/AuthContext';
10
+ import { getOnboardingStepConfig } from './api';
11
+ import { selectActiveSteps } from './stepSelection';
12
+ import CookieConsentStep from './steps/CookieConsentStep';
13
+ import CompleteNameStep from './steps/CompleteNameStep';
14
+ import BrowserPushStep from './steps/BrowserPushStep';
15
+
16
+ export const UNIVERSAL_STEP_DESCRIPTORS = [
17
+ {
18
+ id: 'cookie_consent',
19
+ condition: (ctx) => Boolean(ctx.user && !ctx.user.accepted_convenience_cookies),
20
+ blocking: true,
21
+ skipable: false,
22
+ persistDismissed: false,
23
+ titleKey: 'Onboarding.COOKIE_CONSENT_TITLE',
24
+ Component: CookieConsentStep,
25
+ },
26
+ {
27
+ id: 'complete_name',
28
+ condition: (ctx) => Boolean(ctx.user && (!ctx.user.first_name?.trim() || !ctx.user.last_name?.trim())),
29
+ blocking: true,
30
+ skipable: false,
31
+ persistDismissed: false,
32
+ titleKey: 'Onboarding.COMPLETE_NAME_TITLE',
33
+ Component: CompleteNameStep,
34
+ },
35
+ {
36
+ id: 'browser_push',
37
+ condition: (ctx) => Boolean(ctx.pushState?.supported && !ctx.pushState.subscribed),
38
+ blocking: false,
39
+ skipable: true,
40
+ persistDismissed: true,
41
+ titleKey: 'Onboarding.BROWSER_PUSH_TITLE',
42
+ Component: BrowserPushStep,
43
+ },
44
+ ];
45
+
46
+ export const OnboardingContext = createContext(null);
47
+
48
+ export function useOnboarding() {
49
+ return useContext(OnboardingContext);
50
+ }
51
+
52
+ function loadDismissedSteps() {
53
+ if (typeof window === 'undefined') return new Set();
54
+ try {
55
+ return new Set(JSON.parse(window.localStorage.getItem('onboarding_dismissed')) || []);
56
+ } catch {
57
+ return new Set();
58
+ }
59
+ }
60
+
61
+ function getInitialPushState() {
62
+ const supported = typeof navigator !== 'undefined'
63
+ && typeof window !== 'undefined'
64
+ && 'serviceWorker' in navigator
65
+ && 'PushManager' in window;
66
+ return supported ? null : { supported: false, subscribed: false };
67
+ }
68
+
69
+ export function OnboardingProvider({ children, extraSteps = [] }) {
70
+ const { user } = useContext(AuthContext);
71
+ const [configMap, setConfigMap] = useState({});
72
+ const [configLoaded, setConfigLoaded] = useState(false);
73
+ const [pushState, setPushState] = useState(getInitialPushState);
74
+ const [dismissedSet, setDismissedSet] = useState(loadDismissedSteps);
75
+
76
+ const descriptors = useMemo(
77
+ () => [...UNIVERSAL_STEP_DESCRIPTORS, ...extraSteps],
78
+ [extraSteps],
79
+ );
80
+
81
+ useEffect(() => {
82
+ let cancelled = false;
83
+ if (!user) {
84
+ setConfigMap({});
85
+ setConfigLoaded(false);
86
+ return undefined;
87
+ }
88
+
89
+ getOnboardingStepConfig()
90
+ .then((data) => {
91
+ if (cancelled) return;
92
+ const nextConfigMap = {};
93
+ (data || []).forEach((item) => { nextConfigMap[item.key] = item.enabled; });
94
+ setConfigMap(nextConfigMap);
95
+ })
96
+ .catch(() => {
97
+ if (!cancelled) setConfigMap({});
98
+ })
99
+ .finally(() => {
100
+ if (!cancelled) setConfigLoaded(true);
101
+ });
102
+
103
+ return () => { cancelled = true; };
104
+ }, [user]);
105
+
106
+ useEffect(() => {
107
+ let cancelled = false;
108
+ if (typeof navigator === 'undefined' || typeof window === 'undefined'
109
+ || !('serviceWorker' in navigator) || !('PushManager' in window)) {
110
+ setPushState({ supported: false, subscribed: false });
111
+ return undefined;
112
+ }
113
+
114
+ navigator.serviceWorker.ready
115
+ .then((registration) => registration.pushManager.getSubscription())
116
+ .then((subscription) => {
117
+ if (!cancelled) setPushState({ supported: true, subscribed: Boolean(subscription) });
118
+ })
119
+ .catch(() => {
120
+ if (!cancelled) setPushState({ supported: true, subscribed: false });
121
+ });
122
+
123
+ return () => { cancelled = true; };
124
+ }, []);
125
+
126
+ const dismissStep = useCallback((id) => {
127
+ setDismissedSet((previous) => {
128
+ const next = new Set(previous);
129
+ next.add(id);
130
+ if (typeof window !== 'undefined') {
131
+ try {
132
+ window.localStorage.setItem('onboarding_dismissed', JSON.stringify([...next]));
133
+ } catch {
134
+ // Persistence is optional; the in-memory dismissal still applies.
135
+ }
136
+ }
137
+ return next;
138
+ });
139
+ }, []);
140
+
141
+ const ctx = useMemo(() => ({ user, pushState }), [user, pushState]);
142
+ const activeSteps = useMemo(() => {
143
+ if (!configLoaded || !user) return [];
144
+ return selectActiveSteps(descriptors, configMap, ctx, dismissedSet);
145
+ }, [configLoaded, user, descriptors, configMap, ctx, dismissedSet]);
146
+
147
+ const value = useMemo(
148
+ () => ({ activeSteps, dismissStep, ctx, configMap, setConfigMap }),
149
+ [activeSteps, dismissStep, ctx, configMap],
150
+ );
151
+
152
+ return <OnboardingContext.Provider value={value}>{children}</OnboardingContext.Provider>;
153
+ }
@@ -0,0 +1,66 @@
1
+ import React, { 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
+ import { useOnboarding } from './OnboardingProvider';
10
+
11
+ export function OnboardingWizard() {
12
+ const { t } = useTranslation();
13
+ const onboarding = useOnboarding();
14
+ const [sessionDismissed, setSessionDismissed] = useState(() => new Set());
15
+ const totalRef = useRef(null);
16
+ const [completed, setCompleted] = useState(0);
17
+ const activeSteps = onboarding?.activeSteps || [];
18
+ const visibleSteps = activeSteps.filter((step) => !sessionDismissed.has(step.id));
19
+
20
+ useEffect(() => {
21
+ if (visibleSteps.length > 0 && totalRef.current === null) {
22
+ totalRef.current = visibleSteps.length;
23
+ setCompleted(0);
24
+ }
25
+ if (visibleSteps.length === 0) {
26
+ totalRef.current = null;
27
+ setCompleted(0);
28
+ }
29
+ }, [visibleSteps.length]);
30
+
31
+ if (!onboarding || visibleSteps.length === 0) return null;
32
+
33
+ const { dismissStep, ctx } = onboarding;
34
+ const currentStep = visibleSteps[0];
35
+ const total = totalRef.current || visibleSteps.length;
36
+ const progress = total > 1 ? Math.round((completed / total) * 100) : 0;
37
+ const StepComponent = currentStep.Component;
38
+
39
+ const completeCurrentStep = () => {
40
+ setSessionDismissed((previous) => new Set([...previous, currentStep.id]));
41
+ setCompleted((count) => count + 1);
42
+ };
43
+
44
+ const dismissCurrentStep = () => {
45
+ if (currentStep.persistDismissed) dismissStep(currentStep.id);
46
+ completeCurrentStep();
47
+ };
48
+
49
+ return (
50
+ <Dialog open fullWidth maxWidth="sm" disableEscapeKeyDown={currentStep.blocking} onClose={currentStep.blocking ? undefined : dismissCurrentStep}>
51
+ <DialogTitle sx={{ pb: 0 }}>
52
+ <Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
53
+ <Typography variant="subtitle2" color="text.secondary">
54
+ {total > 1 ? t('Onboarding.STEP_COUNTER', { current: completed + 1, total }) : t('Onboarding.SETUP')}
55
+ </Typography>
56
+ </Box>
57
+ {total > 1 && <LinearProgress variant="determinate" value={progress} sx={{ mt: 1, borderRadius: 1 }} />}
58
+ </DialogTitle>
59
+ <DialogContent sx={{ pt: 2 }}>
60
+ <StepComponent onComplete={completeCurrentStep} onDismiss={dismissCurrentStep} ctx={ctx} />
61
+ </DialogContent>
62
+ </Dialog>
63
+ );
64
+ }
65
+
66
+ export default OnboardingWizard;
@@ -0,0 +1,13 @@
1
+ import apiClient from '../auth/apiClient';
2
+
3
+ const STEP_CONFIG_URL = '/api/onboarding/step-config/';
4
+
5
+ export async function getOnboardingStepConfig() {
6
+ const response = await apiClient.get(STEP_CONFIG_URL);
7
+ return response.data;
8
+ }
9
+
10
+ export async function patchOnboardingStepConfig(payload) {
11
+ const response = await apiClient.patch(STEP_CONFIG_URL, payload);
12
+ return response.data;
13
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Pure onboarding step selection.
3
+ *
4
+ * @param {Array} descriptors Step descriptors in display order.
5
+ * @param {Object} configMap Enabled state keyed by dcm onboarding step key.
6
+ * @param {Object} ctx Runtime context supplied to each step condition.
7
+ * @param {Set} dismissedSet Persistently dismissed step ids.
8
+ * @returns {Array} Steps that should be shown.
9
+ */
10
+ export function selectActiveSteps(descriptors, configMap, ctx, dismissedSet = new Set()) {
11
+ return descriptors.filter((step) => {
12
+ if (configMap[step.id] === false) return false;
13
+ if (step.persistDismissed && dismissedSet.has(step.id)) return false;
14
+ return step.condition(ctx);
15
+ });
16
+ }
@@ -0,0 +1,63 @@
1
+ import React, { useState } from 'react';
2
+ import { useTranslation } from 'react-i18next';
3
+ import Alert from '@mui/material/Alert';
4
+ import Box from '@mui/material/Box';
5
+ import Button from '@mui/material/Button';
6
+ import CircularProgress from '@mui/material/CircularProgress';
7
+ import Typography from '@mui/material/Typography';
8
+ import NotificationsIcon from '@mui/icons-material/Notifications';
9
+ import {
10
+ getVapidPublicKey,
11
+ patchNotificationPreferences,
12
+ savePushSubscription,
13
+ urlBase64ToUint8Array,
14
+ } from '../../notifications/api';
15
+
16
+ export function BrowserPushStep({ onComplete, onDismiss }) {
17
+ const { t } = useTranslation();
18
+ const [loading, setLoading] = useState(false);
19
+ const [error, setError] = useState('');
20
+
21
+ const enable = async () => {
22
+ setLoading(true);
23
+ setError('');
24
+ try {
25
+ const permission = await Notification.requestPermission();
26
+ if (permission !== 'granted') {
27
+ setError(t('NotificationSettings.PUSH_DENIED'));
28
+ return;
29
+ }
30
+ const vapidPublicKey = await getVapidPublicKey();
31
+ const registration = await navigator.serviceWorker.ready;
32
+ const subscription = await registration.pushManager.subscribe({
33
+ userVisibleOnly: true,
34
+ applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
35
+ });
36
+ await savePushSubscription(subscription.toJSON(), navigator.userAgent);
37
+ await patchNotificationPreferences({ push_opt_in: true });
38
+ onComplete();
39
+ } catch (requestError) {
40
+ if (requestError?.response?.status === 409) {
41
+ setError(t('NotificationSettings.PUSH_CONFLICT'));
42
+ } else {
43
+ setError(t('NotificationSettings.PUSH_ERROR'));
44
+ }
45
+ } finally {
46
+ setLoading(false);
47
+ }
48
+ };
49
+
50
+ return (
51
+ <Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
52
+ <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}><NotificationsIcon color="primary" /><Typography variant="h6">{t('Onboarding.BROWSER_PUSH_TITLE')}</Typography></Box>
53
+ <Typography variant="body2" color="text.secondary">{t('Onboarding.BROWSER_PUSH_BODY')}</Typography>
54
+ {error && <Alert severity="warning" onClose={() => setError('')}>{error}</Alert>}
55
+ <Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
56
+ <Button variant="text" onClick={onDismiss} disabled={loading}>{t('Onboarding.SKIP')}</Button>
57
+ <Button variant="contained" onClick={enable} disabled={loading} startIcon={loading ? <CircularProgress size={16} /> : undefined}>{t('NotificationSettings.PUSH_ENABLE')}</Button>
58
+ </Box>
59
+ </Box>
60
+ );
61
+ }
62
+
63
+ export default BrowserPushStep;
@@ -0,0 +1,46 @@
1
+ import React, { useState } from 'react';
2
+ import { useTranslation } from 'react-i18next';
3
+ import Alert from '@mui/material/Alert';
4
+ import Box from '@mui/material/Box';
5
+ import Button from '@mui/material/Button';
6
+ import CircularProgress from '@mui/material/CircularProgress';
7
+ import TextField from '@mui/material/TextField';
8
+ import Typography from '@mui/material/Typography';
9
+ import PersonIcon from '@mui/icons-material/Person';
10
+ import { updateUserProfile } from '../../auth/authApi';
11
+
12
+ export function CompleteNameStep({ onComplete }) {
13
+ const { t } = useTranslation();
14
+ const [firstName, setFirstName] = useState('');
15
+ const [lastName, setLastName] = useState('');
16
+ const [loading, setLoading] = useState(false);
17
+ const [error, setError] = useState('');
18
+
19
+ const submit = async (event) => {
20
+ event.preventDefault();
21
+ if (!firstName.trim() || !lastName.trim()) return;
22
+ setLoading(true);
23
+ setError('');
24
+ try {
25
+ await updateUserProfile({ first_name: firstName.trim(), last_name: lastName.trim() });
26
+ onComplete();
27
+ } catch {
28
+ setError(t('Onboarding.COMPLETE_NAME_ERROR'));
29
+ } finally {
30
+ setLoading(false);
31
+ }
32
+ };
33
+
34
+ return (
35
+ <Box component="form" onSubmit={submit} sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
36
+ <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}><PersonIcon color="primary" /><Typography variant="h6">{t('Onboarding.COMPLETE_NAME_TITLE')}</Typography></Box>
37
+ <Typography variant="body2" color="text.secondary">{t('Onboarding.COMPLETE_NAME_BODY')}</Typography>
38
+ {error && <Alert severity="error" onClose={() => setError('')}>{error}</Alert>}
39
+ <TextField label={t('Onboarding.FIRST_NAME_LABEL')} value={firstName} onChange={(event) => setFirstName(event.target.value)} required fullWidth size="small" />
40
+ <TextField label={t('Onboarding.LAST_NAME_LABEL')} value={lastName} onChange={(event) => setLastName(event.target.value)} required fullWidth size="small" />
41
+ <Box sx={{ display: 'flex', justifyContent: 'flex-end' }}><Button variant="contained" type="submit" disabled={loading || !firstName.trim() || !lastName.trim()} startIcon={loading ? <CircularProgress size={16} /> : undefined}>{t('Onboarding.SAVE')}</Button></Box>
42
+ </Box>
43
+ );
44
+ }
45
+
46
+ export default CompleteNameStep;