@micha.bigler/ui-core-micha 2.5.0 → 2.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/publish.yml +15 -7
- package/dist/auth/AuthContext.js +6 -0
- package/dist/i18n/notificationsTranslations.js +17 -0
- package/dist/i18n/onboardingTranslations.js +20 -0
- package/dist/index.js +14 -0
- package/dist/notifications/NotificationSettings.js +148 -0
- package/dist/notifications/api.js +41 -0
- package/dist/notifications/serviceWorker/sw.js +49 -0
- package/dist/onboarding/OnboardingProvider.js +133 -0
- package/dist/onboarding/OnboardingWizard.js +53 -0
- package/dist/onboarding/api.js +10 -0
- package/dist/onboarding/stepSelection.js +18 -0
- package/dist/onboarding/steps/BrowserPushStep.js +49 -0
- package/dist/onboarding/steps/CompleteNameStep.js +37 -0
- package/dist/onboarding/steps/CookieConsentStep.js +68 -0
- package/package.json +7 -3
- package/src/auth/AuthContext.jsx +6 -0
- package/src/i18n/notificationsTranslations.ts +17 -0
- package/src/i18n/onboardingTranslations.ts +20 -0
- package/src/index.js +22 -0
- package/src/notifications/NotificationSettings.jsx +189 -0
- package/src/notifications/api.js +49 -0
- package/src/notifications/serviceWorker/sw.js +50 -0
- package/src/onboarding/OnboardingProvider.jsx +153 -0
- package/src/onboarding/OnboardingWizard.jsx +71 -0
- package/src/onboarding/api.js +13 -0
- package/src/onboarding/stepSelection.js +16 -0
- package/src/onboarding/steps/BrowserPushStep.jsx +63 -0
- package/src/onboarding/steps/CompleteNameStep.jsx +46 -0
- package/src/onboarding/steps/CookieConsentStep.jsx +133 -0
- package/tests/AuthContext.test.jsx +50 -0
- package/tests/CookieConsentStep.test.jsx +69 -0
- package/tests/NotificationSettings.test.jsx +82 -0
- package/tests/notificationsApi.test.js +41 -0
- package/tests/onboardingDescriptors.test.js +25 -0
- package/tests/stepSelection.test.js +53 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import React, { useState } from 'react';
|
|
3
|
+
import { useTranslation } from 'react-i18next';
|
|
4
|
+
import Alert from '@mui/material/Alert';
|
|
5
|
+
import Box from '@mui/material/Box';
|
|
6
|
+
import Button from '@mui/material/Button';
|
|
7
|
+
import CircularProgress from '@mui/material/CircularProgress';
|
|
8
|
+
import TextField from '@mui/material/TextField';
|
|
9
|
+
import Typography from '@mui/material/Typography';
|
|
10
|
+
import PersonIcon from '@mui/icons-material/Person';
|
|
11
|
+
import { updateUserProfile } from '../../auth/authApi';
|
|
12
|
+
export function CompleteNameStep({ onComplete }) {
|
|
13
|
+
const { t } = useTranslation();
|
|
14
|
+
const [firstName, setFirstName] = useState('');
|
|
15
|
+
const [lastName, setLastName] = useState('');
|
|
16
|
+
const [loading, setLoading] = useState(false);
|
|
17
|
+
const [error, setError] = useState('');
|
|
18
|
+
const submit = async (event) => {
|
|
19
|
+
event.preventDefault();
|
|
20
|
+
if (!firstName.trim() || !lastName.trim())
|
|
21
|
+
return;
|
|
22
|
+
setLoading(true);
|
|
23
|
+
setError('');
|
|
24
|
+
try {
|
|
25
|
+
await updateUserProfile({ first_name: firstName.trim(), last_name: lastName.trim() });
|
|
26
|
+
onComplete();
|
|
27
|
+
}
|
|
28
|
+
catch (_a) {
|
|
29
|
+
setError(t('Onboarding.COMPLETE_NAME_ERROR'));
|
|
30
|
+
}
|
|
31
|
+
finally {
|
|
32
|
+
setLoading(false);
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
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(PersonIcon, { color: "primary" }), _jsx(Typography, { variant: "h6", children: t('Onboarding.COMPLETE_NAME_TITLE') })] }), _jsx(Typography, { variant: "body2", color: "text.secondary", children: t('Onboarding.COMPLETE_NAME_BODY') }), error && _jsx(Alert, { severity: "error", onClose: () => setError(''), children: error }), _jsx(TextField, { label: t('Onboarding.FIRST_NAME_LABEL'), value: firstName, onChange: (event) => setFirstName(event.target.value), required: true, fullWidth: true, size: "small" }), _jsx(TextField, { label: t('Onboarding.LAST_NAME_LABEL'), value: lastName, onChange: (event) => setLastName(event.target.value), required: true, fullWidth: true, size: "small" }), _jsx(Box, { sx: { display: 'flex', justifyContent: 'flex-end' }, children: _jsx(Button, { variant: "contained", type: "submit", disabled: loading || !firstName.trim() || !lastName.trim(), startIcon: loading ? _jsx(CircularProgress, { size: 16 }) : undefined, children: t('Onboarding.SAVE') }) })] }));
|
|
36
|
+
}
|
|
37
|
+
export default CompleteNameStep;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import React, { useState } from 'react';
|
|
3
|
+
import { useTranslation } from 'react-i18next';
|
|
4
|
+
import Alert from '@mui/material/Alert';
|
|
5
|
+
import Box from '@mui/material/Box';
|
|
6
|
+
import Button from '@mui/material/Button';
|
|
7
|
+
import Checkbox from '@mui/material/Checkbox';
|
|
8
|
+
import CircularProgress from '@mui/material/CircularProgress';
|
|
9
|
+
import FormControlLabel from '@mui/material/FormControlLabel';
|
|
10
|
+
import Link from '@mui/material/Link';
|
|
11
|
+
import Typography from '@mui/material/Typography';
|
|
12
|
+
import PrivacyTipIcon from '@mui/icons-material/PrivacyTip';
|
|
13
|
+
import { fetchCookieStatement, fetchPrivacyStatement, updateUserProfile } from '../../auth/authApi';
|
|
14
|
+
export function CookieConsentStep({ onComplete }) {
|
|
15
|
+
const { t } = useTranslation();
|
|
16
|
+
const [privacyAccepted, setPrivacyAccepted] = useState(false);
|
|
17
|
+
const [cookiesAccepted, setCookiesAccepted] = useState(false);
|
|
18
|
+
const [privacyText, setPrivacyText] = useState('');
|
|
19
|
+
const [cookieText, setCookieText] = useState('');
|
|
20
|
+
const [showPrivacyText, setShowPrivacyText] = useState(false);
|
|
21
|
+
const [showCookieText, setShowCookieText] = useState(false);
|
|
22
|
+
const [loading, setLoading] = useState(false);
|
|
23
|
+
const [error, setError] = useState('');
|
|
24
|
+
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;
|
|
50
|
+
setLoading(true);
|
|
51
|
+
setError('');
|
|
52
|
+
try {
|
|
53
|
+
await updateUserProfile({
|
|
54
|
+
accepted_privacy_statement: true,
|
|
55
|
+
accepted_convenience_cookies: cookiesAccepted,
|
|
56
|
+
});
|
|
57
|
+
onComplete();
|
|
58
|
+
}
|
|
59
|
+
catch (_a) {
|
|
60
|
+
setError(t('Onboarding.PRIVACY_COOKIES_ERROR'));
|
|
61
|
+
}
|
|
62
|
+
finally {
|
|
63
|
+
setLoading(false);
|
|
64
|
+
}
|
|
65
|
+
};
|
|
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') }) })] }));
|
|
67
|
+
}
|
|
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.
|
|
3
|
+
"version": "2.7.0",
|
|
4
4
|
"main": "dist/index.js",
|
|
5
5
|
"module": "dist/index.js",
|
|
6
6
|
"repository": {
|
|
@@ -22,12 +22,16 @@
|
|
|
22
22
|
"react-router-dom": "^7.15.1"
|
|
23
23
|
},
|
|
24
24
|
"scripts": {
|
|
25
|
-
"build": "tsc -p tsconfig.build.json"
|
|
25
|
+
"build": "tsc -p tsconfig.build.json",
|
|
26
|
+
"test": "vitest run"
|
|
26
27
|
},
|
|
27
28
|
"devDependencies": {
|
|
28
29
|
"@mui/icons-material": "^7.3.11",
|
|
30
|
+
"@testing-library/react": "^16.3.2",
|
|
29
31
|
"i18next": "^26.2.0",
|
|
32
|
+
"jsdom": "^29.1.1",
|
|
30
33
|
"react-i18next": "^17.0.8",
|
|
31
|
-
"typescript": "^6.0.3"
|
|
34
|
+
"typescript": "^6.0.3",
|
|
35
|
+
"vitest": "^4.1.10"
|
|
32
36
|
}
|
|
33
37
|
}
|
package/src/auth/AuthContext.jsx
CHANGED
|
@@ -115,6 +115,11 @@ export const AuthProvider = ({ children }) => {
|
|
|
115
115
|
}));
|
|
116
116
|
};
|
|
117
117
|
|
|
118
|
+
const refreshUser = async () => {
|
|
119
|
+
const data = await fetchCurrentUser();
|
|
120
|
+
if (data) setUser(mapUserFromApi(data));
|
|
121
|
+
};
|
|
122
|
+
|
|
118
123
|
const logout = async () => {
|
|
119
124
|
try {
|
|
120
125
|
await logoutSession();
|
|
@@ -134,6 +139,7 @@ export const AuthProvider = ({ children }) => {
|
|
|
134
139
|
loading,
|
|
135
140
|
login,
|
|
136
141
|
logout,
|
|
142
|
+
refreshUser,
|
|
137
143
|
}}
|
|
138
144
|
>
|
|
139
145
|
{children}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export const notificationsTranslations = {
|
|
2
|
+
'NotificationSettings.TITLE': { de: 'Benachrichtigungen', fr: 'Notifications', en: 'Notifications', sw: 'Arifa' },
|
|
3
|
+
'NotificationSettings.SUBTITLE': { de: 'Lege fest, wie du informiert werden möchtest.', fr: 'Choisissez comment vous souhaitez être informé.', en: 'Choose how you want to be notified.', sw: 'Chagua jinsi unavyotaka kuarifiwa.' },
|
|
4
|
+
'NotificationSettings.LOAD_ERROR': { de: 'Benachrichtigungseinstellungen konnten nicht geladen werden.', fr: 'Impossible de charger les paramètres de notification.', en: 'Notification settings could not be loaded.', sw: 'Mipangilio ya arifa haikuweza kupakiwa.' },
|
|
5
|
+
'NotificationSettings.SAVE_ERROR': { de: 'Benachrichtigungseinstellungen konnten nicht gespeichert werden.', fr: 'Impossible d’enregistrer les paramètres de notification.', en: 'Notification settings could not be saved.', sw: 'Mipangilio ya arifa haikuweza kuhifadhiwa.' },
|
|
6
|
+
'NotificationSettings.EMAIL_LABEL': { de: 'E-Mail-Benachrichtigungen', fr: 'Notifications par e-mail', en: 'Email notifications', sw: 'Arifa za barua pepe' },
|
|
7
|
+
'NotificationSettings.EMAIL_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.' },
|
|
8
|
+
'NotificationSettings.PUSH_LABEL': { de: 'Push-Benachrichtigungen', fr: 'Notifications push', en: 'Push notifications', sw: 'Arifa za push' },
|
|
9
|
+
'NotificationSettings.PUSH_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.' },
|
|
10
|
+
'NotificationSettings.PUSH_ENABLE': { de: 'Push aktivieren', fr: 'Activer les notifications push', en: 'Enable push', sw: 'Washa push' },
|
|
11
|
+
'NotificationSettings.PUSH_DISABLE': { de: 'Push auf diesem Gerät deaktivieren', fr: 'Désactiver les notifications push sur cet appareil', en: 'Disable push on this device', sw: 'Zima push kwenye kifaa hiki' },
|
|
12
|
+
'NotificationSettings.PUSH_DENIED': { de: 'Die Browserberechtigung für Push-Benachrichtigungen wurde verweigert.', fr: 'L’autorisation du navigateur pour les notifications push a été refusée.', en: 'Browser permission for push notifications was denied.', sw: 'Ruhusa ya kivinjari kwa arifa za push imekataliwa.' },
|
|
13
|
+
'NotificationSettings.PUSH_ERROR': { de: 'Push-Benachrichtigungen konnten nicht aktualisiert werden.', fr: 'Impossible de mettre à jour les notifications push.', en: 'Push notifications could not be updated.', sw: 'Arifa za push hazikuweza kusasishwa.' },
|
|
14
|
+
'NotificationSettings.PUSH_CONFLICT': { de: 'Dieses Gerät ist bereits mit einem anderen Konto für Push-Benachrichtigungen verbunden.', fr: 'Cet appareil est déjà associé à un autre compte pour les notifications push.', en: 'This device is already associated with another account for push notifications.', sw: 'Kifaa hiki tayari kimeunganishwa na akaunti nyingine kwa arifa za push.' },
|
|
15
|
+
'NotificationSettings.PUSH_NOT_SUPPORTED': { de: 'Push-Benachrichtigungen werden von diesem Browser nicht unterstützt.', fr: 'Les notifications push ne sont pas prises en charge par ce navigateur.', en: 'Push notifications are not supported by this browser.', sw: 'Arifa za push hazitumiki na kivinjari hiki.' },
|
|
16
|
+
'NotificationSettings.IOS_HINT': { de: 'Füge diese App zuerst zum Home-Bildschirm hinzu, um Push-Benachrichtigungen zu aktivieren.', fr: 'Ajoutez d’abord cette application à l’écran d’accueil pour activer les notifications push.', en: 'Add this app to your home screen first to enable push notifications.', sw: 'Ongeza programu hii kwenye skrini yako ya mwanzo kwanza ili kuwasha arifa za push.' },
|
|
17
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export const onboardingTranslations = {
|
|
2
|
+
'Onboarding.SETUP': { de: 'Einrichtung', fr: 'Configuration', en: 'Setup', sw: 'Usanidi' },
|
|
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.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.' },
|
|
11
|
+
'Onboarding.COMPLETE_NAME_TITLE': { de: 'Dein Name', fr: 'Votre nom', en: 'Your name', sw: 'Jina lako' },
|
|
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.' },
|
|
13
|
+
'Onboarding.FIRST_NAME_LABEL': { de: 'Vorname', fr: 'Prénom', en: 'First name', sw: 'Jina la kwanza' },
|
|
14
|
+
'Onboarding.LAST_NAME_LABEL': { de: 'Nachname', fr: 'Nom', en: 'Last name', sw: 'Jina la mwisho' },
|
|
15
|
+
'Onboarding.SAVE': { de: 'Speichern', fr: 'Enregistrer', en: 'Save', sw: 'Hifadhi' },
|
|
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.' },
|
|
19
|
+
'Onboarding.SKIP': { de: 'Überspringen', fr: 'Passer', en: 'Skip', sw: 'Ruka' },
|
|
20
|
+
};
|
package/src/index.js
CHANGED
|
@@ -41,3 +41,25 @@ export { QrSignupManager } from './components/QrSignupManager';
|
|
|
41
41
|
|
|
42
42
|
// --- 6. Translations ---
|
|
43
43
|
export { authTranslations } from './i18n/authTranslations';
|
|
44
|
+
|
|
45
|
+
// --- 7. Notifications ---
|
|
46
|
+
export { NotificationSettings } from './notifications/NotificationSettings';
|
|
47
|
+
export * from './notifications/api';
|
|
48
|
+
|
|
49
|
+
// --- 8. Onboarding ---
|
|
50
|
+
export * from './onboarding/api';
|
|
51
|
+
export { selectActiveSteps } from './onboarding/stepSelection';
|
|
52
|
+
export {
|
|
53
|
+
OnboardingContext,
|
|
54
|
+
OnboardingProvider,
|
|
55
|
+
UNIVERSAL_STEP_DESCRIPTORS,
|
|
56
|
+
useOnboarding,
|
|
57
|
+
} from './onboarding/OnboardingProvider';
|
|
58
|
+
export { OnboardingWizard } from './onboarding/OnboardingWizard';
|
|
59
|
+
export { CookieConsentStep } from './onboarding/steps/CookieConsentStep';
|
|
60
|
+
export { CompleteNameStep } from './onboarding/steps/CompleteNameStep';
|
|
61
|
+
export { BrowserPushStep } from './onboarding/steps/BrowserPushStep';
|
|
62
|
+
|
|
63
|
+
// --- 9. Translations ---
|
|
64
|
+
export { notificationsTranslations } from './i18n/notificationsTranslations';
|
|
65
|
+
export { onboardingTranslations } from './i18n/onboardingTranslations';
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import React, { useEffect, useState } from 'react';
|
|
2
|
+
import { useTranslation } from 'react-i18next';
|
|
3
|
+
import Alert from '@mui/material/Alert';
|
|
4
|
+
import Box from '@mui/material/Box';
|
|
5
|
+
import Button from '@mui/material/Button';
|
|
6
|
+
import CircularProgress from '@mui/material/CircularProgress';
|
|
7
|
+
import Divider from '@mui/material/Divider';
|
|
8
|
+
import FormControlLabel from '@mui/material/FormControlLabel';
|
|
9
|
+
import Switch from '@mui/material/Switch';
|
|
10
|
+
import Typography from '@mui/material/Typography';
|
|
11
|
+
import NotificationsIcon from '@mui/icons-material/Notifications';
|
|
12
|
+
import {
|
|
13
|
+
getNotificationPreferences,
|
|
14
|
+
getVapidPublicKey,
|
|
15
|
+
patchNotificationPreferences,
|
|
16
|
+
removePushSubscription,
|
|
17
|
+
savePushSubscription,
|
|
18
|
+
urlBase64ToUint8Array,
|
|
19
|
+
} from './api';
|
|
20
|
+
|
|
21
|
+
function getPushSupport() {
|
|
22
|
+
return typeof navigator !== 'undefined'
|
|
23
|
+
&& typeof window !== 'undefined'
|
|
24
|
+
&& 'serviceWorker' in navigator
|
|
25
|
+
&& 'PushManager' in window;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function getIosInstallState() {
|
|
29
|
+
if (typeof navigator === 'undefined' || typeof window === 'undefined') return false;
|
|
30
|
+
const isIos = /iphone|ipad|ipod/i.test(navigator.userAgent || '');
|
|
31
|
+
const standalone = window.matchMedia?.('(display-mode: standalone)').matches
|
|
32
|
+
|| window.navigator.standalone === true;
|
|
33
|
+
return isIos && !standalone;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function NotificationSettings() {
|
|
37
|
+
const { t } = useTranslation();
|
|
38
|
+
const [preferences, setPreferences] = useState(null);
|
|
39
|
+
const [loading, setLoading] = useState(true);
|
|
40
|
+
const [savingEmail, setSavingEmail] = useState(false);
|
|
41
|
+
const [savingPush, setSavingPush] = useState(false);
|
|
42
|
+
const [pushSubscribed, setPushSubscribed] = useState(false);
|
|
43
|
+
const [error, setError] = useState('');
|
|
44
|
+
const [conflict, setConflict] = useState('');
|
|
45
|
+
|
|
46
|
+
const pushSupported = getPushSupport();
|
|
47
|
+
const iosNeedsInstall = getIosInstallState();
|
|
48
|
+
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
let cancelled = false;
|
|
51
|
+
|
|
52
|
+
getNotificationPreferences()
|
|
53
|
+
.then((data) => {
|
|
54
|
+
if (!cancelled) setPreferences(data);
|
|
55
|
+
})
|
|
56
|
+
.catch(() => {
|
|
57
|
+
if (!cancelled) setError(t('NotificationSettings.LOAD_ERROR'));
|
|
58
|
+
})
|
|
59
|
+
.finally(() => {
|
|
60
|
+
if (!cancelled) setLoading(false);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
return () => { cancelled = true; };
|
|
64
|
+
}, [t]);
|
|
65
|
+
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
let cancelled = false;
|
|
68
|
+
if (!pushSupported) return undefined;
|
|
69
|
+
|
|
70
|
+
navigator.serviceWorker.ready
|
|
71
|
+
.then((registration) => registration.pushManager.getSubscription())
|
|
72
|
+
.then((subscription) => {
|
|
73
|
+
if (!cancelled) setPushSubscribed(Boolean(subscription));
|
|
74
|
+
})
|
|
75
|
+
.catch(() => {
|
|
76
|
+
if (!cancelled) setPushSubscribed(false);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
return () => { cancelled = true; };
|
|
80
|
+
}, [pushSupported]);
|
|
81
|
+
|
|
82
|
+
const handleEmailToggle = async (event) => {
|
|
83
|
+
const email_opt_in = event.target.checked;
|
|
84
|
+
setSavingEmail(true);
|
|
85
|
+
setError('');
|
|
86
|
+
try {
|
|
87
|
+
const updated = await patchNotificationPreferences({ email_opt_in });
|
|
88
|
+
setPreferences(updated);
|
|
89
|
+
} catch {
|
|
90
|
+
setError(t('NotificationSettings.SAVE_ERROR'));
|
|
91
|
+
} finally {
|
|
92
|
+
setSavingEmail(false);
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const handleEnablePush = async () => {
|
|
97
|
+
setSavingPush(true);
|
|
98
|
+
setError('');
|
|
99
|
+
setConflict('');
|
|
100
|
+
try {
|
|
101
|
+
const permission = await Notification.requestPermission();
|
|
102
|
+
if (permission !== 'granted') {
|
|
103
|
+
setError(t('NotificationSettings.PUSH_DENIED'));
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const vapidPublicKey = await getVapidPublicKey();
|
|
107
|
+
const registration = await navigator.serviceWorker.ready;
|
|
108
|
+
const subscription = await registration.pushManager.subscribe({
|
|
109
|
+
userVisibleOnly: true,
|
|
110
|
+
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
|
|
111
|
+
});
|
|
112
|
+
await savePushSubscription(subscription.toJSON(), navigator.userAgent);
|
|
113
|
+
const updated = await patchNotificationPreferences({ push_opt_in: true });
|
|
114
|
+
setPreferences(updated);
|
|
115
|
+
setPushSubscribed(true);
|
|
116
|
+
} catch (requestError) {
|
|
117
|
+
if (requestError?.response?.status === 409) {
|
|
118
|
+
setConflict(t('NotificationSettings.PUSH_CONFLICT'));
|
|
119
|
+
} else {
|
|
120
|
+
setError(t('NotificationSettings.PUSH_ERROR'));
|
|
121
|
+
}
|
|
122
|
+
} finally {
|
|
123
|
+
setSavingPush(false);
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const handleDisablePush = async () => {
|
|
128
|
+
setSavingPush(true);
|
|
129
|
+
setError('');
|
|
130
|
+
setConflict('');
|
|
131
|
+
try {
|
|
132
|
+
const registration = await navigator.serviceWorker.ready;
|
|
133
|
+
const subscription = await registration.pushManager.getSubscription();
|
|
134
|
+
if (subscription) {
|
|
135
|
+
const endpoint = subscription.endpoint;
|
|
136
|
+
await subscription.unsubscribe();
|
|
137
|
+
await removePushSubscription({ endpoint });
|
|
138
|
+
}
|
|
139
|
+
setPushSubscribed(false);
|
|
140
|
+
} catch {
|
|
141
|
+
setError(t('NotificationSettings.PUSH_ERROR'));
|
|
142
|
+
} finally {
|
|
143
|
+
setSavingPush(false);
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
if (loading) {
|
|
148
|
+
return <Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}><CircularProgress /></Box>;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return (
|
|
152
|
+
<Box sx={{ maxWidth: 520 }}>
|
|
153
|
+
<Typography variant="h6" gutterBottom sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
154
|
+
<NotificationsIcon />
|
|
155
|
+
{t('NotificationSettings.TITLE')}
|
|
156
|
+
</Typography>
|
|
157
|
+
<Typography variant="body2" color="text.secondary" gutterBottom>
|
|
158
|
+
{t('NotificationSettings.SUBTITLE')}
|
|
159
|
+
</Typography>
|
|
160
|
+
{error && <Alert severity="error" sx={{ mb: 2 }} onClose={() => setError('')}>{error}</Alert>}
|
|
161
|
+
{conflict && <Alert severity="warning" sx={{ mb: 2 }} onClose={() => setConflict('')}>{conflict}</Alert>}
|
|
162
|
+
|
|
163
|
+
<Box sx={{ py: 2 }}>
|
|
164
|
+
<FormControlLabel
|
|
165
|
+
control={<Switch checked={Boolean(preferences?.email_opt_in)} onChange={handleEmailToggle} disabled={savingEmail} />}
|
|
166
|
+
label={<Box><Typography variant="body1">{t('NotificationSettings.EMAIL_LABEL')}</Typography><Typography variant="caption" color="text.secondary">{t('NotificationSettings.EMAIL_HINT')}</Typography></Box>}
|
|
167
|
+
labelPlacement="end"
|
|
168
|
+
sx={{ alignItems: 'flex-start', ml: 0 }}
|
|
169
|
+
/>
|
|
170
|
+
</Box>
|
|
171
|
+
|
|
172
|
+
<Divider />
|
|
173
|
+
|
|
174
|
+
<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
|
+
{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
|
+
))}
|
|
184
|
+
</Box>
|
|
185
|
+
</Box>
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export default NotificationSettings;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import apiClient from '../auth/apiClient';
|
|
2
|
+
|
|
3
|
+
const PREFERENCES_URL = '/api/notifications/preferences/';
|
|
4
|
+
const PUSH_SUBSCRIPTION_URL = `${PREFERENCES_URL}push-subscription/`;
|
|
5
|
+
|
|
6
|
+
export async function getNotificationPreferences() {
|
|
7
|
+
const response = await apiClient.get(PREFERENCES_URL);
|
|
8
|
+
return response.data;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function patchNotificationPreferences(patch) {
|
|
12
|
+
const response = await apiClient.patch(PREFERENCES_URL, patch);
|
|
13
|
+
return response.data;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function getVapidPublicKey() {
|
|
17
|
+
const response = await apiClient.get(`${PREFERENCES_URL}vapid-public-key/`);
|
|
18
|
+
return response.data?.vapidPublicKey;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function listPushSubscriptions() {
|
|
22
|
+
const response = await apiClient.get(PUSH_SUBSCRIPTION_URL);
|
|
23
|
+
return response.data;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function savePushSubscription(subscription, ua) {
|
|
27
|
+
const response = await apiClient.post(PUSH_SUBSCRIPTION_URL, { subscription, ua });
|
|
28
|
+
return response.data;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function removePushSubscription(subscriptionIdentifier) {
|
|
32
|
+
const response = await apiClient.delete(PUSH_SUBSCRIPTION_URL, { data: subscriptionIdentifier });
|
|
33
|
+
return response.data;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function urlBase64ToUint8Array(base64String) {
|
|
37
|
+
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
|
|
38
|
+
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
|
|
39
|
+
const decoder = typeof window !== 'undefined' && typeof window.atob === 'function'
|
|
40
|
+
? window.atob
|
|
41
|
+
: typeof atob === 'function'
|
|
42
|
+
? atob
|
|
43
|
+
: null;
|
|
44
|
+
|
|
45
|
+
if (!decoder) throw new Error('Base64 decoding is unavailable in this environment.');
|
|
46
|
+
|
|
47
|
+
const raw = decoder(base64);
|
|
48
|
+
return Uint8Array.from([...raw].map((character) => character.charCodeAt(0)));
|
|
49
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Canonical service-worker source. Copy this file verbatim to each consuming
|
|
3
|
+
* application's public/sw.js so it is served from that application's origin root.
|
|
4
|
+
*/
|
|
5
|
+
const resolveSameOriginUrl = (url) => {
|
|
6
|
+
try {
|
|
7
|
+
const resolvedUrl = new URL(url || '/', self.location.origin);
|
|
8
|
+
if (resolvedUrl.origin !== self.location.origin) return '/';
|
|
9
|
+
|
|
10
|
+
return `${resolvedUrl.pathname}${resolvedUrl.search}${resolvedUrl.hash}`;
|
|
11
|
+
} catch {
|
|
12
|
+
return '/';
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
self.addEventListener('push', (event) => {
|
|
17
|
+
if (!event.data) return;
|
|
18
|
+
|
|
19
|
+
let payload;
|
|
20
|
+
try {
|
|
21
|
+
payload = event.data.json();
|
|
22
|
+
} catch {
|
|
23
|
+
payload = { body: event.data.text() };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const options = {
|
|
27
|
+
body: payload.body || '',
|
|
28
|
+
data: { url: resolveSameOriginUrl(payload.url) },
|
|
29
|
+
};
|
|
30
|
+
if (payload.icon) options.icon = payload.icon;
|
|
31
|
+
if (payload.badge) options.badge = payload.badge;
|
|
32
|
+
|
|
33
|
+
event.waitUntil(self.registration.showNotification(payload.title || 'Notification', options));
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
self.addEventListener('notificationclick', (event) => {
|
|
37
|
+
event.notification.close();
|
|
38
|
+
const url = resolveSameOriginUrl(event.notification.data?.url);
|
|
39
|
+
|
|
40
|
+
event.waitUntil(
|
|
41
|
+
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => {
|
|
42
|
+
for (const client of clientList) {
|
|
43
|
+
if (client.url.includes(self.location.origin) && 'focus' in client) {
|
|
44
|
+
return Promise.resolve(client.navigate(url)).then(() => client.focus());
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return clients.openWindow ? clients.openWindow(url) : undefined;
|
|
48
|
+
}),
|
|
49
|
+
);
|
|
50
|
+
});
|