@micha.bigler/ui-core-micha 2.7.0 → 2.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -14,7 +14,12 @@ export const onboardingTranslations = {
14
14
  'Onboarding.LAST_NAME_LABEL': { de: 'Nachname', fr: 'Nom', en: 'Last name', sw: 'Jina la mwisho' },
15
15
  'Onboarding.SAVE': { de: 'Speichern', fr: 'Enregistrer', en: 'Save', sw: 'Hifadhi' },
16
16
  'Onboarding.COMPLETE_NAME_ERROR': { de: 'Der Name konnte nicht gespeichert werden. Bitte versuche es erneut.', fr: 'Impossible d’enregistrer votre nom. Veuillez réessayer.', en: 'Your name could not be saved. Please try again.', sw: 'Jina lako halikuweza kuhifadhiwa. Tafadhali jaribu tena.' },
17
- 'Onboarding.BROWSER_PUSH_TITLE': { de: 'Push-Benachrichtigungen', fr: 'Notifications push', en: 'Push notifications', sw: 'Arifa za push' },
18
- '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.' },
19
24
  'Onboarding.SKIP': { de: 'Überspringen', fr: 'Passer', en: 'Skip', sw: 'Ruka' },
20
25
  };
@@ -3,7 +3,6 @@ import React, { useEffect, useState } from 'react';
3
3
  import { useTranslation } from 'react-i18next';
4
4
  import Alert from '@mui/material/Alert';
5
5
  import Box from '@mui/material/Box';
6
- import Button from '@mui/material/Button';
7
6
  import CircularProgress from '@mui/material/CircularProgress';
8
7
  import Divider from '@mui/material/Divider';
9
8
  import FormControlLabel from '@mui/material/FormControlLabel';
