@micha.bigler/ui-core-micha 2.5.0 → 2.7.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 +15 -7
- package/dist/auth/AuthContext.js +6 -0
- package/dist/i18n/notificationsTranslations.js +17 -0
- package/dist/i18n/onboardingTranslations.js +20 -0
- package/dist/index.js +14 -0
- package/dist/notifications/NotificationSettings.js +148 -0
- package/dist/notifications/api.js +41 -0
- package/dist/notifications/serviceWorker/sw.js +49 -0
- package/dist/onboarding/OnboardingProvider.js +133 -0
- package/dist/onboarding/OnboardingWizard.js +53 -0
- package/dist/onboarding/api.js +10 -0
- package/dist/onboarding/stepSelection.js +18 -0
- package/dist/onboarding/steps/BrowserPushStep.js +49 -0
- package/dist/onboarding/steps/CompleteNameStep.js +37 -0
- package/dist/onboarding/steps/CookieConsentStep.js +68 -0
- package/package.json +7 -3
- package/src/auth/AuthContext.jsx +6 -0
- package/src/i18n/notificationsTranslations.ts +17 -0
- package/src/i18n/onboardingTranslations.ts +20 -0
- package/src/index.js +22 -0
- package/src/notifications/NotificationSettings.jsx +189 -0
- package/src/notifications/api.js +49 -0
- package/src/notifications/serviceWorker/sw.js +50 -0
- package/src/onboarding/OnboardingProvider.jsx +153 -0
- package/src/onboarding/OnboardingWizard.jsx +71 -0
- package/src/onboarding/api.js +13 -0
- package/src/onboarding/stepSelection.js +16 -0
- package/src/onboarding/steps/BrowserPushStep.jsx +63 -0
- package/src/onboarding/steps/CompleteNameStep.jsx +46 -0
- package/src/onboarding/steps/CookieConsentStep.jsx +133 -0
- package/tests/AuthContext.test.jsx +50 -0
- package/tests/CookieConsentStep.test.jsx +69 -0
- package/tests/NotificationSettings.test.jsx +82 -0
- package/tests/notificationsApi.test.js +41 -0
- package/tests/onboardingDescriptors.test.js +25 -0
- package/tests/stepSelection.test.js +53 -0
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import React, {
|
|
2
|
+
createContext,
|
|
3
|
+
useCallback,
|
|
4
|
+
useContext,
|
|
5
|
+
useEffect,
|
|
6
|
+
useMemo,
|
|
7
|
+
useState,
|
|
8
|
+
} from 'react';
|
|
9
|
+
import { AuthContext } from '../auth/AuthContext';
|
|
10
|
+
import { getOnboardingStepConfig } from './api';
|
|
11
|
+
import { selectActiveSteps } from './stepSelection';
|
|
12
|
+
import CookieConsentStep from './steps/CookieConsentStep';
|
|
13
|
+
import CompleteNameStep from './steps/CompleteNameStep';
|
|
14
|
+
import BrowserPushStep from './steps/BrowserPushStep';
|
|
15
|
+
|
|
16
|
+
export const UNIVERSAL_STEP_DESCRIPTORS = [
|
|
17
|
+
{
|
|
18
|
+
id: 'cookie_consent',
|
|
19
|
+
condition: (ctx) => Boolean(ctx.user && !ctx.user.accepted_privacy_statement),
|
|
20
|
+
blocking: true,
|
|
21
|
+
skipable: false,
|
|
22
|
+
persistDismissed: false,
|
|
23
|
+
titleKey: 'Onboarding.PRIVACY_COOKIES_TITLE',
|
|
24
|
+
Component: CookieConsentStep,
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
id: 'complete_name',
|
|
28
|
+
condition: (ctx) => Boolean(ctx.user && (!ctx.user.first_name?.trim() || !ctx.user.last_name?.trim())),
|
|
29
|
+
blocking: true,
|
|
30
|
+
skipable: false,
|
|
31
|
+
persistDismissed: false,
|
|
32
|
+
titleKey: 'Onboarding.COMPLETE_NAME_TITLE',
|
|
33
|
+
Component: CompleteNameStep,
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
id: 'browser_push',
|
|
37
|
+
condition: (ctx) => Boolean(ctx.pushState?.supported && !ctx.pushState.subscribed),
|
|
38
|
+
blocking: false,
|
|
39
|
+
skipable: true,
|
|
40
|
+
persistDismissed: true,
|
|
41
|
+
titleKey: 'Onboarding.BROWSER_PUSH_TITLE',
|
|
42
|
+
Component: BrowserPushStep,
|
|
43
|
+
},
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
export const OnboardingContext = createContext(null);
|
|
47
|
+
|
|
48
|
+
export function useOnboarding() {
|
|
49
|
+
return useContext(OnboardingContext);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function loadDismissedSteps() {
|
|
53
|
+
if (typeof window === 'undefined') return new Set();
|
|
54
|
+
try {
|
|
55
|
+
return new Set(JSON.parse(window.localStorage.getItem('onboarding_dismissed')) || []);
|
|
56
|
+
} catch {
|
|
57
|
+
return new Set();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function getInitialPushState() {
|
|
62
|
+
const supported = typeof navigator !== 'undefined'
|
|
63
|
+
&& typeof window !== 'undefined'
|
|
64
|
+
&& 'serviceWorker' in navigator
|
|
65
|
+
&& 'PushManager' in window;
|
|
66
|
+
return supported ? null : { supported: false, subscribed: false };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function OnboardingProvider({ children, extraSteps = [] }) {
|
|
70
|
+
const { user } = useContext(AuthContext);
|
|
71
|
+
const [configMap, setConfigMap] = useState({});
|
|
72
|
+
const [configLoaded, setConfigLoaded] = useState(false);
|
|
73
|
+
const [pushState, setPushState] = useState(getInitialPushState);
|
|
74
|
+
const [dismissedSet, setDismissedSet] = useState(loadDismissedSteps);
|
|
75
|
+
|
|
76
|
+
const descriptors = useMemo(
|
|
77
|
+
() => [...UNIVERSAL_STEP_DESCRIPTORS, ...extraSteps],
|
|
78
|
+
[extraSteps],
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
let cancelled = false;
|
|
83
|
+
if (!user) {
|
|
84
|
+
setConfigMap({});
|
|
85
|
+
setConfigLoaded(false);
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
getOnboardingStepConfig()
|
|
90
|
+
.then((data) => {
|
|
91
|
+
if (cancelled) return;
|
|
92
|
+
const nextConfigMap = {};
|
|
93
|
+
(data || []).forEach((item) => { nextConfigMap[item.key] = item.enabled; });
|
|
94
|
+
setConfigMap(nextConfigMap);
|
|
95
|
+
})
|
|
96
|
+
.catch(() => {
|
|
97
|
+
if (!cancelled) setConfigMap({});
|
|
98
|
+
})
|
|
99
|
+
.finally(() => {
|
|
100
|
+
if (!cancelled) setConfigLoaded(true);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
return () => { cancelled = true; };
|
|
104
|
+
}, [user]);
|
|
105
|
+
|
|
106
|
+
useEffect(() => {
|
|
107
|
+
let cancelled = false;
|
|
108
|
+
if (typeof navigator === 'undefined' || typeof window === 'undefined'
|
|
109
|
+
|| !('serviceWorker' in navigator) || !('PushManager' in window)) {
|
|
110
|
+
setPushState({ supported: false, subscribed: false });
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
navigator.serviceWorker.ready
|
|
115
|
+
.then((registration) => registration.pushManager.getSubscription())
|
|
116
|
+
.then((subscription) => {
|
|
117
|
+
if (!cancelled) setPushState({ supported: true, subscribed: Boolean(subscription) });
|
|
118
|
+
})
|
|
119
|
+
.catch(() => {
|
|
120
|
+
if (!cancelled) setPushState({ supported: true, subscribed: false });
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
return () => { cancelled = true; };
|
|
124
|
+
}, []);
|
|
125
|
+
|
|
126
|
+
const dismissStep = useCallback((id) => {
|
|
127
|
+
setDismissedSet((previous) => {
|
|
128
|
+
const next = new Set(previous);
|
|
129
|
+
next.add(id);
|
|
130
|
+
if (typeof window !== 'undefined') {
|
|
131
|
+
try {
|
|
132
|
+
window.localStorage.setItem('onboarding_dismissed', JSON.stringify([...next]));
|
|
133
|
+
} catch {
|
|
134
|
+
// Persistence is optional; the in-memory dismissal still applies.
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return next;
|
|
138
|
+
});
|
|
139
|
+
}, []);
|
|
140
|
+
|
|
141
|
+
const ctx = useMemo(() => ({ user, pushState }), [user, pushState]);
|
|
142
|
+
const activeSteps = useMemo(() => {
|
|
143
|
+
if (!configLoaded || !user) return [];
|
|
144
|
+
return selectActiveSteps(descriptors, configMap, ctx, dismissedSet);
|
|
145
|
+
}, [configLoaded, user, descriptors, configMap, ctx, dismissedSet]);
|
|
146
|
+
|
|
147
|
+
const value = useMemo(
|
|
148
|
+
() => ({ activeSteps, dismissStep, ctx, configMap, setConfigMap }),
|
|
149
|
+
[activeSteps, dismissStep, ctx, configMap],
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
return <OnboardingContext.Provider value={value}>{children}</OnboardingContext.Provider>;
|
|
153
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import React, { useContext, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { useTranslation } from 'react-i18next';
|
|
3
|
+
import Box from '@mui/material/Box';
|
|
4
|
+
import Dialog from '@mui/material/Dialog';
|
|
5
|
+
import DialogContent from '@mui/material/DialogContent';
|
|
6
|
+
import DialogTitle from '@mui/material/DialogTitle';
|
|
7
|
+
import LinearProgress from '@mui/material/LinearProgress';
|
|
8
|
+
import Typography from '@mui/material/Typography';
|
|
9
|
+
import { AuthContext } from '../auth/AuthContext';
|
|
10
|
+
import { useOnboarding } from './OnboardingProvider';
|
|
11
|
+
|
|
12
|
+
export function OnboardingWizard() {
|
|
13
|
+
const { t } = useTranslation();
|
|
14
|
+
const onboarding = useOnboarding();
|
|
15
|
+
const authContext = useContext(AuthContext);
|
|
16
|
+
const [sessionDismissed, setSessionDismissed] = useState(() => new Set());
|
|
17
|
+
const totalRef = useRef(null);
|
|
18
|
+
const [completed, setCompleted] = useState(0);
|
|
19
|
+
const activeSteps = onboarding?.activeSteps || [];
|
|
20
|
+
const visibleSteps = activeSteps.filter((step) => !sessionDismissed.has(step.id));
|
|
21
|
+
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
if (visibleSteps.length > 0 && totalRef.current === null) {
|
|
24
|
+
totalRef.current = visibleSteps.length;
|
|
25
|
+
setCompleted(0);
|
|
26
|
+
}
|
|
27
|
+
if (visibleSteps.length === 0) {
|
|
28
|
+
totalRef.current = null;
|
|
29
|
+
setCompleted(0);
|
|
30
|
+
}
|
|
31
|
+
}, [visibleSteps.length]);
|
|
32
|
+
|
|
33
|
+
if (!onboarding || visibleSteps.length === 0) return null;
|
|
34
|
+
|
|
35
|
+
const { dismissStep, ctx } = onboarding;
|
|
36
|
+
const currentStep = visibleSteps[0];
|
|
37
|
+
const total = totalRef.current || visibleSteps.length;
|
|
38
|
+
const progress = total > 1 ? Math.round((completed / total) * 100) : 0;
|
|
39
|
+
const StepComponent = currentStep.Component;
|
|
40
|
+
|
|
41
|
+
const completeCurrentStep = () => {
|
|
42
|
+
setSessionDismissed((previous) => new Set([...previous, currentStep.id]));
|
|
43
|
+
setCompleted((count) => count + 1);
|
|
44
|
+
authContext?.refreshUser?.()?.catch(() => {
|
|
45
|
+
// Best-effort refresh; the step itself already persisted successfully.
|
|
46
|
+
});
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const dismissCurrentStep = () => {
|
|
50
|
+
if (currentStep.persistDismissed) dismissStep(currentStep.id);
|
|
51
|
+
completeCurrentStep();
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<Dialog open fullWidth maxWidth="sm" disableEscapeKeyDown={currentStep.blocking} onClose={currentStep.blocking ? undefined : dismissCurrentStep}>
|
|
56
|
+
<DialogTitle sx={{ pb: 0 }}>
|
|
57
|
+
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
|
58
|
+
<Typography variant="subtitle2" color="text.secondary">
|
|
59
|
+
{total > 1 ? t('Onboarding.STEP_COUNTER', { current: completed + 1, total }) : t('Onboarding.SETUP')}
|
|
60
|
+
</Typography>
|
|
61
|
+
</Box>
|
|
62
|
+
{total > 1 && <LinearProgress variant="determinate" value={progress} sx={{ mt: 1, borderRadius: 1 }} />}
|
|
63
|
+
</DialogTitle>
|
|
64
|
+
<DialogContent sx={{ pt: 2 }}>
|
|
65
|
+
<StepComponent onComplete={completeCurrentStep} onDismiss={dismissCurrentStep} ctx={ctx} />
|
|
66
|
+
</DialogContent>
|
|
67
|
+
</Dialog>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export default OnboardingWizard;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import apiClient from '../auth/apiClient';
|
|
2
|
+
|
|
3
|
+
const STEP_CONFIG_URL = '/api/onboarding/step-config/';
|
|
4
|
+
|
|
5
|
+
export async function getOnboardingStepConfig() {
|
|
6
|
+
const response = await apiClient.get(STEP_CONFIG_URL);
|
|
7
|
+
return response.data;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function patchOnboardingStepConfig(payload) {
|
|
11
|
+
const response = await apiClient.patch(STEP_CONFIG_URL, payload);
|
|
12
|
+
return response.data;
|
|
13
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure onboarding step selection.
|
|
3
|
+
*
|
|
4
|
+
* @param {Array} descriptors Step descriptors in display order.
|
|
5
|
+
* @param {Object} configMap Enabled state keyed by dcm onboarding step key.
|
|
6
|
+
* @param {Object} ctx Runtime context supplied to each step condition.
|
|
7
|
+
* @param {Set} dismissedSet Persistently dismissed step ids.
|
|
8
|
+
* @returns {Array} Steps that should be shown.
|
|
9
|
+
*/
|
|
10
|
+
export function selectActiveSteps(descriptors, configMap, ctx, dismissedSet = new Set()) {
|
|
11
|
+
return descriptors.filter((step) => {
|
|
12
|
+
if (configMap[step.id] === false) return false;
|
|
13
|
+
if (step.persistDismissed && dismissedSet.has(step.id)) return false;
|
|
14
|
+
return step.condition(ctx);
|
|
15
|
+
});
|
|
16
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import { useTranslation } from 'react-i18next';
|
|
3
|
+
import Alert from '@mui/material/Alert';
|
|
4
|
+
import Box from '@mui/material/Box';
|
|
5
|
+
import Button from '@mui/material/Button';
|
|
6
|
+
import CircularProgress from '@mui/material/CircularProgress';
|
|
7
|
+
import Typography from '@mui/material/Typography';
|
|
8
|
+
import NotificationsIcon from '@mui/icons-material/Notifications';
|
|
9
|
+
import {
|
|
10
|
+
getVapidPublicKey,
|
|
11
|
+
patchNotificationPreferences,
|
|
12
|
+
savePushSubscription,
|
|
13
|
+
urlBase64ToUint8Array,
|
|
14
|
+
} from '../../notifications/api';
|
|
15
|
+
|
|
16
|
+
export function BrowserPushStep({ onComplete, onDismiss }) {
|
|
17
|
+
const { t } = useTranslation();
|
|
18
|
+
const [loading, setLoading] = useState(false);
|
|
19
|
+
const [error, setError] = useState('');
|
|
20
|
+
|
|
21
|
+
const enable = async () => {
|
|
22
|
+
setLoading(true);
|
|
23
|
+
setError('');
|
|
24
|
+
try {
|
|
25
|
+
const permission = await Notification.requestPermission();
|
|
26
|
+
if (permission !== 'granted') {
|
|
27
|
+
setError(t('NotificationSettings.PUSH_DENIED'));
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const vapidPublicKey = await getVapidPublicKey();
|
|
31
|
+
const registration = await navigator.serviceWorker.ready;
|
|
32
|
+
const subscription = await registration.pushManager.subscribe({
|
|
33
|
+
userVisibleOnly: true,
|
|
34
|
+
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
|
|
35
|
+
});
|
|
36
|
+
await savePushSubscription(subscription.toJSON(), navigator.userAgent);
|
|
37
|
+
await patchNotificationPreferences({ push_opt_in: true });
|
|
38
|
+
onComplete();
|
|
39
|
+
} catch (requestError) {
|
|
40
|
+
if (requestError?.response?.status === 409) {
|
|
41
|
+
setError(t('NotificationSettings.PUSH_CONFLICT'));
|
|
42
|
+
} else {
|
|
43
|
+
setError(t('NotificationSettings.PUSH_ERROR'));
|
|
44
|
+
}
|
|
45
|
+
} finally {
|
|
46
|
+
setLoading(false);
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
|
52
|
+
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}><NotificationsIcon color="primary" /><Typography variant="h6">{t('Onboarding.BROWSER_PUSH_TITLE')}</Typography></Box>
|
|
53
|
+
<Typography variant="body2" color="text.secondary">{t('Onboarding.BROWSER_PUSH_BODY')}</Typography>
|
|
54
|
+
{error && <Alert severity="warning" onClose={() => setError('')}>{error}</Alert>}
|
|
55
|
+
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
|
|
56
|
+
<Button variant="text" onClick={onDismiss} disabled={loading}>{t('Onboarding.SKIP')}</Button>
|
|
57
|
+
<Button variant="contained" onClick={enable} disabled={loading} startIcon={loading ? <CircularProgress size={16} /> : undefined}>{t('NotificationSettings.PUSH_ENABLE')}</Button>
|
|
58
|
+
</Box>
|
|
59
|
+
</Box>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export default BrowserPushStep;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import { useTranslation } from 'react-i18next';
|
|
3
|
+
import Alert from '@mui/material/Alert';
|
|
4
|
+
import Box from '@mui/material/Box';
|
|
5
|
+
import Button from '@mui/material/Button';
|
|
6
|
+
import CircularProgress from '@mui/material/CircularProgress';
|
|
7
|
+
import TextField from '@mui/material/TextField';
|
|
8
|
+
import Typography from '@mui/material/Typography';
|
|
9
|
+
import PersonIcon from '@mui/icons-material/Person';
|
|
10
|
+
import { updateUserProfile } from '../../auth/authApi';
|
|
11
|
+
|
|
12
|
+
export function CompleteNameStep({ onComplete }) {
|
|
13
|
+
const { t } = useTranslation();
|
|
14
|
+
const [firstName, setFirstName] = useState('');
|
|
15
|
+
const [lastName, setLastName] = useState('');
|
|
16
|
+
const [loading, setLoading] = useState(false);
|
|
17
|
+
const [error, setError] = useState('');
|
|
18
|
+
|
|
19
|
+
const submit = async (event) => {
|
|
20
|
+
event.preventDefault();
|
|
21
|
+
if (!firstName.trim() || !lastName.trim()) return;
|
|
22
|
+
setLoading(true);
|
|
23
|
+
setError('');
|
|
24
|
+
try {
|
|
25
|
+
await updateUserProfile({ first_name: firstName.trim(), last_name: lastName.trim() });
|
|
26
|
+
onComplete();
|
|
27
|
+
} catch {
|
|
28
|
+
setError(t('Onboarding.COMPLETE_NAME_ERROR'));
|
|
29
|
+
} finally {
|
|
30
|
+
setLoading(false);
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<Box component="form" onSubmit={submit} sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
|
36
|
+
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}><PersonIcon color="primary" /><Typography variant="h6">{t('Onboarding.COMPLETE_NAME_TITLE')}</Typography></Box>
|
|
37
|
+
<Typography variant="body2" color="text.secondary">{t('Onboarding.COMPLETE_NAME_BODY')}</Typography>
|
|
38
|
+
{error && <Alert severity="error" onClose={() => setError('')}>{error}</Alert>}
|
|
39
|
+
<TextField label={t('Onboarding.FIRST_NAME_LABEL')} value={firstName} onChange={(event) => setFirstName(event.target.value)} required fullWidth size="small" />
|
|
40
|
+
<TextField label={t('Onboarding.LAST_NAME_LABEL')} value={lastName} onChange={(event) => setLastName(event.target.value)} required fullWidth size="small" />
|
|
41
|
+
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}><Button variant="contained" type="submit" disabled={loading || !firstName.trim() || !lastName.trim()} startIcon={loading ? <CircularProgress size={16} /> : undefined}>{t('Onboarding.SAVE')}</Button></Box>
|
|
42
|
+
</Box>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export default CompleteNameStep;
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import { useTranslation } from 'react-i18next';
|
|
3
|
+
import Alert from '@mui/material/Alert';
|
|
4
|
+
import Box from '@mui/material/Box';
|
|
5
|
+
import Button from '@mui/material/Button';
|
|
6
|
+
import Checkbox from '@mui/material/Checkbox';
|
|
7
|
+
import CircularProgress from '@mui/material/CircularProgress';
|
|
8
|
+
import FormControlLabel from '@mui/material/FormControlLabel';
|
|
9
|
+
import Link from '@mui/material/Link';
|
|
10
|
+
import Typography from '@mui/material/Typography';
|
|
11
|
+
import PrivacyTipIcon from '@mui/icons-material/PrivacyTip';
|
|
12
|
+
import { fetchCookieStatement, fetchPrivacyStatement, updateUserProfile } from '../../auth/authApi';
|
|
13
|
+
|
|
14
|
+
export function CookieConsentStep({ onComplete }) {
|
|
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);
|
|
22
|
+
const [loading, setLoading] = useState(false);
|
|
23
|
+
const [error, setError] = useState('');
|
|
24
|
+
|
|
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;
|
|
50
|
+
setLoading(true);
|
|
51
|
+
setError('');
|
|
52
|
+
try {
|
|
53
|
+
await updateUserProfile({
|
|
54
|
+
accepted_privacy_statement: true,
|
|
55
|
+
accepted_convenience_cookies: cookiesAccepted,
|
|
56
|
+
});
|
|
57
|
+
onComplete();
|
|
58
|
+
} catch {
|
|
59
|
+
setError(t('Onboarding.PRIVACY_COOKIES_ERROR'));
|
|
60
|
+
} finally {
|
|
61
|
+
setLoading(false);
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
return (
|
|
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>
|
|
72
|
+
{error && <Alert severity="error" onClose={() => setError('')}>{error}</Alert>}
|
|
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>
|
|
129
|
+
</Box>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export default CookieConsentStep;
|
|
@@ -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,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
|
+
});
|