@micha.bigler/ui-core-micha 2.6.1 → 2.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/publish.yml +11 -7
- package/dist/auth/AuthContext.js +6 -0
- package/dist/i18n/onboardingTranslations.js +14 -6
- package/dist/onboarding/OnboardingProvider.js +29 -5
- package/dist/onboarding/OnboardingWizard.js +7 -1
- package/dist/onboarding/steps/BrowserPushStep.js +40 -3
- package/dist/onboarding/steps/CookieConsentStep.js +43 -6
- package/package.json +1 -1
- package/src/auth/AuthContext.jsx +6 -0
- package/src/i18n/onboardingTranslations.ts +14 -6
- package/src/onboarding/OnboardingProvider.jsx +29 -5
- package/src/onboarding/OnboardingWizard.jsx +6 -1
- package/src/onboarding/steps/BrowserPushStep.jsx +62 -6
- package/src/onboarding/steps/CookieConsentStep.jsx +103 -9
- package/tests/AuthContext.test.jsx +50 -0
- package/tests/BrowserPushStep.test.jsx +110 -0
- package/tests/CookieConsentStep.test.jsx +69 -0
- package/tests/OnboardingProvider.test.jsx +56 -0
- package/tests/onboardingDescriptors.test.js +69 -0
|
@@ -51,18 +51,22 @@ jobs:
|
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
const current = JSON.parse(fs.readFileSync('package.json', 'utf8')).version;
|
|
54
|
-
let
|
|
54
|
+
let published;
|
|
55
55
|
try {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
56
|
+
published = cp.execFileSync(
|
|
57
|
+
'npm',
|
|
58
|
+
['view', '@micha.bigler/ui-core-micha', 'version'],
|
|
59
|
+
{ encoding: 'utf8' }
|
|
60
|
+
).trim();
|
|
59
61
|
} catch (error) {
|
|
60
|
-
console.log(
|
|
62
|
+
console.log(
|
|
63
|
+
'Could not fetch the published npm version (the package may not exist yet or the registry is unavailable). Publishing.'
|
|
64
|
+
);
|
|
61
65
|
writeOutput('true');
|
|
62
66
|
process.exit(0);
|
|
63
67
|
}
|
|
64
68
|
|
|
65
|
-
console.log(`
|
|
69
|
+
console.log(`Published version: ${published}`);
|
|
66
70
|
console.log(`Current version: ${current}`);
|
|
67
71
|
|
|
68
72
|
function parse(version) {
|
|
@@ -82,7 +86,7 @@ jobs:
|
|
|
82
86
|
return false;
|
|
83
87
|
}
|
|
84
88
|
|
|
85
|
-
if (greaterThan(current,
|
|
89
|
+
if (greaterThan(current, published)) {
|
|
86
90
|
console.log('Version increased. Publishing.');
|
|
87
91
|
writeOutput('true');
|
|
88
92
|
} else {
|
package/dist/auth/AuthContext.js
CHANGED
|
@@ -69,6 +69,11 @@ export const AuthProvider = ({ children }) => {
|
|
|
69
69
|
const login = (userData) => {
|
|
70
70
|
setUser((prev) => (Object.assign(Object.assign({}, prev), mapUserFromApi(userData))));
|
|
71
71
|
};
|
|
72
|
+
const refreshUser = async () => {
|
|
73
|
+
const data = await fetchCurrentUser();
|
|
74
|
+
if (data)
|
|
75
|
+
setUser(mapUserFromApi(data));
|
|
76
|
+
};
|
|
72
77
|
const logout = async () => {
|
|
73
78
|
try {
|
|
74
79
|
await logoutSession();
|
|
@@ -87,5 +92,6 @@ export const AuthProvider = ({ children }) => {
|
|
|
87
92
|
loading,
|
|
88
93
|
login,
|
|
89
94
|
logout,
|
|
95
|
+
refreshUser,
|
|
90
96
|
}, children: [children, _jsx(ReauthModal, {})] }));
|
|
91
97
|
};
|
|
@@ -1,17 +1,25 @@
|
|
|
1
1
|
export const onboardingTranslations = {
|
|
2
2
|
'Onboarding.SETUP': { de: 'Einrichtung', fr: 'Configuration', en: 'Setup', sw: 'Usanidi' },
|
|
3
3
|
'Onboarding.STEP_COUNTER': { de: 'Schritt {{current}} von {{total}}', fr: 'Étape {{current}} sur {{total}}', en: 'Step {{current}} of {{total}}', sw: 'Hatua ya {{current}} kati ya {{total}}' },
|
|
4
|
-
'Onboarding.
|
|
5
|
-
'Onboarding.
|
|
6
|
-
'Onboarding.
|
|
7
|
-
'Onboarding.
|
|
4
|
+
'Onboarding.PRIVACY_COOKIES_TITLE': { de: 'Datenschutz & Cookies', fr: 'Confidentialité et cookies', en: 'Privacy & cookies', sw: 'Faragha na vidakuzi' },
|
|
5
|
+
'Onboarding.PRIVACY_COOKIES_BODY': { de: 'Bitte lies unsere Datenschutzerklärung und stimme ihr zu, um fortzufahren. Optionale Cookies helfen uns, deine Einstellungen und Präferenzen zu speichern.', fr: 'Veuillez lire et accepter notre politique de confidentialité pour continuer. Les cookies facultatifs nous aident à enregistrer vos réglages et préférences.', en: 'Please read and agree to our privacy statement to continue. Optional cookies help us save your settings and preferences.', sw: 'Tafadhali soma na ukubali taarifa yetu ya faragha ili kuendelea. Vidakuzi vya hiari hutusaidia kuhifadhi mipangilio na mapendeleo yako.' },
|
|
6
|
+
'Onboarding.PRIVACY_AGREEMENT_LABEL': { de: 'Ich habe die Datenschutzerklärung gelesen und stimme ihr zu.', fr: 'J’ai lu et j’accepte la politique de confidentialité.', en: 'I have read and agree to the privacy statement.', sw: 'Nimesoma na ninakubali taarifa ya faragha.' },
|
|
7
|
+
'Onboarding.COOKIES_OPT_IN_LABEL': { de: 'Optionale Cookies aktivieren, um meine Einstellungen und Präferenzen zu speichern.', fr: 'Activer les cookies facultatifs pour enregistrer mes réglages et préférences.', en: 'Enable optional cookies to save my settings and preferences.', sw: 'Washa vidakuzi vya hiari kuhifadhi mipangilio na mapendeleo yangu.' },
|
|
8
|
+
'Onboarding.VIEW_STATEMENT': { de: 'Text anzeigen', fr: 'Afficher le texte', en: 'View text', sw: 'Angalia maandishi' },
|
|
9
|
+
'Onboarding.CONTINUE': { de: 'Weiter', fr: 'Continuer', en: 'Continue', sw: 'Endelea' },
|
|
10
|
+
'Onboarding.PRIVACY_COOKIES_ERROR': { de: 'Deine Angaben konnten nicht gespeichert werden. Bitte versuche es erneut.', fr: 'Vos préférences n’ont pas pu être enregistrées. Veuillez réessayer.', en: 'Your preferences could not be saved. Please try again.', sw: 'Mapendeleo yako hayakuweza kuhifadhiwa. Tafadhali jaribu tena.' },
|
|
8
11
|
'Onboarding.COMPLETE_NAME_TITLE': { de: 'Dein Name', fr: 'Votre nom', en: 'Your name', sw: 'Jina lako' },
|
|
9
12
|
'Onboarding.COMPLETE_NAME_BODY': { de: 'Bitte gib deinen Vor- und Nachnamen an, damit andere dich erkennen.', fr: 'Veuillez indiquer votre prénom et votre nom afin que les autres puissent vous reconnaître.', en: 'Please provide your first and last name so others can recognise you.', sw: 'Tafadhali weka jina lako la kwanza na la mwisho ili wengine wakutambue.' },
|
|
10
13
|
'Onboarding.FIRST_NAME_LABEL': { de: 'Vorname', fr: 'Prénom', en: 'First name', sw: 'Jina la kwanza' },
|
|
11
14
|
'Onboarding.LAST_NAME_LABEL': { de: 'Nachname', fr: 'Nom', en: 'Last name', sw: 'Jina la mwisho' },
|
|
12
15
|
'Onboarding.SAVE': { de: 'Speichern', fr: 'Enregistrer', en: 'Save', sw: 'Hifadhi' },
|
|
13
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.' },
|
|
14
|
-
'Onboarding.
|
|
15
|
-
'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.' },
|
|
16
24
|
'Onboarding.SKIP': { de: 'Überspringen', fr: 'Passer', en: 'Skip', sw: 'Ruka' },
|
|
17
25
|
};
|
|
@@ -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';
|
|
@@ -9,11 +10,11 @@ import BrowserPushStep from './steps/BrowserPushStep';
|
|
|
9
10
|
export const UNIVERSAL_STEP_DESCRIPTORS = [
|
|
10
11
|
{
|
|
11
12
|
id: 'cookie_consent',
|
|
12
|
-
condition: (ctx) => Boolean(ctx.user && !ctx.user.
|
|
13
|
+
condition: (ctx) => Boolean(ctx.user && !ctx.user.accepted_privacy_statement),
|
|
13
14
|
blocking: true,
|
|
14
15
|
skipable: false,
|
|
15
16
|
persistDismissed: false,
|
|
16
|
-
titleKey: 'Onboarding.
|
|
17
|
+
titleKey: 'Onboarding.PRIVACY_COOKIES_TITLE',
|
|
17
18
|
Component: CookieConsentStep,
|
|
18
19
|
},
|
|
19
20
|
{
|
|
@@ -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,5 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import React, { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import React, { useContext, useEffect, useRef, useState } from 'react';
|
|
3
3
|
import { useTranslation } from 'react-i18next';
|
|
4
4
|
import Box from '@mui/material/Box';
|
|
5
5
|
import Dialog from '@mui/material/Dialog';
|
|
@@ -7,10 +7,12 @@ import DialogContent from '@mui/material/DialogContent';
|
|
|
7
7
|
import DialogTitle from '@mui/material/DialogTitle';
|
|
8
8
|
import LinearProgress from '@mui/material/LinearProgress';
|
|
9
9
|
import Typography from '@mui/material/Typography';
|
|
10
|
+
import { AuthContext } from '../auth/AuthContext';
|
|
10
11
|
import { useOnboarding } from './OnboardingProvider';
|
|
11
12
|
export function OnboardingWizard() {
|
|
12
13
|
const { t } = useTranslation();
|
|
13
14
|
const onboarding = useOnboarding();
|
|
15
|
+
const authContext = useContext(AuthContext);
|
|
14
16
|
const [sessionDismissed, setSessionDismissed] = useState(() => new Set());
|
|
15
17
|
const totalRef = useRef(null);
|
|
16
18
|
const [completed, setCompleted] = useState(0);
|
|
@@ -34,8 +36,12 @@ export function OnboardingWizard() {
|
|
|
34
36
|
const progress = total > 1 ? Math.round((completed / total) * 100) : 0;
|
|
35
37
|
const StepComponent = currentStep.Component;
|
|
36
38
|
const completeCurrentStep = () => {
|
|
39
|
+
var _a, _b;
|
|
37
40
|
setSessionDismissed((previous) => new Set([...previous, currentStep.id]));
|
|
38
41
|
setCompleted((count) => count + 1);
|
|
42
|
+
(_b = (_a = authContext === null || authContext === void 0 ? void 0 : authContext.refreshUser) === null || _a === void 0 ? void 0 : _a.call(authContext)) === null || _b === void 0 ? void 0 : _b.catch(() => {
|
|
43
|
+
// Best-effort refresh; the step itself already persisted successfully.
|
|
44
|
+
});
|
|
39
45
|
};
|
|
40
46
|
const dismissCurrentStep = () => {
|
|
41
47
|
if (currentStep.persistDismissed)
|
|
@@ -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;
|
|
@@ -4,28 +4,65 @@ 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
|
+
import Checkbox from '@mui/material/Checkbox';
|
|
7
8
|
import CircularProgress from '@mui/material/CircularProgress';
|
|
9
|
+
import FormControlLabel from '@mui/material/FormControlLabel';
|
|
10
|
+
import Link from '@mui/material/Link';
|
|
8
11
|
import Typography from '@mui/material/Typography';
|
|
9
|
-
import
|
|
10
|
-
import { updateUserProfile } from '../../auth/authApi';
|
|
12
|
+
import PrivacyTipIcon from '@mui/icons-material/PrivacyTip';
|
|
13
|
+
import { fetchCookieStatement, fetchPrivacyStatement, updateUserProfile } from '../../auth/authApi';
|
|
11
14
|
export function CookieConsentStep({ onComplete }) {
|
|
12
15
|
const { t } = useTranslation();
|
|
16
|
+
const [privacyAccepted, setPrivacyAccepted] = useState(false);
|
|
17
|
+
const [cookiesAccepted, setCookiesAccepted] = useState(false);
|
|
18
|
+
const [privacyText, setPrivacyText] = useState('');
|
|
19
|
+
const [cookieText, setCookieText] = useState('');
|
|
20
|
+
const [showPrivacyText, setShowPrivacyText] = useState(false);
|
|
21
|
+
const [showCookieText, setShowCookieText] = useState(false);
|
|
13
22
|
const [loading, setLoading] = useState(false);
|
|
14
23
|
const [error, setError] = useState('');
|
|
15
|
-
const
|
|
24
|
+
const togglePrivacyText = async () => {
|
|
25
|
+
if (!showPrivacyText && !privacyText) {
|
|
26
|
+
try {
|
|
27
|
+
setPrivacyText(await fetchPrivacyStatement());
|
|
28
|
+
}
|
|
29
|
+
catch (_a) {
|
|
30
|
+
// Non-fatal: the link simply won't reveal inline text this time.
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
setShowPrivacyText((previous) => !previous);
|
|
34
|
+
};
|
|
35
|
+
const toggleCookieText = async () => {
|
|
36
|
+
if (!showCookieText && !cookieText) {
|
|
37
|
+
try {
|
|
38
|
+
setCookieText(await fetchCookieStatement());
|
|
39
|
+
}
|
|
40
|
+
catch (_a) {
|
|
41
|
+
// Non-fatal: the link simply won't reveal inline text this time.
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
setShowCookieText((previous) => !previous);
|
|
45
|
+
};
|
|
46
|
+
const submit = async (event) => {
|
|
47
|
+
event.preventDefault();
|
|
48
|
+
if (!privacyAccepted)
|
|
49
|
+
return;
|
|
16
50
|
setLoading(true);
|
|
17
51
|
setError('');
|
|
18
52
|
try {
|
|
19
|
-
await updateUserProfile({
|
|
53
|
+
await updateUserProfile({
|
|
54
|
+
accepted_privacy_statement: true,
|
|
55
|
+
accepted_convenience_cookies: cookiesAccepted,
|
|
56
|
+
});
|
|
20
57
|
onComplete();
|
|
21
58
|
}
|
|
22
59
|
catch (_a) {
|
|
23
|
-
setError(t('Onboarding.
|
|
60
|
+
setError(t('Onboarding.PRIVACY_COOKIES_ERROR'));
|
|
24
61
|
}
|
|
25
62
|
finally {
|
|
26
63
|
setLoading(false);
|
|
27
64
|
}
|
|
28
65
|
};
|
|
29
|
-
return (_jsxs(Box, { sx: { display: 'flex', flexDirection: 'column', gap: 2 }, children: [_jsxs(Box, { sx: { display: 'flex', alignItems: 'center', gap: 1 }, children: [_jsx(
|
|
66
|
+
return (_jsxs(Box, { component: "form", onSubmit: submit, sx: { display: 'flex', flexDirection: 'column', gap: 2 }, children: [_jsxs(Box, { sx: { display: 'flex', alignItems: 'center', gap: 1 }, children: [_jsx(PrivacyTipIcon, { color: "primary" }), _jsx(Typography, { variant: "h6", children: t('Onboarding.PRIVACY_COOKIES_TITLE') })] }), _jsx(Typography, { variant: "body2", color: "text.secondary", children: t('Onboarding.PRIVACY_COOKIES_BODY') }), error && _jsx(Alert, { severity: "error", onClose: () => setError(''), children: error }), _jsxs(Box, { children: [_jsx(FormControlLabel, { control: (_jsx(Checkbox, { checked: privacyAccepted, onChange: (event) => setPrivacyAccepted(event.target.checked), required: true })), label: t('Onboarding.PRIVACY_AGREEMENT_LABEL') }), _jsxs(Box, { children: [_jsx(Link, { component: "button", type: "button", variant: "body2", onClick: togglePrivacyText, children: t('Onboarding.VIEW_STATEMENT') }), showPrivacyText && privacyText && (_jsx(Typography, { variant: "body2", color: "text.secondary", sx: { mt: 1, whiteSpace: 'pre-wrap' }, children: privacyText }))] })] }), _jsxs(Box, { children: [_jsx(FormControlLabel, { control: (_jsx(Checkbox, { checked: cookiesAccepted, onChange: (event) => setCookiesAccepted(event.target.checked) })), label: t('Onboarding.COOKIES_OPT_IN_LABEL') }), _jsxs(Box, { children: [_jsx(Link, { component: "button", type: "button", variant: "body2", onClick: toggleCookieText, children: t('Onboarding.VIEW_STATEMENT') }), showCookieText && cookieText && (_jsx(Typography, { variant: "body2", color: "text.secondary", sx: { mt: 1, whiteSpace: 'pre-wrap' }, children: cookieText }))] })] }), _jsx(Box, { sx: { display: 'flex', justifyContent: 'flex-end' }, children: _jsx(Button, { variant: "contained", type: "submit", disabled: loading || !privacyAccepted, startIcon: loading ? _jsx(CircularProgress, { size: 16 }) : undefined, children: t('Onboarding.CONTINUE') }) })] }));
|
|
30
67
|
}
|
|
31
68
|
export default CookieConsentStep;
|
package/package.json
CHANGED
package/src/auth/AuthContext.jsx
CHANGED
|
@@ -115,6 +115,11 @@ export const AuthProvider = ({ children }) => {
|
|
|
115
115
|
}));
|
|
116
116
|
};
|
|
117
117
|
|
|
118
|
+
const refreshUser = async () => {
|
|
119
|
+
const data = await fetchCurrentUser();
|
|
120
|
+
if (data) setUser(mapUserFromApi(data));
|
|
121
|
+
};
|
|
122
|
+
|
|
118
123
|
const logout = async () => {
|
|
119
124
|
try {
|
|
120
125
|
await logoutSession();
|
|
@@ -134,6 +139,7 @@ export const AuthProvider = ({ children }) => {
|
|
|
134
139
|
loading,
|
|
135
140
|
login,
|
|
136
141
|
logout,
|
|
142
|
+
refreshUser,
|
|
137
143
|
}}
|
|
138
144
|
>
|
|
139
145
|
{children}
|
|
@@ -1,17 +1,25 @@
|
|
|
1
1
|
export const onboardingTranslations = {
|
|
2
2
|
'Onboarding.SETUP': { de: 'Einrichtung', fr: 'Configuration', en: 'Setup', sw: 'Usanidi' },
|
|
3
3
|
'Onboarding.STEP_COUNTER': { de: 'Schritt {{current}} von {{total}}', fr: 'Étape {{current}} sur {{total}}', en: 'Step {{current}} of {{total}}', sw: 'Hatua ya {{current}} kati ya {{total}}' },
|
|
4
|
-
'Onboarding.
|
|
5
|
-
'Onboarding.
|
|
6
|
-
'Onboarding.
|
|
7
|
-
'Onboarding.
|
|
4
|
+
'Onboarding.PRIVACY_COOKIES_TITLE': { de: 'Datenschutz & Cookies', fr: 'Confidentialité et cookies', en: 'Privacy & cookies', sw: 'Faragha na vidakuzi' },
|
|
5
|
+
'Onboarding.PRIVACY_COOKIES_BODY': { de: 'Bitte lies unsere Datenschutzerklärung und stimme ihr zu, um fortzufahren. Optionale Cookies helfen uns, deine Einstellungen und Präferenzen zu speichern.', fr: 'Veuillez lire et accepter notre politique de confidentialité pour continuer. Les cookies facultatifs nous aident à enregistrer vos réglages et préférences.', en: 'Please read and agree to our privacy statement to continue. Optional cookies help us save your settings and preferences.', sw: 'Tafadhali soma na ukubali taarifa yetu ya faragha ili kuendelea. Vidakuzi vya hiari hutusaidia kuhifadhi mipangilio na mapendeleo yako.' },
|
|
6
|
+
'Onboarding.PRIVACY_AGREEMENT_LABEL': { de: 'Ich habe die Datenschutzerklärung gelesen und stimme ihr zu.', fr: 'J’ai lu et j’accepte la politique de confidentialité.', en: 'I have read and agree to the privacy statement.', sw: 'Nimesoma na ninakubali taarifa ya faragha.' },
|
|
7
|
+
'Onboarding.COOKIES_OPT_IN_LABEL': { de: 'Optionale Cookies aktivieren, um meine Einstellungen und Präferenzen zu speichern.', fr: 'Activer les cookies facultatifs pour enregistrer mes réglages et préférences.', en: 'Enable optional cookies to save my settings and preferences.', sw: 'Washa vidakuzi vya hiari kuhifadhi mipangilio na mapendeleo yangu.' },
|
|
8
|
+
'Onboarding.VIEW_STATEMENT': { de: 'Text anzeigen', fr: 'Afficher le texte', en: 'View text', sw: 'Angalia maandishi' },
|
|
9
|
+
'Onboarding.CONTINUE': { de: 'Weiter', fr: 'Continuer', en: 'Continue', sw: 'Endelea' },
|
|
10
|
+
'Onboarding.PRIVACY_COOKIES_ERROR': { de: 'Deine Angaben konnten nicht gespeichert werden. Bitte versuche es erneut.', fr: 'Vos préférences n’ont pas pu être enregistrées. Veuillez réessayer.', en: 'Your preferences could not be saved. Please try again.', sw: 'Mapendeleo yako hayakuweza kuhifadhiwa. Tafadhali jaribu tena.' },
|
|
8
11
|
'Onboarding.COMPLETE_NAME_TITLE': { de: 'Dein Name', fr: 'Votre nom', en: 'Your name', sw: 'Jina lako' },
|
|
9
12
|
'Onboarding.COMPLETE_NAME_BODY': { de: 'Bitte gib deinen Vor- und Nachnamen an, damit andere dich erkennen.', fr: 'Veuillez indiquer votre prénom et votre nom afin que les autres puissent vous reconnaître.', en: 'Please provide your first and last name so others can recognise you.', sw: 'Tafadhali weka jina lako la kwanza na la mwisho ili wengine wakutambue.' },
|
|
10
13
|
'Onboarding.FIRST_NAME_LABEL': { de: 'Vorname', fr: 'Prénom', en: 'First name', sw: 'Jina la kwanza' },
|
|
11
14
|
'Onboarding.LAST_NAME_LABEL': { de: 'Nachname', fr: 'Nom', en: 'Last name', sw: 'Jina la mwisho' },
|
|
12
15
|
'Onboarding.SAVE': { de: 'Speichern', fr: 'Enregistrer', en: 'Save', sw: 'Hifadhi' },
|
|
13
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.' },
|
|
14
|
-
'Onboarding.
|
|
15
|
-
'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.' },
|
|
16
24
|
'Onboarding.SKIP': { de: 'Überspringen', fr: 'Passer', en: 'Skip', sw: 'Ruka' },
|
|
17
25
|
};
|
|
@@ -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';
|
|
@@ -16,11 +17,11 @@ import BrowserPushStep from './steps/BrowserPushStep';
|
|
|
16
17
|
export const UNIVERSAL_STEP_DESCRIPTORS = [
|
|
17
18
|
{
|
|
18
19
|
id: 'cookie_consent',
|
|
19
|
-
condition: (ctx) => Boolean(ctx.user && !ctx.user.
|
|
20
|
+
condition: (ctx) => Boolean(ctx.user && !ctx.user.accepted_privacy_statement),
|
|
20
21
|
blocking: true,
|
|
21
22
|
skipable: false,
|
|
22
23
|
persistDismissed: false,
|
|
23
|
-
titleKey: 'Onboarding.
|
|
24
|
+
titleKey: 'Onboarding.PRIVACY_COOKIES_TITLE',
|
|
24
25
|
Component: CookieConsentStep,
|
|
25
26
|
},
|
|
26
27
|
{
|
|
@@ -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,4 +1,4 @@
|
|
|
1
|
-
import React, { useEffect, useRef, useState } from 'react';
|
|
1
|
+
import React, { useContext, useEffect, useRef, useState } from 'react';
|
|
2
2
|
import { useTranslation } from 'react-i18next';
|
|
3
3
|
import Box from '@mui/material/Box';
|
|
4
4
|
import Dialog from '@mui/material/Dialog';
|
|
@@ -6,11 +6,13 @@ import DialogContent from '@mui/material/DialogContent';
|
|
|
6
6
|
import DialogTitle from '@mui/material/DialogTitle';
|
|
7
7
|
import LinearProgress from '@mui/material/LinearProgress';
|
|
8
8
|
import Typography from '@mui/material/Typography';
|
|
9
|
+
import { AuthContext } from '../auth/AuthContext';
|
|
9
10
|
import { useOnboarding } from './OnboardingProvider';
|
|
10
11
|
|
|
11
12
|
export function OnboardingWizard() {
|
|
12
13
|
const { t } = useTranslation();
|
|
13
14
|
const onboarding = useOnboarding();
|
|
15
|
+
const authContext = useContext(AuthContext);
|
|
14
16
|
const [sessionDismissed, setSessionDismissed] = useState(() => new Set());
|
|
15
17
|
const totalRef = useRef(null);
|
|
16
18
|
const [completed, setCompleted] = useState(0);
|
|
@@ -39,6 +41,9 @@ export function OnboardingWizard() {
|
|
|
39
41
|
const completeCurrentStep = () => {
|
|
40
42
|
setSessionDismissed((previous) => new Set([...previous, currentStep.id]));
|
|
41
43
|
setCompleted((count) => count + 1);
|
|
44
|
+
authContext?.refreshUser?.()?.catch(() => {
|
|
45
|
+
// Best-effort refresh; the step itself already persisted successfully.
|
|
46
|
+
});
|
|
42
47
|
};
|
|
43
48
|
|
|
44
49
|
const dismissCurrentStep = () => {
|
|
@@ -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
|
);
|
|
@@ -3,35 +3,129 @@ 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
|
+
import Checkbox from '@mui/material/Checkbox';
|
|
6
7
|
import CircularProgress from '@mui/material/CircularProgress';
|
|
8
|
+
import FormControlLabel from '@mui/material/FormControlLabel';
|
|
9
|
+
import Link from '@mui/material/Link';
|
|
7
10
|
import Typography from '@mui/material/Typography';
|
|
8
|
-
import
|
|
9
|
-
import { updateUserProfile } from '../../auth/authApi';
|
|
11
|
+
import PrivacyTipIcon from '@mui/icons-material/PrivacyTip';
|
|
12
|
+
import { fetchCookieStatement, fetchPrivacyStatement, updateUserProfile } from '../../auth/authApi';
|
|
10
13
|
|
|
11
14
|
export function CookieConsentStep({ onComplete }) {
|
|
12
15
|
const { t } = useTranslation();
|
|
16
|
+
const [privacyAccepted, setPrivacyAccepted] = useState(false);
|
|
17
|
+
const [cookiesAccepted, setCookiesAccepted] = useState(false);
|
|
18
|
+
const [privacyText, setPrivacyText] = useState('');
|
|
19
|
+
const [cookieText, setCookieText] = useState('');
|
|
20
|
+
const [showPrivacyText, setShowPrivacyText] = useState(false);
|
|
21
|
+
const [showCookieText, setShowCookieText] = useState(false);
|
|
13
22
|
const [loading, setLoading] = useState(false);
|
|
14
23
|
const [error, setError] = useState('');
|
|
15
24
|
|
|
16
|
-
const
|
|
25
|
+
const togglePrivacyText = async () => {
|
|
26
|
+
if (!showPrivacyText && !privacyText) {
|
|
27
|
+
try {
|
|
28
|
+
setPrivacyText(await fetchPrivacyStatement());
|
|
29
|
+
} catch {
|
|
30
|
+
// Non-fatal: the link simply won't reveal inline text this time.
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
setShowPrivacyText((previous) => !previous);
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const toggleCookieText = async () => {
|
|
37
|
+
if (!showCookieText && !cookieText) {
|
|
38
|
+
try {
|
|
39
|
+
setCookieText(await fetchCookieStatement());
|
|
40
|
+
} catch {
|
|
41
|
+
// Non-fatal: the link simply won't reveal inline text this time.
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
setShowCookieText((previous) => !previous);
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const submit = async (event) => {
|
|
48
|
+
event.preventDefault();
|
|
49
|
+
if (!privacyAccepted) return;
|
|
17
50
|
setLoading(true);
|
|
18
51
|
setError('');
|
|
19
52
|
try {
|
|
20
|
-
await updateUserProfile({
|
|
53
|
+
await updateUserProfile({
|
|
54
|
+
accepted_privacy_statement: true,
|
|
55
|
+
accepted_convenience_cookies: cookiesAccepted,
|
|
56
|
+
});
|
|
21
57
|
onComplete();
|
|
22
58
|
} catch {
|
|
23
|
-
setError(t('Onboarding.
|
|
59
|
+
setError(t('Onboarding.PRIVACY_COOKIES_ERROR'));
|
|
24
60
|
} finally {
|
|
25
61
|
setLoading(false);
|
|
26
62
|
}
|
|
27
63
|
};
|
|
28
64
|
|
|
29
65
|
return (
|
|
30
|
-
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
|
31
|
-
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}
|
|
32
|
-
|
|
66
|
+
<Box component="form" onSubmit={submit} sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
|
67
|
+
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
68
|
+
<PrivacyTipIcon color="primary" />
|
|
69
|
+
<Typography variant="h6">{t('Onboarding.PRIVACY_COOKIES_TITLE')}</Typography>
|
|
70
|
+
</Box>
|
|
71
|
+
<Typography variant="body2" color="text.secondary">{t('Onboarding.PRIVACY_COOKIES_BODY')}</Typography>
|
|
33
72
|
{error && <Alert severity="error" onClose={() => setError('')}>{error}</Alert>}
|
|
34
|
-
|
|
73
|
+
|
|
74
|
+
<Box>
|
|
75
|
+
<FormControlLabel
|
|
76
|
+
control={(
|
|
77
|
+
<Checkbox
|
|
78
|
+
checked={privacyAccepted}
|
|
79
|
+
onChange={(event) => setPrivacyAccepted(event.target.checked)}
|
|
80
|
+
required
|
|
81
|
+
/>
|
|
82
|
+
)}
|
|
83
|
+
label={t('Onboarding.PRIVACY_AGREEMENT_LABEL')}
|
|
84
|
+
/>
|
|
85
|
+
<Box>
|
|
86
|
+
<Link component="button" type="button" variant="body2" onClick={togglePrivacyText}>
|
|
87
|
+
{t('Onboarding.VIEW_STATEMENT')}
|
|
88
|
+
</Link>
|
|
89
|
+
{showPrivacyText && privacyText && (
|
|
90
|
+
<Typography variant="body2" color="text.secondary" sx={{ mt: 1, whiteSpace: 'pre-wrap' }}>
|
|
91
|
+
{privacyText}
|
|
92
|
+
</Typography>
|
|
93
|
+
)}
|
|
94
|
+
</Box>
|
|
95
|
+
</Box>
|
|
96
|
+
|
|
97
|
+
<Box>
|
|
98
|
+
<FormControlLabel
|
|
99
|
+
control={(
|
|
100
|
+
<Checkbox
|
|
101
|
+
checked={cookiesAccepted}
|
|
102
|
+
onChange={(event) => setCookiesAccepted(event.target.checked)}
|
|
103
|
+
/>
|
|
104
|
+
)}
|
|
105
|
+
label={t('Onboarding.COOKIES_OPT_IN_LABEL')}
|
|
106
|
+
/>
|
|
107
|
+
<Box>
|
|
108
|
+
<Link component="button" type="button" variant="body2" onClick={toggleCookieText}>
|
|
109
|
+
{t('Onboarding.VIEW_STATEMENT')}
|
|
110
|
+
</Link>
|
|
111
|
+
{showCookieText && cookieText && (
|
|
112
|
+
<Typography variant="body2" color="text.secondary" sx={{ mt: 1, whiteSpace: 'pre-wrap' }}>
|
|
113
|
+
{cookieText}
|
|
114
|
+
</Typography>
|
|
115
|
+
)}
|
|
116
|
+
</Box>
|
|
117
|
+
</Box>
|
|
118
|
+
|
|
119
|
+
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
|
|
120
|
+
<Button
|
|
121
|
+
variant="contained"
|
|
122
|
+
type="submit"
|
|
123
|
+
disabled={loading || !privacyAccepted}
|
|
124
|
+
startIcon={loading ? <CircularProgress size={16} /> : undefined}
|
|
125
|
+
>
|
|
126
|
+
{t('Onboarding.CONTINUE')}
|
|
127
|
+
</Button>
|
|
128
|
+
</Box>
|
|
35
129
|
</Box>
|
|
36
130
|
);
|
|
37
131
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import React, { useContext } from 'react';
|
|
3
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { render, screen, waitFor } from '@testing-library/react';
|
|
5
|
+
|
|
6
|
+
const apiClient = vi.hoisted(() => ({
|
|
7
|
+
ensureCsrfToken: vi.fn().mockResolvedValue(undefined),
|
|
8
|
+
}));
|
|
9
|
+
const authApi = vi.hoisted(() => ({
|
|
10
|
+
fetchAuthMethods: vi.fn().mockResolvedValue({}),
|
|
11
|
+
fetchCurrentUser: vi.fn(),
|
|
12
|
+
logoutSession: vi.fn(),
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
vi.mock('../src/auth/apiClient', () => apiClient);
|
|
16
|
+
vi.mock('../src/auth/authApi', () => authApi);
|
|
17
|
+
vi.mock('../src/auth/ReauthModal', () => ({ ReauthModal: () => null }));
|
|
18
|
+
|
|
19
|
+
import { AuthContext, AuthProvider } from '../src/auth/AuthContext';
|
|
20
|
+
|
|
21
|
+
function Probe() {
|
|
22
|
+
const { user, refreshUser } = useContext(AuthContext);
|
|
23
|
+
return (
|
|
24
|
+
<div>
|
|
25
|
+
<span data-testid="first-name">{user?.first_name || ''}</span>
|
|
26
|
+
<button type="button" onClick={() => refreshUser()}>refresh</button>
|
|
27
|
+
</div>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe('AuthContext refreshUser', () => {
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
vi.clearAllMocks();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('re-fetches the current user and updates context state', async () => {
|
|
37
|
+
authApi.fetchCurrentUser
|
|
38
|
+
.mockResolvedValueOnce({ id: 1, first_name: 'Ada' })
|
|
39
|
+
.mockResolvedValueOnce({ id: 1, first_name: 'Grace' });
|
|
40
|
+
|
|
41
|
+
render(<AuthProvider><Probe /></AuthProvider>);
|
|
42
|
+
|
|
43
|
+
await waitFor(() => expect(screen.getByTestId('first-name').textContent).toBe('Ada'));
|
|
44
|
+
|
|
45
|
+
screen.getByRole('button', { name: 'refresh' }).click();
|
|
46
|
+
|
|
47
|
+
await waitFor(() => expect(screen.getByTestId('first-name').textContent).toBe('Grace'));
|
|
48
|
+
expect(authApi.fetchCurrentUser).toHaveBeenCalledTimes(2);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
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 authApi = vi.hoisted(() => ({
|
|
7
|
+
fetchCookieStatement: vi.fn(),
|
|
8
|
+
fetchPrivacyStatement: vi.fn(),
|
|
9
|
+
updateUserProfile: vi.fn(),
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
vi.mock('../src/auth/authApi', () => authApi);
|
|
13
|
+
vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key) => key }) }));
|
|
14
|
+
|
|
15
|
+
import { CookieConsentStep } from '../src/onboarding/steps/CookieConsentStep';
|
|
16
|
+
|
|
17
|
+
describe('CookieConsentStep (combined privacy + cookies)', () => {
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
vi.clearAllMocks();
|
|
20
|
+
authApi.fetchPrivacyStatement.mockResolvedValue('privacy text');
|
|
21
|
+
authApi.fetchCookieStatement.mockResolvedValue('cookie text');
|
|
22
|
+
authApi.updateUserProfile.mockResolvedValue({});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
cleanup();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('does not submit while the privacy checkbox is unchecked', async () => {
|
|
30
|
+
const onComplete = vi.fn();
|
|
31
|
+
render(<CookieConsentStep onComplete={onComplete} />);
|
|
32
|
+
|
|
33
|
+
const button = screen.getByRole('button', { name: 'Onboarding.CONTINUE' });
|
|
34
|
+
expect(button.disabled).toBe(true);
|
|
35
|
+
fireEvent.click(button);
|
|
36
|
+
|
|
37
|
+
await waitFor(() => expect(authApi.updateUserProfile).not.toHaveBeenCalled());
|
|
38
|
+
expect(onComplete).not.toHaveBeenCalled();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('submits both fields with cookies unchecked (privacy only)', async () => {
|
|
42
|
+
const onComplete = vi.fn();
|
|
43
|
+
render(<CookieConsentStep onComplete={onComplete} />);
|
|
44
|
+
|
|
45
|
+
fireEvent.click(screen.getByRole('checkbox', { name: 'Onboarding.PRIVACY_AGREEMENT_LABEL' }));
|
|
46
|
+
fireEvent.click(screen.getByRole('button', { name: 'Onboarding.CONTINUE' }));
|
|
47
|
+
|
|
48
|
+
await waitFor(() => expect(authApi.updateUserProfile).toHaveBeenCalledWith({
|
|
49
|
+
accepted_privacy_statement: true,
|
|
50
|
+
accepted_convenience_cookies: false,
|
|
51
|
+
}));
|
|
52
|
+
expect(onComplete).toHaveBeenCalled();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('submits both fields with cookies checked', async () => {
|
|
56
|
+
const onComplete = vi.fn();
|
|
57
|
+
render(<CookieConsentStep onComplete={onComplete} />);
|
|
58
|
+
|
|
59
|
+
fireEvent.click(screen.getByRole('checkbox', { name: 'Onboarding.PRIVACY_AGREEMENT_LABEL' }));
|
|
60
|
+
fireEvent.click(screen.getByRole('checkbox', { name: 'Onboarding.COOKIES_OPT_IN_LABEL' }));
|
|
61
|
+
fireEvent.click(screen.getByRole('button', { name: 'Onboarding.CONTINUE' }));
|
|
62
|
+
|
|
63
|
+
await waitFor(() => expect(authApi.updateUserProfile).toHaveBeenCalledWith({
|
|
64
|
+
accepted_privacy_statement: true,
|
|
65
|
+
accepted_convenience_cookies: true,
|
|
66
|
+
}));
|
|
67
|
+
expect(onComplete).toHaveBeenCalled();
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { UNIVERSAL_STEP_DESCRIPTORS } from '../src/onboarding/OnboardingProvider';
|
|
3
|
+
import { selectActiveSteps } from '../src/onboarding/stepSelection';
|
|
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');
|
|
7
|
+
|
|
8
|
+
describe('cookie_consent descriptor condition', () => {
|
|
9
|
+
it('is active when the user has not accepted the privacy statement, regardless of cookies', () => {
|
|
10
|
+
expect(cookieConsentDescriptor.condition({
|
|
11
|
+
user: { accepted_privacy_statement: false, accepted_convenience_cookies: false },
|
|
12
|
+
})).toBe(true);
|
|
13
|
+
expect(cookieConsentDescriptor.condition({
|
|
14
|
+
user: { accepted_privacy_statement: false, accepted_convenience_cookies: true },
|
|
15
|
+
})).toBe(true);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('is inactive once the privacy statement is accepted, even if cookies were declined', () => {
|
|
19
|
+
expect(cookieConsentDescriptor.condition({
|
|
20
|
+
user: { accepted_privacy_statement: true, accepted_convenience_cookies: false },
|
|
21
|
+
})).toBe(false);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('is inactive with no user', () => {
|
|
25
|
+
expect(cookieConsentDescriptor.condition({ user: null })).toBe(false);
|
|
26
|
+
});
|
|
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
|
+
});
|