@@ -143,6 +142,6 @@ export function NotificationSettings() {
143
142
  if (loading) {
144
143
  return _jsx(Box, { sx: { display: 'flex', justifyContent: 'center', py: 4 }, children: _jsx(CircularProgress, {}) });
145
144
  }
146
- return (_jsxs(Box, { sx: { maxWidth: 520 }, children: [_jsxs(Typography, { variant: "h6", gutterBottom: true, sx: { display: 'flex', alignItems: 'center', gap: 1 }, children: [_jsx(NotificationsIcon, {}), t('NotificationSettings.TITLE')] }), _jsx(Typography, { variant: "body2", color: "text.secondary", gutterBottom: true, children: t('NotificationSettings.SUBTITLE') }), error && _jsx(Alert, { severity: "error", sx: { mb: 2 }, onClose: () => setError(''), children: error }), conflict && _jsx(Alert, { severity: "warning", sx: { mb: 2 }, onClose: () => setConflict(''), children: conflict }), _jsx(Box, { sx: { py: 2 }, children: _jsx(FormControlLabel, { control: _jsx(Switch, { checked: Boolean(preferences === null || preferences === void 0 ? void 0 : preferences.email_opt_in), onChange: handleEmailToggle, disabled: savingEmail }), label: _jsxs(Box, { children: [_jsx(Typography, { variant: "body1", children: t('NotificationSettings.EMAIL_LABEL') }), _jsx(Typography, { variant: "caption", color: "text.secondary", children: t('NotificationSettings.EMAIL_HINT') })] }), labelPlacement: "end", sx: { alignItems: 'flex-start', ml: 0 } }) }), _jsx(Divider, {}), _jsxs(Box, { sx: { py: 2 }, children: [_jsx(Typography, { variant: "body1", gutterBottom: true, children: t('NotificationSettings.PUSH_LABEL') }), _jsx(Typography, { variant: "caption", color: "text.secondary", display: "block", sx: { mb: 1.5 }, children: t('NotificationSettings.PUSH_HINT') }), iosNeedsInstall && _jsx(Alert, { severity: "info", sx: { mb: 1.5 }, children: t('NotificationSettings.IOS_HINT') }), !pushSupported && !iosNeedsInstall && _jsx(Alert, { severity: "warning", children: t('NotificationSettings.PUSH_NOT_SUPPORTED') }), pushSupported && !iosNeedsInstall && (pushSubscribed ? (_jsx(Button, { variant: "outlined", color: "error", onClick: handleDisablePush, disabled: savingPush, startIcon: savingPush ? _jsx(CircularProgress, { size: 16 }) : undefined, children: t('NotificationSettings.PUSH_DISABLE') })) : (_jsx(Button, { variant: "contained", onClick: handleEnablePush, disabled: savingPush, startIcon: savingPush ? _jsx(CircularProgress, { size: 16 }) : undefined, children: t('NotificationSettings.PUSH_ENABLE') })))] })] }));
145
+ return (_jsxs(Box, { sx: { maxWidth: 520 }, children: [_jsxs(Typography, { variant: "h6", gutterBottom: true, sx: { display: 'flex', alignItems: 'center', gap: 1 }, children: [_jsx(NotificationsIcon, {}), t('NotificationSettings.TITLE')] }), _jsx(Typography, { variant: "body2", color: "text.secondary", gutterBottom: true, children: t('NotificationSettings.SUBTITLE') }), error && _jsx(Alert, { severity: "error", sx: { mb: 2 }, onClose: () => setError(''), children: error }), conflict && _jsx(Alert, { severity: "warning", sx: { mb: 2 }, onClose: () => setConflict(''), children: conflict }), _jsx(Box, { sx: { py: 2 }, children: _jsx(FormControlLabel, { control: _jsx(Switch, { checked: Boolean(preferences === null || preferences === void 0 ? void 0 : preferences.email_opt_in), onChange: handleEmailToggle, disabled: savingEmail }), label: _jsxs(Box, { children: [_jsx(Typography, { variant: "body1", children: t('NotificationSettings.EMAIL_LABEL') }), _jsx(Typography, { variant: "caption", color: "text.secondary", children: t('NotificationSettings.EMAIL_HINT') })] }), labelPlacement: "end", sx: { alignItems: 'flex-start', ml: 0 } }) }), _jsx(Divider, {}), _jsxs(Box, { sx: { py: 2 }, children: [iosNeedsInstall && _jsx(Alert, { severity: "info", sx: { mb: 1.5 }, children: t('NotificationSettings.IOS_HINT') }), !pushSupported && !iosNeedsInstall && _jsx(Alert, { severity: "warning", sx: { mb: 1.5 }, children: t('NotificationSettings.PUSH_NOT_SUPPORTED') }), _jsx(FormControlLabel, { control: (_jsx(Switch, { checked: pushSubscribed, onChange: (event) => (event.target.checked ? handleEnablePush() : handleDisablePush()), disabled: savingPush || !pushSupported || iosNeedsInstall })), label: _jsxs(Box, { children: [_jsx(Typography, { variant: "body1", children: t('NotificationSettings.PUSH_LABEL') }), _jsx(Typography, { variant: "caption", color: "text.secondary", children: t('NotificationSettings.PUSH_HINT') })] }), labelPlacement: "end", sx: { alignItems: 'flex-start', ml: 0 } })] })] }));
147
146
  }
148
147
  export default NotificationSettings;
@@ -1,6 +1,7 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import React, { createContext, useCallback, useContext, useEffect, useMemo, useState, } from 'react';
3
3
  import { AuthContext } from '../auth/AuthContext';
4
+ import { getNotificationPreferences } from '../notifications/api';
4
5
  import { getOnboardingStepConfig } from './api';
5
6
  import { selectActiveSteps } from './stepSelection';
6
7
  import CookieConsentStep from './steps/CookieConsentStep';
@@ -27,11 +28,15 @@ export const UNIVERSAL_STEP_DESCRIPTORS = [
27
28
  },
