@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.
@@ -51,18 +51,22 @@ jobs:
51
51
  }
52
52
 
53
53
  const current = JSON.parse(fs.readFileSync('package.json', 'utf8')).version;
54
- let previous;
54
+ let published;
55
55
  try {
56
- previous = JSON.parse(
57
- cp.execFileSync('git', ['show', 'HEAD^:package.json'], { encoding: 'utf8' })
58
- ).version;
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('No previous package.json found. Publishing.');
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(`Previous version: ${previous}`);
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, previous)) {
89
+ if (greaterThan(current, published)) {
86
90
  console.log('Version increased. Publishing.');
87
91
  writeOutput('true');
88
92
  } else {
@@ -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.COOKIE_CONSENT_TITLE': { de: 'Cookie-Einstellungen', fr: 'Préférences des cookies', en: 'Cookie preferences', sw: 'Mapendeleo ya vidakuzi' },
5
- 'Onboarding.COOKIE_CONSENT_BODY': { de: 'Diese App verwendet optionale Cookies, um deine Einstellungen und Präferenzen zu speichern. Möchtest du diese aktivieren?', fr: 'Cette application utilise des cookies facultatifs pour enregistrer vos réglages et préférences. Souhaitez-vous les activer ?', en: 'This app uses optional cookies to save your settings and preferences. Would you like to enable them?', sw: 'Programu hii hutumia vidakuzi vya hiari kuhifadhi mipangilio na mapendeleo yako. Je, ungependa kuviwasha?' },
6
- 'Onboarding.COOKIE_CONSENT_ACCEPT': { de: 'Akzeptieren', fr: 'Accepter', en: 'Accept', sw: 'Kubali' },
7
- 'Onboarding.COOKIE_CONSENT_ERROR': { de: 'Die Cookie-Einstellung konnte nicht gespeichert werden. Bitte versuche es erneut.', fr: 'Impossible d’enregistrer votre préférence de cookies. Veuillez réessayer.', en: 'Your cookie preference could not be saved. Please try again.', sw: 'Upendeleo wako wa vidakuzi haukuweza kuhifadhiwa. Tafadhali jaribu tena.' },
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.BROWSER_PUSH_TITLE': { de: 'Push-Benachrichtigungen', fr: 'Notifications push', en: 'Push notifications', sw: 'Arifa za push' },
15
- 'Onboarding.BROWSER_PUSH_BODY': { de: 'Erhalte Benachrichtigungen direkt im Browser, auch wenn die App nicht geöffnet ist.', fr: 'Recevez des notifications directement dans votre navigateur, même lorsque l’application est fermée.', en: 'Receive notifications directly in your browser, even when the app is not open.', sw: 'Pokea arifa moja kwa moja kwenye kivinjari chako, hata programu haijafunguliwa.' },
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.accepted_convenience_cookies),
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.COOKIE_CONSENT_TITLE',
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) => { var _a; return Boolean(((_a = ctx.pushState) === null || _a === void 0 ? void 0 : _a.supported) && !ctx.pushState.subscribed); },
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.BROWSER_PUSH_TITLE',
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.BROWSER_PUSH_TITLE') })] }), _jsx(Typography, { variant: "body2", color: "text.secondary", children: t('Onboarding.BROWSER_PUSH_BODY') }), error && _jsx(Alert, { severity: "warning", onClose: () => setError(''), children: error }), _jsxs(Box, { sx: { display: 'flex', justifyContent: 'flex-end', gap: 1 }, children: [_jsx(Button, { variant: "text", onClick: onDismiss, disabled: loading, children: t('Onboarding.SKIP') }), _jsx(Button, { variant: "contained", onClick: enable, disabled: loading, startIcon: loading ? _jsx(CircularProgress, { size: 16 }) : undefined, children: t('NotificationSettings.PUSH_ENABLE') })] })] }));
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 CookieIcon from '@mui/icons-material/Cookie';
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 accept = async () => {
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({ accepted_convenience_cookies: true });
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.COOKIE_CONSENT_ERROR'));
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(CookieIcon, { color: "primary" }), _jsx(Typography, { variant: "h6", children: t('Onboarding.COOKIE_CONSENT_TITLE') })] }), _jsx(Typography, { variant: "body2", color: "text.secondary", children: t('Onboarding.COOKIE_CONSENT_BODY') }), error && _jsx(Alert, { severity: "error", onClose: () => setError(''), children: error }), _jsx(Box, { sx: { display: 'flex', justifyContent: 'flex-end' }, children: _jsx(Button, { variant: "contained", onClick: accept, disabled: loading, startIcon: loading ? _jsx(CircularProgress, { size: 16 }) : undefined, children: t('Onboarding.COOKIE_CONSENT_ACCEPT') }) })] }));
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@micha.bigler/ui-core-micha",
3
- "version": "2.6.1",
3
+ "version": "2.8.0",
4
4
  "main": "dist/index.js",
