@micha.bigler/ui-core-micha 2.7.0 → 2.8.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.
- package/dist/i18n/onboardingTranslations.js +7 -2
- package/dist/notifications/NotificationSettings.js +1 -2
- package/dist/onboarding/OnboardingProvider.js +27 -3
- package/dist/onboarding/steps/BrowserPushStep.js +40 -3
- package/package.json +1 -1
- package/src/i18n/onboardingTranslations.ts +7 -2
- package/src/notifications/NotificationSettings.jsx +13 -9
- package/src/onboarding/OnboardingProvider.jsx +27 -3
- package/src/onboarding/steps/BrowserPushStep.jsx +62 -6
- package/tests/BrowserPushStep.test.jsx +110 -0
- package/tests/NotificationSettings.test.jsx +2 -2
- package/tests/OnboardingProvider.test.jsx +56 -0
- package/tests/onboardingDescriptors.test.js +44 -0
|
@@ -14,7 +14,12 @@ export const onboardingTranslations = {
|
|
|
14
14
|
'Onboarding.LAST_NAME_LABEL': { de: 'Nachname', fr: 'Nom', en: 'Last name', sw: 'Jina la mwisho' },
|
|
15
15
|
'Onboarding.SAVE': { de: 'Speichern', fr: 'Enregistrer', en: 'Save', sw: 'Hifadhi' },
|
|
16
16
|
'Onboarding.COMPLETE_NAME_ERROR': { de: 'Der Name konnte nicht gespeichert werden. Bitte versuche es erneut.', fr: 'Impossible d’enregistrer votre nom. Veuillez réessayer.', en: 'Your name could not be saved. Please try again.', sw: 'Jina lako halikuweza kuhifadhiwa. Tafadhali jaribu tena.' },
|
|
17
|
-
'Onboarding.
|
|
18
|
-
'Onboarding.
|
|
17
|
+
'Onboarding.NOTIFICATIONS_TITLE': { de: 'Benachrichtigungen', fr: 'Notifications', en: 'Notifications', sw: 'Arifa' },
|
|
18
|
+
'Onboarding.NOTIFICATIONS_BODY': { de: 'Wähle aus, wie du über wichtige Neuigkeiten informiert werden möchtest.', fr: 'Choisissez comment vous souhaitez être informé des informations importantes.', en: 'Choose how you would like to be notified about important updates.', sw: 'Chagua jinsi unavyotaka kuarifiwa kuhusu taarifa muhimu.' },
|
|
19
|
+
'Onboarding.EMAIL_NOTIFICATIONS_LABEL': { de: 'E-Mail-Benachrichtigungen', fr: 'Notifications par e-mail', en: 'Email notifications', sw: 'Arifa za barua pepe' },
|
|
20
|
+
'Onboarding.EMAIL_NOTIFICATIONS_HINT': { de: 'Erhalte wichtige Informationen per E-Mail.', fr: 'Recevez les informations importantes par e-mail.', en: 'Receive important information by email.', sw: 'Pokea taarifa muhimu kwa barua pepe.' },
|
|
21
|
+
'Onboarding.PUSH_NOTIFICATIONS_LABEL': { de: 'Push-Benachrichtigungen', fr: 'Notifications push', en: 'Push notifications', sw: 'Arifa za push' },
|
|
22
|
+
'Onboarding.PUSH_NOTIFICATIONS_HINT': { de: 'Aktiviere Benachrichtigungen für dieses Gerät.', fr: 'Activez les notifications pour cet appareil.', en: 'Enable notifications for this device.', sw: 'Washa arifa kwa kifaa hiki.' },
|
|
23
|
+
'Onboarding.PUSH_ENABLED': { de: 'Push-Benachrichtigungen sind auf diesem Gerät aktiviert.', fr: 'Les notifications push sont activées sur cet appareil.', en: 'Push notifications are enabled on this device.', sw: 'Arifa za push zimewashwa kwenye kifaa hiki.' },
|
|
19
24
|
'Onboarding.SKIP': { de: 'Überspringen', fr: 'Passer', en: 'Skip', sw: 'Ruka' },
|
|
20
25
|
};
|
|
@@ -3,7 +3,6 @@ import React, { useEffect, useState } from 'react';
|
|
|
3
3
|
import { useTranslation } from 'react-i18next';
|
|
4
4
|
import Alert from '@mui/material/Alert';
|
|
5
5
|
import Box from '@mui/material/Box';
|
|
6
|
-
import Button from '@mui/material/Button';
|
|
7
6
|
import CircularProgress from '@mui/material/CircularProgress';
|
|
8
7
|
import Divider from '@mui/material/Divider';
|
|
9
8
|
import FormControlLabel from '@mui/material/FormControlLabel';
|
|
@@ -143,6 +142,6 @@ export function NotificationSettings() {
|
|
|
143
142
|
if (loading) {
|
|
144
143
|
return _jsx(Box, { sx: { display: 'flex', justifyContent: 'center', py: 4 }, children: _jsx(CircularProgress, {}) });
|
|
145
144
|
}
|
|
146
|
-
return (_jsxs(Box, { sx: { maxWidth: 520 }, children: [_jsxs(Typography, { variant: "h6", gutterBottom: true, sx: { display: 'flex', alignItems: 'center', gap: 1 }, children: [_jsx(NotificationsIcon, {}), t('NotificationSettings.TITLE')] }), _jsx(Typography, { variant: "body2", color: "text.secondary", gutterBottom: true, children: t('NotificationSettings.SUBTITLE') }), error && _jsx(Alert, { severity: "error", sx: { mb: 2 }, onClose: () => setError(''), children: error }), conflict && _jsx(Alert, { severity: "warning", sx: { mb: 2 }, onClose: () => setConflict(''), children: conflict }), _jsx(Box, { sx: { py: 2 }, children: _jsx(FormControlLabel, { control: _jsx(Switch, { checked: Boolean(preferences === null || preferences === void 0 ? void 0 : preferences.email_opt_in), onChange: handleEmailToggle, disabled: savingEmail }), label: _jsxs(Box, { children: [_jsx(Typography, { variant: "body1", children: t('NotificationSettings.EMAIL_LABEL') }), _jsx(Typography, { variant: "caption", color: "text.secondary", children: t('NotificationSettings.EMAIL_HINT') })] }), labelPlacement: "end", sx: { alignItems: 'flex-start', ml: 0 } }) }), _jsx(Divider, {}), _jsxs(Box, { sx: { py: 2 }, children: [
|
|
145
|
+
return (_jsxs(Box, { sx: { maxWidth: 520 }, children: [_jsxs(Typography, { variant: "h6", gutterBottom: true, sx: { display: 'flex', alignItems: 'center', gap: 1 }, children: [_jsx(NotificationsIcon, {}), t('NotificationSettings.TITLE')] }), _jsx(Typography, { variant: "body2", color: "text.secondary", gutterBottom: true, children: t('NotificationSettings.SUBTITLE') }), error && _jsx(Alert, { severity: "error", sx: { mb: 2 }, onClose: () => setError(''), children: error }), conflict && _jsx(Alert, { severity: "warning", sx: { mb: 2 }, onClose: () => setConflict(''), children: conflict }), _jsx(Box, { sx: { py: 2 }, children: _jsx(FormControlLabel, { control: _jsx(Switch, { checked: Boolean(preferences === null || preferences === void 0 ? void 0 : preferences.email_opt_in), onChange: handleEmailToggle, disabled: savingEmail }), label: _jsxs(Box, { children: [_jsx(Typography, { variant: "body1", children: t('NotificationSettings.EMAIL_LABEL') }), _jsx(Typography, { variant: "caption", color: "text.secondary", children: t('NotificationSettings.EMAIL_HINT') })] }), labelPlacement: "end", sx: { alignItems: 'flex-start', ml: 0 } }) }), _jsx(Divider, {}), _jsxs(Box, { sx: { py: 2 }, children: [iosNeedsInstall && _jsx(Alert, { severity: "info", sx: { mb: 1.5 }, children: t('NotificationSettings.IOS_HINT') }), !pushSupported && !iosNeedsInstall && _jsx(Alert, { severity: "warning", sx: { mb: 1.5 }, children: t('NotificationSettings.PUSH_NOT_SUPPORTED') }), _jsx(FormControlLabel, { control: (_jsx(Switch, { checked: pushSubscribed, onChange: (event) => (event.target.checked ? handleEnablePush() : handleDisablePush()), disabled: savingPush || !pushSupported || iosNeedsInstall })), label: _jsxs(Box, { children: [_jsx(Typography, { variant: "body1", children: t('NotificationSettings.PUSH_LABEL') }), _jsx(Typography, { variant: "caption", color: "text.secondary", children: t('NotificationSettings.PUSH_HINT') })] }), labelPlacement: "end", sx: { alignItems: 'flex-start', ml: 0 } })] })] }));
|
|
147
146
|
}
|
|
148
147
|
export default NotificationSettings;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState, } from 'react';
|
|
3
3
|
import { AuthContext } from '../auth/AuthContext';
|
|
4
|
+
import { getNotificationPreferences } from '../notifications/api';
|
|
4
5
|
import { getOnboardingStepConfig } from './api';
|
|
5
6
|
import { selectActiveSteps } from './stepSelection';
|
|
6
7
|
import CookieConsentStep from './steps/CookieConsentStep';
|
|
@@ -27,11 +28,15 @@ export const UNIVERSAL_STEP_DESCRIPTORS = [
|
|
|
27
28
|
},
|
|
28
29
|
{
|
|
29
30
|
id: 'browser_push',
|
|
30
|
-
condition: (ctx) => {
|
|
31
|
+
condition: (ctx) => {
|
|
32
|
+
var _a, _b;
|
|
33
|
+
return Boolean((((_a = ctx.pushState) === null || _a === void 0 ? void 0 : _a.supported) && (!ctx.pushState.subscribed || !ctx.emailOptedIn))
|
|
34
|
+
|| (!((_b = ctx.pushState) === null || _b === void 0 ? void 0 : _b.supported) && !ctx.emailOptedIn));
|
|
35
|
+
},
|
|
31
36
|
blocking: false,
|
|
32
37
|
skipable: true,
|
|
33
38
|
persistDismissed: true,
|
|
34
|
-
titleKey: 'Onboarding.
|
|
39
|
+
titleKey: 'Onboarding.NOTIFICATIONS_TITLE',
|
|
35
40
|
Component: BrowserPushStep,
|
|
36
41
|
},
|
|
37
42
|
];
|
|
@@ -61,6 +66,7 @@ export function OnboardingProvider({ children, extraSteps = [] }) {
|
|
|
61
66
|
const [configMap, setConfigMap] = useState({});
|
|
62
67
|
const [configLoaded, setConfigLoaded] = useState(false);
|
|
63
68
|
const [pushState, setPushState] = useState(getInitialPushState);
|
|
69
|
+
const [emailOptedIn, setEmailOptedIn] = useState(false);
|
|
64
70
|
const [dismissedSet, setDismissedSet] = useState(loadDismissedSteps);
|
|
65
71
|
const descriptors = useMemo(() => [...UNIVERSAL_STEP_DESCRIPTORS, ...extraSteps], [extraSteps]);
|
|
66
72
|
useEffect(() => {
|
|
@@ -107,6 +113,24 @@ export function OnboardingProvider({ children, extraSteps = [] }) {
|
|
|
107
113
|
});
|
|
108
114
|
return () => { cancelled = true; };
|
|
109
115
|
}, []);
|
|
116
|
+
useEffect(() => {
|
|
117
|
+
let cancelled = false;
|
|
118
|
+
if (!user) {
|
|
119
|
+
setEmailOptedIn(false);
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
setEmailOptedIn(false);
|
|
123
|
+
getNotificationPreferences()
|
|
124
|
+
.then((data) => {
|
|
125
|
+
if (!cancelled)
|
|
126
|
+
setEmailOptedIn(Boolean(data === null || data === void 0 ? void 0 : data.email_opt_in));
|
|
127
|
+
})
|
|
128
|
+
.catch(() => {
|
|
129
|
+
if (!cancelled)
|
|
130
|
+
setEmailOptedIn(false);
|
|
131
|
+
});
|
|
132
|
+
return () => { cancelled = true; };
|
|
133
|
+
}, [user]);
|
|
110
134
|
const dismissStep = useCallback((id) => {
|
|
111
135
|
setDismissedSet((previous) => {
|
|
112
136
|
const next = new Set(previous);
|
|
@@ -122,7 +146,7 @@ export function OnboardingProvider({ children, extraSteps = [] }) {
|
|
|
122
146
|
return next;
|
|
123
147
|
});
|
|
124
148
|
}, []);
|
|
125
|
-
const ctx = useMemo(() => ({ user, pushState }), [user, pushState]);
|
|
149
|
+
const ctx = useMemo(() => ({ user, pushState, emailOptedIn }), [user, pushState, emailOptedIn]);
|
|
126
150
|
const activeSteps = useMemo(() => {
|
|
127
151
|
if (!configLoaded || !user)
|
|
128
152
|
return [];
|
|
@@ -1,22 +1,58 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import React, { useState } from 'react';
|
|
2
|
+
import React, { useEffect, useState } from 'react';
|
|
3
3
|
import { useTranslation } from 'react-i18next';
|
|
4
4
|
import Alert from '@mui/material/Alert';
|
|
5
5
|
import Box from '@mui/material/Box';
|
|
6
6
|
import Button from '@mui/material/Button';
|
|
7
7
|
import CircularProgress from '@mui/material/CircularProgress';
|
|
8
|
+
import FormControlLabel from '@mui/material/FormControlLabel';
|
|
9
|
+
import Switch from '@mui/material/Switch';
|
|
8
10
|
import Typography from '@mui/material/Typography';
|
|
11
|
+
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
|
9
12
|
import NotificationsIcon from '@mui/icons-material/Notifications';
|
|
10
13
|
import { getVapidPublicKey, patchNotificationPreferences, savePushSubscription, urlBase64ToUint8Array, } from '../../notifications/api';
|
|
11
|
-
export function BrowserPushStep({ onComplete, onDismiss }) {
|
|
14
|
+
export function BrowserPushStep({ onComplete, onDismiss, ctx }) {
|
|
15
|
+
var _a, _b, _c;
|
|
12
16
|
const { t } = useTranslation();
|
|
13
17
|
const [loading, setLoading] = useState(false);
|
|
18
|
+
const [savingEmail, setSavingEmail] = useState(false);
|
|
19
|
+
const [emailOptedIn, setEmailOptedIn] = useState(Boolean(ctx === null || ctx === void 0 ? void 0 : ctx.emailOptedIn));
|
|
20
|
+
const [pushSubscribed, setPushSubscribed] = useState(Boolean((_a = ctx === null || ctx === void 0 ? void 0 : ctx.pushState) === null || _a === void 0 ? void 0 : _a.subscribed));
|
|
14
21
|
const [error, setError] = useState('');
|
|
22
|
+
const pushSupported = Boolean((_b = ctx === null || ctx === void 0 ? void 0 : ctx.pushState) === null || _b === void 0 ? void 0 : _b.supported);
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
setEmailOptedIn(Boolean(ctx === null || ctx === void 0 ? void 0 : ctx.emailOptedIn));
|
|
25
|
+
}, [ctx === null || ctx === void 0 ? void 0 : ctx.emailOptedIn]);
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
var _a;
|
|
28
|
+
setPushSubscribed(Boolean((_a = ctx === null || ctx === void 0 ? void 0 : ctx.pushState) === null || _a === void 0 ? void 0 : _a.subscribed));
|
|
29
|
+
}, [(_c = ctx === null || ctx === void 0 ? void 0 : ctx.pushState) === null || _c === void 0 ? void 0 : _c.subscribed]);
|
|
30
|
+
const handleEmailToggle = async (event) => {
|
|
31
|
+
const nextEmailOptedIn = event.target.checked;
|
|
32
|
+
const previousEmailOptedIn = emailOptedIn;
|
|
33
|
+
setSavingEmail(true);
|
|
34
|
+
setError('');
|
|
35
|
+
setEmailOptedIn(nextEmailOptedIn);
|
|
36
|
+
try {
|
|
37
|
+
await patchNotificationPreferences({ email_opt_in: nextEmailOptedIn });
|
|
38
|
+
}
|
|
39
|
+
catch (_a) {
|
|
40
|
+
setEmailOptedIn(previousEmailOptedIn);
|
|
41
|
+
setError(t('NotificationSettings.SAVE_ERROR'));
|
|
42
|
+
}
|
|
43
|
+
finally {
|
|
44
|
+
setSavingEmail(false);
|
|
45
|
+
}
|
|
46
|
+
};
|
|
15
47
|
const enable = async () => {
|
|
16
48
|
var _a;
|
|
17
49
|
setLoading(true);
|
|
18
50
|
setError('');
|
|
19
51
|
try {
|
|
52
|
+
if (typeof Notification === 'undefined') {
|
|
53
|
+
setError(t('NotificationSettings.PUSH_ERROR'));
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
20
56
|
const permission = await Notification.requestPermission();
|
|
21
57
|
if (permission !== 'granted') {
|
|
22
58
|
setError(t('NotificationSettings.PUSH_DENIED'));
|
|
@@ -30,6 +66,7 @@ export function BrowserPushStep({ onComplete, onDismiss }) {
|
|
|
30
66
|
});
|
|
31
67
|
await savePushSubscription(subscription.toJSON(), navigator.userAgent);
|
|
32
68
|
await patchNotificationPreferences({ push_opt_in: true });
|
|
69
|
+
setPushSubscribed(true);
|
|
33
70
|
onComplete();
|
|
34
71
|
}
|
|
35
72
|
catch (requestError) {
|
|
@@ -44,6 +81,6 @@ export function BrowserPushStep({ onComplete, onDismiss }) {
|
|
|
44
81
|
setLoading(false);
|
|
45
82
|
}
|
|
46
83
|
};
|
|
47
|
-
return (_jsxs(Box, { sx: { display: 'flex', flexDirection: 'column', gap: 2 }, children: [_jsxs(Box, { sx: { display: 'flex', alignItems: 'center', gap: 1 }, children: [_jsx(NotificationsIcon, { color: "primary" }), _jsx(Typography, { variant: "h6", children: t('Onboarding.
|
|
84
|
+
return (_jsxs(Box, { sx: { display: 'flex', flexDirection: 'column', gap: 2 }, children: [_jsxs(Box, { sx: { display: 'flex', alignItems: 'center', gap: 1 }, children: [_jsx(NotificationsIcon, { color: "primary" }), _jsx(Typography, { variant: "h6", children: t('Onboarding.NOTIFICATIONS_TITLE') })] }), _jsx(Typography, { variant: "body2", color: "text.secondary", children: t('Onboarding.NOTIFICATIONS_BODY') }), error && _jsx(Alert, { severity: "warning", onClose: () => setError(''), children: error }), _jsx(FormControlLabel, { control: _jsx(Switch, { checked: emailOptedIn, onChange: handleEmailToggle, disabled: savingEmail }), label: _jsxs(Box, { children: [_jsx(Typography, { variant: "body1", children: t('Onboarding.EMAIL_NOTIFICATIONS_LABEL') }), _jsx(Typography, { variant: "caption", color: "text.secondary", children: t('Onboarding.EMAIL_NOTIFICATIONS_HINT') })] }), labelPlacement: "end", sx: { alignItems: 'flex-start', ml: 0 } }), pushSupported && (_jsxs(Box, { children: [_jsx(Typography, { variant: "body1", gutterBottom: true, children: t('Onboarding.PUSH_NOTIFICATIONS_LABEL') }), _jsx(Typography, { variant: "caption", color: "text.secondary", display: "block", sx: { mb: 1.5 }, children: t('Onboarding.PUSH_NOTIFICATIONS_HINT') }), pushSubscribed ? (_jsxs(Box, { sx: { display: 'flex', alignItems: 'center', gap: 1 }, children: [_jsx(CheckCircleIcon, { color: "success", fontSize: "small" }), _jsx(Typography, { variant: "body2", children: t('Onboarding.PUSH_ENABLED') })] })) : (_jsx(Button, { variant: "contained", onClick: enable, disabled: loading, startIcon: loading ? _jsx(CircularProgress, { size: 16 }) : undefined, children: t('NotificationSettings.PUSH_ENABLE') }))] })), _jsxs(Box, { sx: { display: 'flex', justifyContent: 'flex-end', gap: 1 }, children: [_jsx(Button, { variant: "text", onClick: onDismiss, disabled: loading || savingEmail, children: t('Onboarding.SKIP') }), _jsx(Button, { variant: "contained", onClick: onComplete, disabled: loading || savingEmail, children: t('Onboarding.CONTINUE') })] })] }));
|
|
48
85
|
}
|
|
49
86
|
export default BrowserPushStep;
|
package/package.json
CHANGED
|
@@ -14,7 +14,12 @@ export const onboardingTranslations = {
|
|
|
14
14
|
'Onboarding.LAST_NAME_LABEL': { de: 'Nachname', fr: 'Nom', en: 'Last name', sw: 'Jina la mwisho' },
|
|
15
15
|
'Onboarding.SAVE': { de: 'Speichern', fr: 'Enregistrer', en: 'Save', sw: 'Hifadhi' },
|
|
16
16
|
'Onboarding.COMPLETE_NAME_ERROR': { de: 'Der Name konnte nicht gespeichert werden. Bitte versuche es erneut.', fr: 'Impossible d’enregistrer votre nom. Veuillez réessayer.', en: 'Your name could not be saved. Please try again.', sw: 'Jina lako halikuweza kuhifadhiwa. Tafadhali jaribu tena.' },
|
|
17
|
-
'Onboarding.
|
|
18
|
-
'Onboarding.
|
|
17
|
+
'Onboarding.NOTIFICATIONS_TITLE': { de: 'Benachrichtigungen', fr: 'Notifications', en: 'Notifications', sw: 'Arifa' },
|
|
18
|
+
'Onboarding.NOTIFICATIONS_BODY': { de: 'Wähle aus, wie du über wichtige Neuigkeiten informiert werden möchtest.', fr: 'Choisissez comment vous souhaitez être informé des informations importantes.', en: 'Choose how you would like to be notified about important updates.', sw: 'Chagua jinsi unavyotaka kuarifiwa kuhusu taarifa muhimu.' },
|
|
19
|
+
'Onboarding.EMAIL_NOTIFICATIONS_LABEL': { de: 'E-Mail-Benachrichtigungen', fr: 'Notifications par e-mail', en: 'Email notifications', sw: 'Arifa za barua pepe' },
|
|
20
|
+
'Onboarding.EMAIL_NOTIFICATIONS_HINT': { de: 'Erhalte wichtige Informationen per E-Mail.', fr: 'Recevez les informations importantes par e-mail.', en: 'Receive important information by email.', sw: 'Pokea taarifa muhimu kwa barua pepe.' },
|
|
21
|
+
'Onboarding.PUSH_NOTIFICATIONS_LABEL': { de: 'Push-Benachrichtigungen', fr: 'Notifications push', en: 'Push notifications', sw: 'Arifa za push' },
|
|
22
|
+
'Onboarding.PUSH_NOTIFICATIONS_HINT': { de: 'Aktiviere Benachrichtigungen für dieses Gerät.', fr: 'Activez les notifications pour cet appareil.', en: 'Enable notifications for this device.', sw: 'Washa arifa kwa kifaa hiki.' },
|
|
23
|
+
'Onboarding.PUSH_ENABLED': { de: 'Push-Benachrichtigungen sind auf diesem Gerät aktiviert.', fr: 'Les notifications push sont activées sur cet appareil.', en: 'Push notifications are enabled on this device.', sw: 'Arifa za push zimewashwa kwenye kifaa hiki.' },
|
|
19
24
|
'Onboarding.SKIP': { de: 'Überspringen', fr: 'Passer', en: 'Skip', sw: 'Ruka' },
|
|
20
25
|
};
|
|
@@ -2,7 +2,6 @@ import React, { useEffect, useState } from 'react';
|
|
|
2
2
|
import { useTranslation } from 'react-i18next';
|
|
3
3
|
import Alert from '@mui/material/Alert';
|
|
4
4
|
import Box from '@mui/material/Box';
|
|
5
|
-
import Button from '@mui/material/Button';
|
|
6
5
|
import CircularProgress from '@mui/material/CircularProgress';
|
|
7
6
|
import Divider from '@mui/material/Divider';
|
|
8
7
|
import FormControlLabel from '@mui/material/FormControlLabel';
|
|
@@ -172,15 +171,20 @@ export function NotificationSettings() {
|
|
|
172
171
|
<Divider />
|
|
173
172
|
|
|
174
173
|
<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
174
|
{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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
175
|
+
{!pushSupported && !iosNeedsInstall && <Alert severity="warning" sx={{ mb: 1.5 }}>{t('NotificationSettings.PUSH_NOT_SUPPORTED')}</Alert>}
|
|
176
|
+
<FormControlLabel
|
|
177
|
+
control={(
|
|
178
|
+
<Switch
|
|
179
|
+
checked={pushSubscribed}
|
|
180
|
+
onChange={(event) => (event.target.checked ? handleEnablePush() : handleDisablePush())}
|
|
181
|
+
disabled={savingPush || !pushSupported || iosNeedsInstall}
|
|
182
|
+
/>
|
|
183
|
+
)}
|
|
184
|
+
label={<Box><Typography variant="body1">{t('NotificationSettings.PUSH_LABEL')}</Typography><Typography variant="caption" color="text.secondary">{t('NotificationSettings.PUSH_HINT')}</Typography></Box>}
|
|
185
|
+
labelPlacement="end"
|
|
186
|
+
sx={{ alignItems: 'flex-start', ml: 0 }}
|
|
187
|
+
/>
|
|
184
188
|
</Box>
|
|
185
189
|
</Box>
|
|
186
190
|
);
|
|
@@ -7,6 +7,7 @@ import React, {
|
|
|
7
7
|
useState,
|
|
8
8
|
} from 'react';
|
|
9
9
|
import { AuthContext } from '../auth/AuthContext';
|
|
10
|
+
import { getNotificationPreferences } from '../notifications/api';
|
|
10
11
|
import { getOnboardingStepConfig } from './api';
|
|
11
12
|
import { selectActiveSteps } from './stepSelection';
|
|
12
13
|
import CookieConsentStep from './steps/CookieConsentStep';
|
|
@@ -34,11 +35,14 @@ export const UNIVERSAL_STEP_DESCRIPTORS = [
|
|
|
34
35
|
},
|
|
35
36
|
{
|
|
36
37
|
id: 'browser_push',
|
|
37
|
-
condition: (ctx) => Boolean(
|
|
38
|
+
condition: (ctx) => Boolean(
|
|
39
|
+
(ctx.pushState?.supported && (!ctx.pushState.subscribed || !ctx.emailOptedIn))
|
|
40
|
+
|| (!ctx.pushState?.supported && !ctx.emailOptedIn),
|
|
41
|
+
),
|
|
38
42
|
blocking: false,
|
|
39
43
|
skipable: true,
|
|
40
44
|
persistDismissed: true,
|
|
41
|
-
titleKey: 'Onboarding.
|
|
45
|
+
titleKey: 'Onboarding.NOTIFICATIONS_TITLE',
|
|
42
46
|
Component: BrowserPushStep,
|
|
43
47
|
},
|
|
44
48
|
];
|
|
@@ -71,6 +75,7 @@ export function OnboardingProvider({ children, extraSteps = [] }) {
|
|
|
71
75
|
const [configMap, setConfigMap] = useState({});
|
|
72
76
|
const [configLoaded, setConfigLoaded] = useState(false);
|
|
73
77
|
const [pushState, setPushState] = useState(getInitialPushState);
|
|
78
|
+
const [emailOptedIn, setEmailOptedIn] = useState(false);
|
|
74
79
|
const [dismissedSet, setDismissedSet] = useState(loadDismissedSteps);
|
|
75
80
|
|
|
76
81
|
const descriptors = useMemo(
|
|
@@ -123,6 +128,25 @@ export function OnboardingProvider({ children, extraSteps = [] }) {
|
|
|
123
128
|
return () => { cancelled = true; };
|
|
124
129
|
}, []);
|
|
125
130
|
|
|
131
|
+
useEffect(() => {
|
|
132
|
+
let cancelled = false;
|
|
133
|
+
if (!user) {
|
|
134
|
+
setEmailOptedIn(false);
|
|
135
|
+
return undefined;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
setEmailOptedIn(false);
|
|
139
|
+
getNotificationPreferences()
|
|
140
|
+
.then((data) => {
|
|
141
|
+
if (!cancelled) setEmailOptedIn(Boolean(data?.email_opt_in));
|
|
142
|
+
})
|
|
143
|
+
.catch(() => {
|
|
144
|
+
if (!cancelled) setEmailOptedIn(false);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
return () => { cancelled = true; };
|
|
148
|
+
}, [user]);
|
|
149
|
+
|
|
126
150
|
const dismissStep = useCallback((id) => {
|
|
127
151
|
setDismissedSet((previous) => {
|
|
128
152
|
const next = new Set(previous);
|
|
@@ -138,7 +162,7 @@ export function OnboardingProvider({ children, extraSteps = [] }) {
|
|
|
138
162
|
});
|
|
139
163
|
}, []);
|
|
140
164
|
|
|
141
|
-
const ctx = useMemo(() => ({ user, pushState }), [user, pushState]);
|
|
165
|
+
const ctx = useMemo(() => ({ user, pushState, emailOptedIn }), [user, pushState, emailOptedIn]);
|
|
142
166
|
const activeSteps = useMemo(() => {
|
|
143
167
|
if (!configLoaded || !user) return [];
|
|
144
168
|
return selectActiveSteps(descriptors, configMap, ctx, dismissedSet);
|
|
@@ -1,10 +1,13 @@
|
|
|
1
|
-
import React, { useState } from 'react';
|
|
1
|
+
import React, { useEffect, useState } from 'react';
|
|
2
2
|
import { useTranslation } from 'react-i18next';
|
|
3
3
|
import Alert from '@mui/material/Alert';
|
|
4
4
|
import Box from '@mui/material/Box';
|
|
5
5
|
import Button from '@mui/material/Button';
|
|
6
6
|
import CircularProgress from '@mui/material/CircularProgress';
|
|
7
|
+
import FormControlLabel from '@mui/material/FormControlLabel';
|
|
8
|
+
import Switch from '@mui/material/Switch';
|
|
7
9
|
import Typography from '@mui/material/Typography';
|
|
10
|
+
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
|
8
11
|
import NotificationsIcon from '@mui/icons-material/Notifications';
|
|
9
12
|
import {
|
|
10
13
|
getVapidPublicKey,
|
|
@@ -13,15 +16,47 @@ import {
|
|
|
13
16
|
urlBase64ToUint8Array,
|
|
14
17
|
} from '../../notifications/api';
|
|
15
18
|
|
|
16
|
-
export function BrowserPushStep({ onComplete, onDismiss }) {
|
|
19
|
+
export function BrowserPushStep({ onComplete, onDismiss, ctx }) {
|
|
17
20
|
const { t } = useTranslation();
|
|
18
21
|
const [loading, setLoading] = useState(false);
|
|
22
|
+
const [savingEmail, setSavingEmail] = useState(false);
|
|
23
|
+
const [emailOptedIn, setEmailOptedIn] = useState(Boolean(ctx?.emailOptedIn));
|
|
24
|
+
const [pushSubscribed, setPushSubscribed] = useState(Boolean(ctx?.pushState?.subscribed));
|
|
19
25
|
const [error, setError] = useState('');
|
|
26
|
+
const pushSupported = Boolean(ctx?.pushState?.supported);
|
|
27
|
+
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
setEmailOptedIn(Boolean(ctx?.emailOptedIn));
|
|
30
|
+
}, [ctx?.emailOptedIn]);
|
|
31
|
+
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
setPushSubscribed(Boolean(ctx?.pushState?.subscribed));
|
|
34
|
+
}, [ctx?.pushState?.subscribed]);
|
|
35
|
+
|
|
36
|
+
const handleEmailToggle = async (event) => {
|
|
37
|
+
const nextEmailOptedIn = event.target.checked;
|
|
38
|
+
const previousEmailOptedIn = emailOptedIn;
|
|
39
|
+
setSavingEmail(true);
|
|
40
|
+
setError('');
|
|
41
|
+
setEmailOptedIn(nextEmailOptedIn);
|
|
42
|
+
try {
|
|
43
|
+
await patchNotificationPreferences({ email_opt_in: nextEmailOptedIn });
|
|
44
|
+
} catch {
|
|
45
|
+
setEmailOptedIn(previousEmailOptedIn);
|
|
46
|
+
setError(t('NotificationSettings.SAVE_ERROR'));
|
|
47
|
+
} finally {
|
|
48
|
+
setSavingEmail(false);
|
|
49
|
+
}
|
|
50
|
+
};
|
|
20
51
|
|
|
21
52
|
const enable = async () => {
|
|
22
53
|
setLoading(true);
|
|
23
54
|
setError('');
|
|
24
55
|
try {
|
|
56
|
+
if (typeof Notification === 'undefined') {
|
|
57
|
+
setError(t('NotificationSettings.PUSH_ERROR'));
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
25
60
|
const permission = await Notification.requestPermission();
|
|
26
61
|
if (permission !== 'granted') {
|
|
27
62
|
setError(t('NotificationSettings.PUSH_DENIED'));
|
|
@@ -35,6 +70,7 @@ export function BrowserPushStep({ onComplete, onDismiss }) {
|
|
|
35
70
|
});
|
|
36
71
|
await savePushSubscription(subscription.toJSON(), navigator.userAgent);
|
|
37
72
|
await patchNotificationPreferences({ push_opt_in: true });
|
|
73
|
+
setPushSubscribed(true);
|
|
38
74
|
onComplete();
|
|
39
75
|
} catch (requestError) {
|
|
40
76
|
if (requestError?.response?.status === 409) {
|
|
@@ -49,12 +85,32 @@ export function BrowserPushStep({ onComplete, onDismiss }) {
|
|
|
49
85
|
|
|
50
86
|
return (
|
|
51
87
|
<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.
|
|
53
|
-
<Typography variant="body2" color="text.secondary">{t('Onboarding.
|
|
88
|
+
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}><NotificationsIcon color="primary" /><Typography variant="h6">{t('Onboarding.NOTIFICATIONS_TITLE')}</Typography></Box>
|
|
89
|
+
<Typography variant="body2" color="text.secondary">{t('Onboarding.NOTIFICATIONS_BODY')}</Typography>
|
|
54
90
|
{error && <Alert severity="warning" onClose={() => setError('')}>{error}</Alert>}
|
|
91
|
+
<FormControlLabel
|
|
92
|
+
control={<Switch checked={emailOptedIn} onChange={handleEmailToggle} disabled={savingEmail} />}
|
|
93
|
+
label={<Box><Typography variant="body1">{t('Onboarding.EMAIL_NOTIFICATIONS_LABEL')}</Typography><Typography variant="caption" color="text.secondary">{t('Onboarding.EMAIL_NOTIFICATIONS_HINT')}</Typography></Box>}
|
|
94
|
+
labelPlacement="end"
|
|
95
|
+
sx={{ alignItems: 'flex-start', ml: 0 }}
|
|
96
|
+
/>
|
|
97
|
+
{pushSupported && (
|
|
98
|
+
<Box>
|
|
99
|
+
<Typography variant="body1" gutterBottom>{t('Onboarding.PUSH_NOTIFICATIONS_LABEL')}</Typography>
|
|
100
|
+
<Typography variant="caption" color="text.secondary" display="block" sx={{ mb: 1.5 }}>{t('Onboarding.PUSH_NOTIFICATIONS_HINT')}</Typography>
|
|
101
|
+
{pushSubscribed ? (
|
|
102
|
+
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
103
|
+
<CheckCircleIcon color="success" fontSize="small" />
|
|
104
|
+
<Typography variant="body2">{t('Onboarding.PUSH_ENABLED')}</Typography>
|
|
105
|
+
</Box>
|
|
106
|
+
) : (
|
|
107
|
+
<Button variant="contained" onClick={enable} disabled={loading} startIcon={loading ? <CircularProgress size={16} /> : undefined}>{t('NotificationSettings.PUSH_ENABLE')}</Button>
|
|
108
|
+
)}
|
|
109
|
+
</Box>
|
|
110
|
+
)}
|
|
55
111
|
<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={
|
|
112
|
+
<Button variant="text" onClick={onDismiss} disabled={loading || savingEmail}>{t('Onboarding.SKIP')}</Button>
|
|
113
|
+
<Button variant="contained" onClick={onComplete} disabled={loading || savingEmail}>{t('Onboarding.CONTINUE')}</Button>
|
|
58
114
|
</Box>
|
|
59
115
|
</Box>
|
|
60
116
|
);
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
5
|
+
|
|
6
|
+
const notificationsApi = vi.hoisted(() => ({
|
|
7
|
+
getVapidPublicKey: vi.fn(),
|
|
8
|
+
patchNotificationPreferences: vi.fn(),
|
|
9
|
+
savePushSubscription: vi.fn(),
|
|
10
|
+
urlBase64ToUint8Array: vi.fn(() => new Uint8Array([1, 2, 3])),
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
vi.mock('../src/notifications/api', () => notificationsApi);
|
|
14
|
+
vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key) => key }) }));
|
|
15
|
+
|
|
16
|
+
import { BrowserPushStep } from '../src/onboarding/steps/BrowserPushStep';
|
|
17
|
+
|
|
18
|
+
function renderStep(ctx, props = {}) {
|
|
19
|
+
return render(
|
|
20
|
+
<BrowserPushStep
|
|
21
|
+
ctx={ctx}
|
|
22
|
+
onComplete={vi.fn()}
|
|
23
|
+
onDismiss={vi.fn()}
|
|
24
|
+
{...props}
|
|
25
|
+
/>,
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function installPushEnvironment(subscription) {
|
|
30
|
+
const registration = {
|
|
31
|
+
pushManager: {
|
|
32
|
+
subscribe: vi.fn(),
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
vi.stubGlobal('navigator', {
|
|
36
|
+
serviceWorker: { ready: Promise.resolve(registration) },
|
|
37
|
+
userAgent: 'Unit Test Browser',
|
|
38
|
+
});
|
|
39
|
+
Object.defineProperty(window, 'PushManager', { value: class PushManager {}, configurable: true });
|
|
40
|
+
return registration;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe('BrowserPushStep', () => {
|
|
44
|
+
beforeEach(() => {
|
|
45
|
+
vi.clearAllMocks();
|
|
46
|
+
notificationsApi.patchNotificationPreferences.mockResolvedValue({});
|
|
47
|
+
notificationsApi.getVapidPublicKey.mockResolvedValue('AQID');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
afterEach(() => {
|
|
51
|
+
cleanup();
|
|
52
|
+
vi.unstubAllGlobals();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it.each([
|
|
56
|
+
[false, true],
|
|
57
|
+
[true, false],
|
|
58
|
+
])('saves the selected email notification preference', async (emailOptedIn, expectedEmailOptIn) => {
|
|
59
|
+
renderStep({ emailOptedIn, pushState: { supported: false, subscribed: false } });
|
|
60
|
+
|
|
61
|
+
fireEvent.click(screen.getByRole('switch', { name: /Onboarding.EMAIL_NOTIFICATIONS_LABEL/ }));
|
|
62
|
+
|
|
63
|
+
await waitFor(() => expect(notificationsApi.patchNotificationPreferences).toHaveBeenCalledWith({ email_opt_in: expectedEmailOptIn }));
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('reverts the email toggle when saving the preference fails', async () => {
|
|
67
|
+
notificationsApi.patchNotificationPreferences.mockRejectedValue(new Error('unavailable'));
|
|
68
|
+
renderStep({ emailOptedIn: false, pushState: { supported: false, subscribed: false } });
|
|
69
|
+
|
|
70
|
+
const toggle = screen.getByRole('switch', { name: /Onboarding.EMAIL_NOTIFICATIONS_LABEL/ });
|
|
71
|
+
fireEvent.click(toggle);
|
|
72
|
+
|
|
73
|
+
await waitFor(() => expect(toggle.checked).toBe(false));
|
|
74
|
+
expect(screen.getByRole('alert').textContent).toBe('NotificationSettings.SAVE_ERROR');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('subscribes to push and opts in after saving the subscription', async () => {
|
|
78
|
+
const registration = installPushEnvironment();
|
|
79
|
+
const subscription = {
|
|
80
|
+
toJSON: vi.fn(() => ({ endpoint: 'https://fcm.googleapis.com/fcm/send/example' })),
|
|
81
|
+
};
|
|
82
|
+
const onComplete = vi.fn();
|
|
83
|
+
registration.pushManager.subscribe.mockResolvedValue(subscription);
|
|
84
|
+
vi.stubGlobal('Notification', { requestPermission: vi.fn().mockResolvedValue('granted') });
|
|
85
|
+
|
|
86
|
+
renderStep({ emailOptedIn: false, pushState: { supported: true, subscribed: false } }, { onComplete });
|
|
87
|
+
fireEvent.click(screen.getByRole('button', { name: 'NotificationSettings.PUSH_ENABLE' }));
|
|
88
|
+
|
|
89
|
+
await waitFor(() => expect(notificationsApi.patchNotificationPreferences).toHaveBeenCalledWith({ push_opt_in: true }));
|
|
90
|
+
expect(Notification.requestPermission).toHaveBeenCalledOnce();
|
|
91
|
+
expect(registration.pushManager.subscribe).toHaveBeenCalledWith({
|
|
92
|
+
userVisibleOnly: true,
|
|
93
|
+
applicationServerKey: new Uint8Array([1, 2, 3]),
|
|
94
|
+
});
|
|
95
|
+
expect(notificationsApi.savePushSubscription).toHaveBeenCalledWith(
|
|
96
|
+
{ endpoint: 'https://fcm.googleapis.com/fcm/send/example' },
|
|
97
|
+
'Unit Test Browser',
|
|
98
|
+
);
|
|
99
|
+
expect(notificationsApi.savePushSubscription.mock.invocationCallOrder[0])
|
|
100
|
+
.toBeLessThan(notificationsApi.patchNotificationPreferences.mock.invocationCallOrder[0]);
|
|
101
|
+
expect(onComplete).toHaveBeenCalledOnce();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('shows an enabled state instead of a redundant push enable action', () => {
|
|
105
|
+
renderStep({ emailOptedIn: false, pushState: { supported: true, subscribed: true } });
|
|
106
|
+
|
|
107
|
+
expect(screen.getByText('Onboarding.PUSH_ENABLED')).toBeTruthy();
|
|
108
|
+
expect(screen.queryByRole('button', { name: 'NotificationSettings.PUSH_ENABLE' })).toBeNull();
|
|
109
|
+
});
|
|
110
|
+
});
|
|
@@ -49,7 +49,7 @@ describe('NotificationSettings push toggle', () => {
|
|
|
49
49
|
vi.stubGlobal('Notification', { requestPermission: vi.fn().mockResolvedValue('granted') });
|
|
50
50
|
|
|
51
51
|
render(<NotificationSettings />);
|
|
52
|
-
fireEvent.click(await screen.findByRole('
|
|
52
|
+
fireEvent.click(await screen.findByRole('switch', { name: /PUSH_LABEL/ }));
|
|
53
53
|
|
|
54
54
|
await waitFor(() => expect(notificationsApi.patchNotificationPreferences).toHaveBeenCalledWith({ push_opt_in: true }));
|
|
55
55
|
expect(Notification.requestPermission).toHaveBeenCalledOnce();
|
|
@@ -73,7 +73,7 @@ describe('NotificationSettings push toggle', () => {
|
|
|
73
73
|
installPushEnvironment(subscription);
|
|
74
74
|
|
|
75
75
|
render(<NotificationSettings />);
|
|
76
|
-
fireEvent.click(await screen.findByRole('
|
|
76
|
+
fireEvent.click(await screen.findByRole('switch', { name: /PUSH_LABEL/ }));
|
|
77
77
|
|
|
78
78
|
await waitFor(() => expect(subscription.unsubscribe).toHaveBeenCalledOnce());
|
|
79
79
|
expect(notificationsApi.removePushSubscription).toHaveBeenCalledWith({ endpoint: subscription.endpoint });
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { cleanup, render, screen, waitFor } from '@testing-library/react';
|
|
5
|
+
|
|
6
|
+
const onboardingApi = vi.hoisted(() => ({
|
|
7
|
+
getOnboardingStepConfig: vi.fn(),
|
|
8
|
+
}));
|
|
9
|
+
const notificationsApi = vi.hoisted(() => ({
|
|
10
|
+
getNotificationPreferences: vi.fn(),
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
vi.mock('../src/onboarding/api', () => onboardingApi);
|
|
14
|
+
vi.mock('../src/notifications/api', () => notificationsApi);
|
|
15
|
+
|
|
16
|
+
import { AuthContext } from '../src/auth/AuthContext';
|
|
17
|
+
import { OnboardingProvider, useOnboarding } from '../src/onboarding/OnboardingProvider';
|
|
18
|
+
|
|
19
|
+
function Probe() {
|
|
20
|
+
const onboarding = useOnboarding();
|
|
21
|
+
return <span data-testid="email-opted-in">{String(onboarding?.ctx.emailOptedIn)}</span>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function renderProvider() {
|
|
25
|
+
return render(
|
|
26
|
+
<AuthContext.Provider value={{ user: { id: 1 } }}>
|
|
27
|
+
<OnboardingProvider><Probe /></OnboardingProvider>
|
|
28
|
+
</AuthContext.Provider>,
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe('OnboardingProvider notification preferences', () => {
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
cleanup();
|
|
35
|
+
vi.clearAllMocks();
|
|
36
|
+
onboardingApi.getOnboardingStepConfig.mockResolvedValue([]);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('populates emailOptedIn from notification preferences', async () => {
|
|
40
|
+
notificationsApi.getNotificationPreferences.mockResolvedValue({ email_opt_in: true });
|
|
41
|
+
|
|
42
|
+
renderProvider();
|
|
43
|
+
|
|
44
|
+
await waitFor(() => expect(screen.getByTestId('email-opted-in').textContent).toBe('true'));
|
|
45
|
+
expect(notificationsApi.getNotificationPreferences).toHaveBeenCalledOnce();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('defaults emailOptedIn to false when notification preferences cannot be loaded', async () => {
|
|
49
|
+
notificationsApi.getNotificationPreferences.mockRejectedValue(new Error('unavailable'));
|
|
50
|
+
|
|
51
|
+
renderProvider();
|
|
52
|
+
|
|
53
|
+
await waitFor(() => expect(notificationsApi.getNotificationPreferences).toHaveBeenCalledOnce());
|
|
54
|
+
expect(screen.getByTestId('email-opted-in').textContent).toBe('false');
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
2
|
import { UNIVERSAL_STEP_DESCRIPTORS } from '../src/onboarding/OnboardingProvider';
|
|
3
|
+
import { selectActiveSteps } from '../src/onboarding/stepSelection';
|
|
3
4
|
|
|
4
5
|
const cookieConsentDescriptor = UNIVERSAL_STEP_DESCRIPTORS.find((step) => step.id === 'cookie_consent');
|
|
6
|
+
const browserPushDescriptor = UNIVERSAL_STEP_DESCRIPTORS.find((step) => step.id === 'browser_push');
|
|
5
7
|
|
|
6
8
|
describe('cookie_consent descriptor condition', () => {
|
|
7
9
|
it('is active when the user has not accepted the privacy statement, regardless of cookies', () => {
|
|
@@ -23,3 +25,45 @@ describe('cookie_consent descriptor condition', () => {
|
|
|
23
25
|
expect(cookieConsentDescriptor.condition({ user: null })).toBe(false);
|
|
24
26
|
});
|
|
25
27
|
});
|
|
28
|
+
|
|
29
|
+
describe('browser_push descriptor condition', () => {
|
|
30
|
+
it('is active when push is supported and either channel remains unconfigured', () => {
|
|
31
|
+
expect(browserPushDescriptor.condition({
|
|
32
|
+
pushState: { supported: true, subscribed: false },
|
|
33
|
+
emailOptedIn: true,
|
|
34
|
+
})).toBe(true);
|
|
35
|
+
expect(browserPushDescriptor.condition({
|
|
36
|
+
pushState: { supported: true, subscribed: true },
|
|
37
|
+
emailOptedIn: false,
|
|
38
|
+
})).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('is active when push is unsupported and email notifications are not enabled', () => {
|
|
42
|
+
expect(browserPushDescriptor.condition({
|
|
43
|
+
pushState: { supported: false, subscribed: false },
|
|
44
|
+
emailOptedIn: false,
|
|
45
|
+
})).toBe(true);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('is inactive when push is unsupported with email notifications enabled or both channels are enabled', () => {
|
|
49
|
+
expect(browserPushDescriptor.condition({
|
|
50
|
+
pushState: { supported: false, subscribed: false },
|
|
51
|
+
emailOptedIn: true,
|
|
52
|
+
})).toBe(false);
|
|
53
|
+
expect(browserPushDescriptor.condition({
|
|
54
|
+
pushState: { supported: true, subscribed: true },
|
|
55
|
+
emailOptedIn: true,
|
|
56
|
+
})).toBe(false);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('is excluded after persistent dismissal', () => {
|
|
60
|
+
const activeSteps = selectActiveSteps(
|
|
61
|
+
[browserPushDescriptor],
|
|
62
|
+
{ browser_push: true },
|
|
63
|
+
{ pushState: { supported: true, subscribed: false }, emailOptedIn: false },
|
|
64
|
+
new Set(['browser_push']),
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
expect(activeSteps).toEqual([]);
|
|
68
|
+
});
|
|
69
|
+
});
|