28
29
  {
29
30
  id: 'browser_push',
30
- condition: (ctx) => { 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,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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@micha.bigler/ui-core-micha",
3
- "version": "2.7.0",
3
+ "version": "2.8.1",
4
4
  "main": "dist/index.js",
5
5
  "module": "dist/index.js",
6
6
  "repository": {
@@ -14,7 +14,12 @@ export const onboardingTranslations = {
14
14
  'Onboarding.LAST_NAME_LABEL': { de: 'Nachname', fr: 'Nom', en: 'Last name', sw: 'Jina la mwisho' },
15
15
  'Onboarding.SAVE': { de: 'Speichern', fr: 'Enregistrer', en: 'Save', sw: 'Hifadhi' },
16
16
  'Onboarding.COMPLETE_NAME_ERROR': { de: 'Der Name konnte nicht gespeichert werden. Bitte versuche es erneut.', fr: 'Impossible d’enregistrer votre nom. Veuillez réessayer.', en: 'Your name could not be saved. Please try again.', sw: 'Jina lako halikuweza kuhifadhiwa. Tafadhali jaribu tena.' },
17
- 'Onboarding.BROWSER_PUSH_TITLE': { de: 'Push-Benachrichtigungen', fr: 'Notifications push', en: 'Push notifications', sw: 'Arifa za push' },
18
- '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.' },
19
24
  'Onboarding.SKIP': { de: 'Überspringen', fr: 'Passer', en: 'Skip', sw: 'Ruka' },
20
25
  };
@@ -2,7 +2,6 @@ import React, { useEffect, useState } from 'react';
2
2
  import { useTranslation } from 'react-i18next';
3
3
  import Alert from '@mui/material/Alert';
4
4
  import Box from '@mui/material/Box';
5
- import Button from '@mui/material/Button';
6
5
  import CircularProgress from '@mui/material/CircularProgress';
7
6
  import Divider from '@mui/material/Divider';
8
7
  import FormControlLabel from '@mui/material/FormControlLabel';
@@ -172,15 +171,20 @@ export function NotificationSettings() {
172
171
  <Divider />
173
172
 
174
173
  <Box sx={{ py: 2 }}>
175
- <Typography variant="body1" gutterBottom>{t('NotificationSettings.PUSH_LABEL')}</Typography>
176
- <Typography variant="caption" color="text.secondary" display="block" sx={{ mb: 1.5 }}>{t('NotificationSettings.PUSH_HINT')}</Typography>
177
174
  {iosNeedsInstall && <Alert severity="info" sx={{ mb: 1.5 }}>{t('NotificationSettings.IOS_HINT')}</Alert>}
178
- {!pushSupported && !iosNeedsInstall && <Alert severity="warning">{t('NotificationSettings.PUSH_NOT_SUPPORTED')}</Alert>}
179
- {pushSupported && !iosNeedsInstall && (pushSubscribed ? (
180
- <Button variant="outlined" color="error" onClick={handleDisablePush} disabled={savingPush} startIcon={savingPush ? <CircularProgress size={16} /> : undefined}>{t('NotificationSettings.PUSH_DISABLE')}</Button>
181
- ) : (
182
- <Button variant="contained" onClick={handleEnablePush} disabled={savingPush} startIcon={savingPush ? <CircularProgress size={16} /> : undefined}>{t('NotificationSettings.PUSH_ENABLE')}</Button>
183
- ))}
175
+ {!pushSupported && !iosNeedsInstall && <Alert severity="warning" sx={{ mb: 1.5 }}>{t('NotificationSettings.PUSH_NOT_SUPPORTED')}</Alert>}
176
+ <FormControlLabel
177
+ control={(
178
+ <Switch
179
+ checked={pushSubscribed}
180
+ onChange={(event) => (event.target.checked ? handleEnablePush() : handleDisablePush())}
181
+ disabled={savingPush || !pushSupported || iosNeedsInstall}
182
+ />
183
+ )}
184
+ label={<Box><Typography variant="body1">{t('NotificationSettings.PUSH_LABEL')}</Typography><Typography variant="caption" color="text.secondary">{t('NotificationSettings.PUSH_HINT')}</Typography></Box>}
185
+ labelPlacement="end"
186
+ sx={{ alignItems: 'flex-start', ml: 0 }}
187
+ />
184
188
  </Box>
185
189
  </Box>
186
190
  );
@@ -7,6 +7,7 @@ import React, {
7
7
  useState,
8
8
  } from 'react';
9
9
  import { AuthContext } from '../auth/AuthContext';
10
+ import { getNotificationPreferences } from '../notifications/api';
10
11
  import { getOnboardingStepConfig } from './api';
11
12
  import { selectActiveSteps } from './stepSelection';
12
13
  import CookieConsentStep from './steps/CookieConsentStep';
@@ -34,11 +35,14 @@ export const UNIVERSAL_STEP_DESCRIPTORS = [
34
35
  },
35
36
  {
36
37
  id: 'browser_push',
37
- condition: (ctx) => Boolean(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,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
  );
@@ -0,0 +1,110 @@
1
+ // @vitest-environment jsdom
2
+ import React from 'react';
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4
+ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
5
+
6
+ const notificationsApi = vi.hoisted(() => ({
7
+ getVapidPublicKey: vi.fn(),
8
+ patchNotificationPreferences: vi.fn(),
9
+ savePushSubscription: vi.fn(),
10
+ urlBase64ToUint8Array: vi.fn(() => new Uint8Array([1, 2, 3])),
11
+ }));
12
+
13
+ vi.mock('../src/notifications/api', () => notificationsApi);
14
+ vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key) => key }) }));
15
+
16
+ import { BrowserPushStep } from '../src/onboarding/steps/BrowserPushStep';
17
+
18
+ function renderStep(ctx, props = {}) {
19
+ return render(
20
+ <BrowserPushStep
21
+ ctx={ctx}
22
+ onComplete={vi.fn()}
23
+ onDismiss={vi.fn()}
24
+ {...props}
25
+ />,
26
+ );
27
+ }
28
+
29
+ function installPushEnvironment(subscription) {
30
+ const registration = {
31
+ pushManager: {
32
+ subscribe: vi.fn(),
33
+ },
34
+ };
35
+ vi.stubGlobal('navigator', {
36
+ serviceWorker: { ready: Promise.resolve(registration) },
37
+ userAgent: 'Unit Test Browser',
38
+ });
39
+ Object.defineProperty(window, 'PushManager', { value: class PushManager {}, configurable: true });
40
+ return registration;
41
+ }
42
+
43
+ describe('BrowserPushStep', () => {
44
+ beforeEach(() => {
45
+ vi.clearAllMocks();
46
+ notificationsApi.patchNotificationPreferences.mockResolvedValue({});
47
+ notificationsApi.getVapidPublicKey.mockResolvedValue('AQID');
48
+ });
49
+
50
+ afterEach(() => {
51
+ cleanup();
52
+ vi.unstubAllGlobals();
53
+ });
54
+
55
+ it.each([
56
+ [false, true],
57
+ [true, false],
58
+ ])('saves the selected email notification preference', async (emailOptedIn, expectedEmailOptIn) => {
59
+ renderStep({ emailOptedIn, pushState: { supported: false, subscribed: false } });
60
+
61
+ fireEvent.click(screen.getByRole('switch', { name: /Onboarding.EMAIL_NOTIFICATIONS_LABEL/ }));
62
+
63
+ await waitFor(() => expect(notificationsApi.patchNotificationPreferences).toHaveBeenCalledWith({ email_opt_in: expectedEmailOptIn }));
64
+ });
65
+
66
+ it('reverts the email toggle when saving the preference fails', async () => {
67
+ notificationsApi.patchNotificationPreferences.mockRejectedValue(new Error('unavailable'));
68
+ renderStep({ emailOptedIn: false, pushState: { supported: false, subscribed: false } });
69
+
70
+ const toggle = screen.getByRole('switch', { name: /Onboarding.EMAIL_NOTIFICATIONS_LABEL/ });
71
+ fireEvent.click(toggle);
72
+
73
+ await waitFor(() => expect(toggle.checked).toBe(false));
74
+ expect(screen.getByRole('alert').textContent).toBe('NotificationSettings.SAVE_ERROR');
75
+ });
76
+
77
+ it('subscribes to push and opts in after saving the subscription', async () => {
78
+ const registration = installPushEnvironment();
79
+ const subscription = {
80
+ toJSON: vi.fn(() => ({ endpoint: 'https://fcm.googleapis.com/fcm/send/example' })),
81
+ };
82
+ const onComplete = vi.fn();
83
+ registration.pushManager.subscribe.mockResolvedValue(subscription);
84
+ vi.stubGlobal('Notification', { requestPermission: vi.fn().mockResolvedValue('granted') });
85
+
86
+ renderStep({ emailOptedIn: false, pushState: { supported: true, subscribed: false } }, { onComplete });
87
+ fireEvent.click(screen.getByRole('button', { name: 'NotificationSettings.PUSH_ENABLE' }));
88
+
89
+ await waitFor(() => expect(notificationsApi.patchNotificationPreferences).toHaveBeenCalledWith({ push_opt_in: true }));
90
+ expect(Notification.requestPermission).toHaveBeenCalledOnce();
91
+ expect(registration.pushManager.subscribe).toHaveBeenCalledWith({
92
+ userVisibleOnly: true,
93
+ applicationServerKey: new Uint8Array([1, 2, 3]),
94
+ });
95
+ expect(notificationsApi.savePushSubscription).toHaveBeenCalledWith(
96
+ { endpoint: 'https://fcm.googleapis.com/fcm/send/example' },
97
+ 'Unit Test Browser',
98
+ );
99
+ expect(notificationsApi.savePushSubscription.mock.invocationCallOrder[0])
100
+ .toBeLessThan(notificationsApi.patchNotificationPreferences.mock.invocationCallOrder[0]);
101
+ expect(onComplete).toHaveBeenCalledOnce();
102
+ });
103
+
104
+ it('shows an enabled state instead of a redundant push enable action', () => {
105
+ renderStep({ emailOptedIn: false, pushState: { supported: true, subscribed: true } });
106
+
107
+ expect(screen.getByText('Onboarding.PUSH_ENABLED')).toBeTruthy();
108
+ expect(screen.queryByRole('button', { name: 'NotificationSettings.PUSH_ENABLE' })).toBeNull();
109
+ });
110
+ });
@@ -49,7 +49,7 @@ describe('NotificationSettings push toggle', () => {
49
49
  vi.stubGlobal('Notification', { requestPermission: vi.fn().mockResolvedValue('granted') });
50
50
 
51
51
  render(<NotificationSettings />);
52
- fireEvent.click(await screen.findByRole('button', { name: 'NotificationSettings.PUSH_ENABLE' }));
52
+ fireEvent.click(await screen.findByRole('switch', { name: /PUSH_LABEL/ }));
53
53
 
54
54
  await waitFor(() => expect(notificationsApi.patchNotificationPreferences).toHaveBeenCalledWith({ push_opt_in: true }));
55
55
  expect(Notification.requestPermission).toHaveBeenCalledOnce();
@@ -73,7 +73,7 @@ describe('NotificationSettings push toggle', () => {
73
73
  installPushEnvironment(subscription);
74
74
 
75
75
  render(<NotificationSettings />);
76
- fireEvent.click(await screen.findByRole('button', { name: 'NotificationSettings.PUSH_DISABLE' }));
76
+ fireEvent.click(await screen.findByRole('switch', { name: /PUSH_LABEL/ }));
77
77
 
78
78
  await waitFor(() => expect(subscription.unsubscribe).toHaveBeenCalledOnce());
79
79
  expect(notificationsApi.removePushSubscription).toHaveBeenCalledWith({ endpoint: subscription.endpoint });
@@ -0,0 +1,56 @@
1
+ // @vitest-environment jsdom
2
+ import React from 'react';
3
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
4
+ import { cleanup, render, screen, waitFor } from '@testing-library/react';
5
+
6
+ const onboardingApi = vi.hoisted(() => ({
7
+ getOnboardingStepConfig: vi.fn(),
8
+ }));
9
+ const notificationsApi = vi.hoisted(() => ({
10
+ getNotificationPreferences: vi.fn(),
11
+ }));
12
+
13
+ vi.mock('../src/onboarding/api', () => onboardingApi);
14
+ vi.mock('../src/notifications/api', () => notificationsApi);
15
+
16
+ import { AuthContext } from '../src/auth/AuthContext';
17
+ import { OnboardingProvider, useOnboarding } from '../src/onboarding/OnboardingProvider';
18
+
19
+ function Probe() {
20
+ const onboarding = useOnboarding();
21
+ return <span data-testid="email-opted-in">{String(onboarding?.ctx.emailOptedIn)}</span>;
22
+ }
23
+
24
+ function renderProvider() {
25
+ return render(
26
+ <AuthContext.Provider value={{ user: { id: 1 } }}>
27
+ <OnboardingProvider><Probe /></OnboardingProvider>
28
+ </AuthContext.Provider>,
29
+ );
30
+ }
31
+
32
+ describe('OnboardingProvider notification preferences', () => {
33
+ beforeEach(() => {
34
+ cleanup();
35
+ vi.clearAllMocks();
36
+ onboardingApi.getOnboardingStepConfig.mockResolvedValue([]);
37
+ });
38
+
39
+ it('populates emailOptedIn from notification preferences', async () => {
40
+ notificationsApi.getNotificationPreferences.mockResolvedValue({ email_opt_in: true });
41
+
42
+ renderProvider();
43
+
44
+ await waitFor(() => expect(screen.getByTestId('email-opted-in').textContent).toBe('true'));
45
+ expect(notificationsApi.getNotificationPreferences).toHaveBeenCalledOnce();
46
+ });
47
+
48
+ it('defaults emailOptedIn to false when notification preferences cannot be loaded', async () => {
49
+ notificationsApi.getNotificationPreferences.mockRejectedValue(new Error('unavailable'));
50
+
51
+ renderProvider();
52
+
53
+ await waitFor(() => expect(notificationsApi.getNotificationPreferences).toHaveBeenCalledOnce());
54
+ expect(screen.getByTestId('email-opted-in').textContent).toBe('false');
55
+ });
56
+ });
@@ -1,7 +1,9 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
  import { UNIVERSAL_STEP_DESCRIPTORS } from '../src/onboarding/OnboardingProvider';
3
+ import { selectActiveSteps } from '../src/onboarding/stepSelection';
3
4
 
4
5
  const cookieConsentDescriptor = UNIVERSAL_STEP_DESCRIPTORS.find((step) => step.id === 'cookie_consent');
6
+ const browserPushDescriptor = UNIVERSAL_STEP_DESCRIPTORS.find((step) => step.id === 'browser_push');
5
7
 
6
8
  describe('cookie_consent descriptor condition', () => {
7
9
  it('is active when the user has not accepted the privacy statement, regardless of cookies', () => {
@@ -23,3 +25,45 @@ describe('cookie_consent descriptor condition', () => {
23
25
  expect(cookieConsentDescriptor.condition({ user: null })).toBe(false);
24
26
  });
25
27
  });
28
+
29
+ describe('browser_push descriptor condition', () => {
30
+ it('is active when push is supported and either channel remains unconfigured', () => {
31
+ expect(browserPushDescriptor.condition({
32
+ pushState: { supported: true, subscribed: false },
33
+ emailOptedIn: true,
34
+ })).toBe(true);
35
+ expect(browserPushDescriptor.condition({
36
+ pushState: { supported: true, subscribed: true },
37
+ emailOptedIn: false,
38
+ })).toBe(true);
39
+ });
40
+
41
+ it('is active when push is unsupported and email notifications are not enabled', () => {
42
+ expect(browserPushDescriptor.condition({
43
+ pushState: { supported: false, subscribed: false },
44
+ emailOptedIn: false,
45
+ })).toBe(true);
46
+ });
47
+
48
+ it('is inactive when push is unsupported with email notifications enabled or both channels are enabled', () => {
49
+ expect(browserPushDescriptor.condition({
50
+ pushState: { supported: false, subscribed: false },
51
+ emailOptedIn: true,
52
+ })).toBe(false);
53
+ expect(browserPushDescriptor.condition({
54
+ pushState: { supported: true, subscribed: true },
55
+ emailOptedIn: true,
56
+ })).toBe(false);
57
+ });
58
+
59
+ it('is excluded after persistent dismissal', () => {
60
+ const activeSteps = selectActiveSteps(
61
+ [browserPushDescriptor],
62
+ { browser_push: true },
63
+ { pushState: { supported: true, subscribed: false }, emailOptedIn: false },
64
+ new Set(['browser_push']),
65
+ );
66
+
67
+ expect(activeSteps).toEqual([]);
68
+ });
69
+ });