5
5
  "module": "dist/index.js",
6
6
  "repository": {
@@ -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.COOKIE_CONSENT_TITLE': { de: 'Cookie-Einstellungen', fr: 'Préférences des cookies', en: 'Cookie preferences', sw: 'Mapendeleo ya vidakuzi' },
5
- 'Onboarding.COOKIE_CONSENT_BODY': { de: 'Diese App verwendet optionale Cookies, um deine Einstellungen und Präferenzen zu speichern. Möchtest du diese aktivieren?', fr: 'Cette application utilise des cookies facultatifs pour enregistrer vos réglages et préférences. Souhaitez-vous les activer ?', en: 'This app uses optional cookies to save your settings and preferences. Would you like to enable them?', sw: 'Programu hii hutumia vidakuzi vya hiari kuhifadhi mipangilio na mapendeleo yako. Je, ungependa kuviwasha?' },
6
- 'Onboarding.COOKIE_CONSENT_ACCEPT': { de: 'Akzeptieren', fr: 'Accepter', en: 'Accept', sw: 'Kubali' },
7
- 'Onboarding.COOKIE_CONSENT_ERROR': { de: 'Die Cookie-Einstellung konnte nicht gespeichert werden. Bitte versuche es erneut.', fr: 'Impossible d’enregistrer votre préférence de cookies. Veuillez réessayer.', en: 'Your cookie preference could not be saved. Please try again.', sw: 'Upendeleo wako wa vidakuzi haukuweza kuhifadhiwa. Tafadhali jaribu tena.' },
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.BROWSER_PUSH_TITLE': { de: 'Push-Benachrichtigungen', fr: 'Notifications push', en: 'Push notifications', sw: 'Arifa za push' },
15
- 'Onboarding.BROWSER_PUSH_BODY': { de: 'Erhalte Benachrichtigungen direkt im Browser, auch wenn die App nicht geöffnet ist.', fr: 'Recevez des notifications directement dans votre navigateur, même lorsque l’application est fermée.', en: 'Receive notifications directly in your browser, even when the app is not open.', sw: 'Pokea arifa moja kwa moja kwenye kivinjari chako, hata programu haijafunguliwa.' },
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.accepted_convenience_cookies),
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.COOKIE_CONSENT_TITLE',
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(ctx.pushState?.supported && !ctx.pushState.subscribed),
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.BROWSER_PUSH_TITLE',
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.BROWSER_PUSH_TITLE')}</Typography></Box>
53
- <Typography variant="body2" color="text.secondary">{t('Onboarding.BROWSER_PUSH_BODY')}</Typography>
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={enable} disabled={loading} startIcon={loading ? <CircularProgress size={16} /> : undefined}>{t('NotificationSettings.PUSH_ENABLE')}</Button>
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 CookieIcon from '@mui/icons-material/Cookie';
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 accept = async () => {
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({ accepted_convenience_cookies: true });
53
+ await updateUserProfile({
54
+ accepted_privacy_statement: true,
55
+ accepted_convenience_cookies: cookiesAccepted,
56
+ });
21
57
  onComplete();
22
58
  } catch {
23
- setError(t('Onboarding.COOKIE_CONSENT_ERROR'));
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 }}><CookieIcon color="primary" /><Typography variant="h6">{t('Onboarding.COOKIE_CONSENT_TITLE')}</Typography></Box>
32
- <Typography variant="body2" color="text.secondary">{t('Onboarding.COOKIE_CONSENT_BODY')}</Typography>
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
- <Box sx={{ display: 'flex', justifyContent: 'flex-end' }}><Button variant="contained" onClick={accept} disabled={loading} startIcon={loading ? <CircularProgress size={16} /> : undefined}>{t('Onboarding.COOKIE_CONSENT_ACCEPT')}</Button></Box>
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
+ });