@micha.bigler/ui-core-micha 2.5.0 → 2.6.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.
Files changed (31) hide show
  1. package/.github/workflows/publish.yml +4 -0
  2. package/dist/i18n/notificationsTranslations.js +17 -0
  3. package/dist/i18n/onboardingTranslations.js +17 -0
  4. package/dist/index.js +14 -0
  5. package/dist/notifications/NotificationSettings.js +148 -0
  6. package/dist/notifications/api.js +41 -0
  7. package/dist/notifications/serviceWorker/sw.js +49 -0
  8. package/dist/onboarding/OnboardingProvider.js +133 -0
  9. package/dist/onboarding/OnboardingWizard.js +47 -0
  10. package/dist/onboarding/api.js +10 -0
  11. package/dist/onboarding/stepSelection.js +18 -0
  12. package/dist/onboarding/steps/BrowserPushStep.js +49 -0
  13. package/dist/onboarding/steps/CompleteNameStep.js +37 -0
  14. package/dist/onboarding/steps/CookieConsentStep.js +31 -0
  15. package/package.json +7 -3
  16. package/src/i18n/notificationsTranslations.ts +17 -0
  17. package/src/i18n/onboardingTranslations.ts +17 -0
  18. package/src/index.js +22 -0
  19. package/src/notifications/NotificationSettings.jsx +189 -0
  20. package/src/notifications/api.js +49 -0
  21. package/src/notifications/serviceWorker/sw.js +50 -0
  22. package/src/onboarding/OnboardingProvider.jsx +153 -0
  23. package/src/onboarding/OnboardingWizard.jsx +66 -0
  24. package/src/onboarding/api.js +13 -0
  25. package/src/onboarding/stepSelection.js +16 -0
  26. package/src/onboarding/steps/BrowserPushStep.jsx +63 -0
  27. package/src/onboarding/steps/CompleteNameStep.jsx +46 -0
  28. package/src/onboarding/steps/CookieConsentStep.jsx +39 -0
  29. package/tests/NotificationSettings.test.jsx +82 -0
  30. package/tests/notificationsApi.test.js +41 -0
  31. package/tests/stepSelection.test.js +53 -0
@@ -105,6 +105,10 @@ jobs:
105
105
  if: steps.version_check.outputs.should_publish == 'true'
106
106
  run: pnpm install --frozen-lockfile
107
107
 
108
+ - name: Run tests
109
+ if: steps.version_check.outputs.should_publish == 'true'
110
+ run: pnpm run test
111
+
108
112
  - name: Build
109
113
  if: steps.version_check.outputs.should_publish == 'true'
110
114
  run: pnpm run build
@@ -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,17 @@
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.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.' },
8
+ 'Onboarding.COMPLETE_NAME_TITLE': { de: 'Dein Name', fr: 'Votre nom', en: 'Your name', sw: 'Jina lako' },
9
+ '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
+ 'Onboarding.FIRST_NAME_LABEL': { de: 'Vorname', fr: 'Prénom', en: 'First name', sw: 'Jina la kwanza' },
11
+ 'Onboarding.LAST_NAME_LABEL': { de: 'Nachname', fr: 'Nom', en: 'Last name', sw: 'Jina la mwisho' },
12
+ 'Onboarding.SAVE': { de: 'Speichern', fr: 'Enregistrer', en: 'Save', sw: 'Hifadhi' },
13
+ '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.' },
16
+ 'Onboarding.SKIP': { de: 'Überspringen', fr: 'Passer', en: 'Skip', sw: 'Ruka' },
17
+ };
package/dist/index.js CHANGED
@@ -29,3 +29,17 @@ export { AccessCodeSingleUseToggle } from './components/AccessCodeSingleUseToggl
29
29
  export { QrSignupManager } from './components/QrSignupManager';
30
30
  // --- 6. Translations ---
31
31
  export { authTranslations } from './i18n/authTranslations';
32
+ // --- 7. Notifications ---
33
+ export { NotificationSettings } from './notifications/NotificationSettings';
34
+ export * from './notifications/api';
35
+ // --- 8. Onboarding ---
36
+ export * from './onboarding/api';
37
+ export { selectActiveSteps } from './onboarding/stepSelection';
38
+ export { OnboardingContext, OnboardingProvider, UNIVERSAL_STEP_DESCRIPTORS, useOnboarding, } from './onboarding/OnboardingProvider';
39
+ export { OnboardingWizard } from './onboarding/OnboardingWizard';
40
+ export { CookieConsentStep } from './onboarding/steps/CookieConsentStep';
41
+ export { CompleteNameStep } from './onboarding/steps/CompleteNameStep';
42
+ export { BrowserPushStep } from './onboarding/steps/BrowserPushStep';
43
+ // --- 9. Translations ---
44
+ export { notificationsTranslations } from './i18n/notificationsTranslations';
45
+ export { onboardingTranslations } from './i18n/onboardingTranslations';
@@ -0,0 +1,148 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import React, { useEffect, 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 Divider from '@mui/material/Divider';
9
+ import FormControlLabel from '@mui/material/FormControlLabel';
10
+ import Switch from '@mui/material/Switch';
11
+ import Typography from '@mui/material/Typography';
12
+ import NotificationsIcon from '@mui/icons-material/Notifications';
13
+ import { getNotificationPreferences, getVapidPublicKey, patchNotificationPreferences, removePushSubscription, savePushSubscription, urlBase64ToUint8Array, } from './api';
14
+ function getPushSupport() {
15
+ return typeof navigator !== 'undefined'
16
+ && typeof window !== 'undefined'
17
+ && 'serviceWorker' in navigator
18
+ && 'PushManager' in window;
19
+ }
20
+ function getIosInstallState() {
21
+ var _a;
22
+ if (typeof navigator === 'undefined' || typeof window === 'undefined')
23
+ return false;
24
+ const isIos = /iphone|ipad|ipod/i.test(navigator.userAgent || '');
25
+ const standalone = ((_a = window.matchMedia) === null || _a === void 0 ? void 0 : _a.call(window, '(display-mode: standalone)').matches)
26
+ || window.navigator.standalone === true;
27
+ return isIos && !standalone;
28
+ }
29
+ export function NotificationSettings() {
30
+ const { t } = useTranslation();
31
+ const [preferences, setPreferences] = useState(null);
32
+ const [loading, setLoading] = useState(true);
33
+ const [savingEmail, setSavingEmail] = useState(false);
34
+ const [savingPush, setSavingPush] = useState(false);
35
+ const [pushSubscribed, setPushSubscribed] = useState(false);
36
+ const [error, setError] = useState('');
37
+ const [conflict, setConflict] = useState('');
38
+ const pushSupported = getPushSupport();
39
+ const iosNeedsInstall = getIosInstallState();
40
+ useEffect(() => {
41
+ let cancelled = false;
42
+ getNotificationPreferences()
43
+ .then((data) => {
44
+ if (!cancelled)
45
+ setPreferences(data);
46
+ })
47
+ .catch(() => {
48
+ if (!cancelled)
49
+ setError(t('NotificationSettings.LOAD_ERROR'));
50
+ })
51
+ .finally(() => {
52
+ if (!cancelled)
53
+ setLoading(false);
54
+ });
55
+ return () => { cancelled = true; };
56
+ }, [t]);
57
+ useEffect(() => {
58
+ let cancelled = false;
59
+ if (!pushSupported)
60
+ return undefined;
61
+ navigator.serviceWorker.ready
62
+ .then((registration) => registration.pushManager.getSubscription())
63
+ .then((subscription) => {
64
+ if (!cancelled)
65
+ setPushSubscribed(Boolean(subscription));
66
+ })
67
+ .catch(() => {
68
+ if (!cancelled)
69
+ setPushSubscribed(false);
70
+ });
71
+ return () => { cancelled = true; };
72
+ }, [pushSupported]);
73
+ const handleEmailToggle = async (event) => {
74
+ const email_opt_in = event.target.checked;
75
+ setSavingEmail(true);
76
+ setError('');
77
+ try {
78
+ const updated = await patchNotificationPreferences({ email_opt_in });
79
+ setPreferences(updated);
80
+ }
81
+ catch (_a) {
82
+ setError(t('NotificationSettings.SAVE_ERROR'));
83
+ }
84
+ finally {
85
+ setSavingEmail(false);
86
+ }
87
+ };
88
+ const handleEnablePush = async () => {
89
+ var _a;
90
+ setSavingPush(true);
91
+ setError('');
92
+ setConflict('');
93
+ try {
94
+ const permission = await Notification.requestPermission();
95
+ if (permission !== 'granted') {
96
+ setError(t('NotificationSettings.PUSH_DENIED'));
97
+ return;
98
+ }
99
+ const vapidPublicKey = await getVapidPublicKey();
100
+ const registration = await navigator.serviceWorker.ready;
101
+ const subscription = await registration.pushManager.subscribe({
102
+ userVisibleOnly: true,
103
+ applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
104
+ });
105
+ await savePushSubscription(subscription.toJSON(), navigator.userAgent);
106
+ const updated = await patchNotificationPreferences({ push_opt_in: true });
107
+ setPreferences(updated);
108
+ setPushSubscribed(true);
109
+ }
110
+ catch (requestError) {
111
+ if (((_a = requestError === null || requestError === void 0 ? void 0 : requestError.response) === null || _a === void 0 ? void 0 : _a.status) === 409) {
112
+ setConflict(t('NotificationSettings.PUSH_CONFLICT'));
113
+ }
114
+ else {
115
+ setError(t('NotificationSettings.PUSH_ERROR'));
116
+ }
117
+ }
118
+ finally {
119
+ setSavingPush(false);
120
+ }
121
+ };
122
+ const handleDisablePush = async () => {
123
+ setSavingPush(true);
124
+ setError('');
125
+ setConflict('');
126
+ try {
127
+ const registration = await navigator.serviceWorker.ready;
128
+ const subscription = await registration.pushManager.getSubscription();
129
+ if (subscription) {
130
+ const endpoint = subscription.endpoint;
131
+ await subscription.unsubscribe();
132
+ await removePushSubscription({ endpoint });
133
+ }
134
+ setPushSubscribed(false);
135
+ }
136
+ catch (_a) {
137
+ setError(t('NotificationSettings.PUSH_ERROR'));
138
+ }
139
+ finally {
140
+ setSavingPush(false);
141
+ }
142
+ };
143
+ if (loading) {
144
+ return _jsx(Box, { sx: { display: 'flex', justifyContent: 'center', py: 4 }, children: _jsx(CircularProgress, {}) });
145
+ }
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') })))] })] }));
147
+ }
148
+ export default NotificationSettings;
@@ -0,0 +1,41 @@
1
+ import apiClient from '../auth/apiClient';
2
+ const PREFERENCES_URL = '/api/notifications/preferences/';
3
+ const PUSH_SUBSCRIPTION_URL = `${PREFERENCES_URL}push-subscription/`;
4
+ export async function getNotificationPreferences() {
5
+ const response = await apiClient.get(PREFERENCES_URL);
6
+ return response.data;
7
+ }
8
+ export async function patchNotificationPreferences(patch) {
9
+ const response = await apiClient.patch(PREFERENCES_URL, patch);
10
+ return response.data;
11
+ }
12
+ export async function getVapidPublicKey() {
13
+ var _a;
14
+ const response = await apiClient.get(`${PREFERENCES_URL}vapid-public-key/`);
15
+ return (_a = response.data) === null || _a === void 0 ? void 0 : _a.vapidPublicKey;
16
+ }
17
+ export async function listPushSubscriptions() {
18
+ const response = await apiClient.get(PUSH_SUBSCRIPTION_URL);
19
+ return response.data;
20
+ }
21
+ export async function savePushSubscription(subscription, ua) {
22
+ const response = await apiClient.post(PUSH_SUBSCRIPTION_URL, { subscription, ua });
23
+ return response.data;
24
+ }
25
+ export async function removePushSubscription(subscriptionIdentifier) {
26
+ const response = await apiClient.delete(PUSH_SUBSCRIPTION_URL, { data: subscriptionIdentifier });
27
+ return response.data;
28
+ }
29
+ export function urlBase64ToUint8Array(base64String) {
30
+ const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
31
+ const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
32
+ const decoder = typeof window !== 'undefined' && typeof window.atob === 'function'
33
+ ? window.atob
34
+ : typeof atob === 'function'
35
+ ? atob
36
+ : null;
37
+ if (!decoder)
38
+ throw new Error('Base64 decoding is unavailable in this environment.');
39
+ const raw = decoder(base64);
40
+ return Uint8Array.from([...raw].map((character) => character.charCodeAt(0)));
41
+ }
@@ -0,0 +1,49 @@
1
+ "use strict";
2
+ /*
3
+ * Canonical service-worker source. Copy this file verbatim to each consuming
4
+ * application's public/sw.js so it is served from that application's origin root.
5
+ */
6
+ const resolveSameOriginUrl = (url) => {
7
+ try {
8
+ const resolvedUrl = new URL(url || '/', self.location.origin);
9
+ if (resolvedUrl.origin !== self.location.origin)
10
+ return '/';
11
+ return `${resolvedUrl.pathname}${resolvedUrl.search}${resolvedUrl.hash}`;
12
+ }
13
+ catch (_a) {
14
+ return '/';
15
+ }
16
+ };
17
+ self.addEventListener('push', (event) => {
18
+ if (!event.data)
19
+ return;
20
+ let payload;
21
+ try {
22
+ payload = event.data.json();
23
+ }
24
+ catch (_a) {
25
+ payload = { body: event.data.text() };
26
+ }
27
+ const options = {
28
+ body: payload.body || '',
29
+ data: { url: resolveSameOriginUrl(payload.url) },
30
+ };
31
+ if (payload.icon)
32
+ options.icon = payload.icon;
33
+ if (payload.badge)
34
+ options.badge = payload.badge;
35
+ event.waitUntil(self.registration.showNotification(payload.title || 'Notification', options));
36
+ });
37
+ self.addEventListener('notificationclick', (event) => {
38
+ var _a;
39
+ event.notification.close();
40
+ const url = resolveSameOriginUrl((_a = event.notification.data) === null || _a === void 0 ? void 0 : _a.url);
41
+ event.waitUntil(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
+ });
@@ -0,0 +1,133 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import React, { createContext, useCallback, useContext, useEffect, useMemo, useState, } from 'react';
3
+ import { AuthContext } from '../auth/AuthContext';
4
+ import { getOnboardingStepConfig } from './api';
5
+ import { selectActiveSteps } from './stepSelection';
6
+ import CookieConsentStep from './steps/CookieConsentStep';
7
+ import CompleteNameStep from './steps/CompleteNameStep';
8
+ import BrowserPushStep from './steps/BrowserPushStep';
9
+ export const UNIVERSAL_STEP_DESCRIPTORS = [
10
+ {
11
+ id: 'cookie_consent',
12
+ condition: (ctx) => Boolean(ctx.user && !ctx.user.accepted_convenience_cookies),
13
+ blocking: true,
14
+ skipable: false,
15
+ persistDismissed: false,
16
+ titleKey: 'Onboarding.COOKIE_CONSENT_TITLE',
17
+ Component: CookieConsentStep,
18
+ },
19
+ {
20
+ id: 'complete_name',
21
+ condition: (ctx) => { var _a, _b; return Boolean(ctx.user && (!((_a = ctx.user.first_name) === null || _a === void 0 ? void 0 : _a.trim()) || !((_b = ctx.user.last_name) === null || _b === void 0 ? void 0 : _b.trim()))); },
22
+ blocking: true,
23
+ skipable: false,
24
+ persistDismissed: false,
25
+ titleKey: 'Onboarding.COMPLETE_NAME_TITLE',
26
+ Component: CompleteNameStep,
27
+ },
28
+ {
29
+ 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
+ blocking: false,
32
+ skipable: true,
33
+ persistDismissed: true,
34
+ titleKey: 'Onboarding.BROWSER_PUSH_TITLE',
35
+ Component: BrowserPushStep,
36
+ },
37
+ ];
38
+ export const OnboardingContext = createContext(null);
39
+ export function useOnboarding() {
40
+ return useContext(OnboardingContext);
41
+ }
42
+ function loadDismissedSteps() {
43
+ if (typeof window === 'undefined')
44
+ return new Set();
45
+ try {
46
+ return new Set(JSON.parse(window.localStorage.getItem('onboarding_dismissed')) || []);
47
+ }
48
+ catch (_a) {
49
+ return new Set();
50
+ }
51
+ }
52
+ function getInitialPushState() {
53
+ const supported = typeof navigator !== 'undefined'
54
+ && typeof window !== 'undefined'
55
+ && 'serviceWorker' in navigator
56
+ && 'PushManager' in window;
57
+ return supported ? null : { supported: false, subscribed: false };
58
+ }
59
+ export function OnboardingProvider({ children, extraSteps = [] }) {
60
+ const { user } = useContext(AuthContext);
61
+ const [configMap, setConfigMap] = useState({});
62
+ const [configLoaded, setConfigLoaded] = useState(false);
63
+ const [pushState, setPushState] = useState(getInitialPushState);
64
+ const [dismissedSet, setDismissedSet] = useState(loadDismissedSteps);
65
+ const descriptors = useMemo(() => [...UNIVERSAL_STEP_DESCRIPTORS, ...extraSteps], [extraSteps]);
66
+ useEffect(() => {
67
+ let cancelled = false;
68
+ if (!user) {
69
+ setConfigMap({});
70
+ setConfigLoaded(false);
71
+ return undefined;
72
+ }
73
+ getOnboardingStepConfig()
74
+ .then((data) => {
75
+ if (cancelled)
76
+ return;
77
+ const nextConfigMap = {};
78
+ (data || []).forEach((item) => { nextConfigMap[item.key] = item.enabled; });
79
+ setConfigMap(nextConfigMap);
80
+ })
81
+ .catch(() => {
82
+ if (!cancelled)
83
+ setConfigMap({});
84
+ })
85
+ .finally(() => {
86
+ if (!cancelled)
87
+ setConfigLoaded(true);
88
+ });
89
+ return () => { cancelled = true; };
90
+ }, [user]);
91
+ useEffect(() => {
92
+ let cancelled = false;
93
+ if (typeof navigator === 'undefined' || typeof window === 'undefined'
94
+ || !('serviceWorker' in navigator) || !('PushManager' in window)) {
95
+ setPushState({ supported: false, subscribed: false });
96
+ return undefined;
97
+ }
98
+ navigator.serviceWorker.ready
99
+ .then((registration) => registration.pushManager.getSubscription())
100
+ .then((subscription) => {
101
+ if (!cancelled)
102
+ setPushState({ supported: true, subscribed: Boolean(subscription) });
103
+ })
104
+ .catch(() => {
105
+ if (!cancelled)
106
+ setPushState({ supported: true, subscribed: false });
107
+ });
108
+ return () => { cancelled = true; };
109
+ }, []);
110
+ const dismissStep = useCallback((id) => {
111
+ setDismissedSet((previous) => {
112
+ const next = new Set(previous);
113
+ next.add(id);
114
+ if (typeof window !== 'undefined') {
115
+ try {
116
+ window.localStorage.setItem('onboarding_dismissed', JSON.stringify([...next]));
117
+ }
118
+ catch (_a) {
119
+ // Persistence is optional; the in-memory dismissal still applies.
120
+ }
121
+ }
122
+ return next;
123
+ });
124
+ }, []);
125
+ const ctx = useMemo(() => ({ user, pushState }), [user, pushState]);
126
+ const activeSteps = useMemo(() => {
127
+ if (!configLoaded || !user)
128
+ return [];
129
+ return selectActiveSteps(descriptors, configMap, ctx, dismissedSet);
130
+ }, [configLoaded, user, descriptors, configMap, ctx, dismissedSet]);
131
+ const value = useMemo(() => ({ activeSteps, dismissStep, ctx, configMap, setConfigMap }), [activeSteps, dismissStep, ctx, configMap]);
132
+ return _jsx(OnboardingContext.Provider, { value: value, children: children });
133
+ }
@@ -0,0 +1,47 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import React, { useEffect, useRef, useState } from 'react';
3
+ import { useTranslation } from 'react-i18next';
4
+ import Box from '@mui/material/Box';
5
+ import Dialog from '@mui/material/Dialog';
6
+ import DialogContent from '@mui/material/DialogContent';
7
+ import DialogTitle from '@mui/material/DialogTitle';
8
+ import LinearProgress from '@mui/material/LinearProgress';
9
+ import Typography from '@mui/material/Typography';
10
+ import { useOnboarding } from './OnboardingProvider';
11
+ export function OnboardingWizard() {
12
+ const { t } = useTranslation();
13
+ const onboarding = useOnboarding();
14
+ const [sessionDismissed, setSessionDismissed] = useState(() => new Set());
15
+ const totalRef = useRef(null);
16
+ const [completed, setCompleted] = useState(0);
17
+ const activeSteps = (onboarding === null || onboarding === void 0 ? void 0 : onboarding.activeSteps) || [];
18
+ const visibleSteps = activeSteps.filter((step) => !sessionDismissed.has(step.id));
19
+ useEffect(() => {
20
+ if (visibleSteps.length > 0 && totalRef.current === null) {
21
+ totalRef.current = visibleSteps.length;
22
+ setCompleted(0);
23
+ }
24
+ if (visibleSteps.length === 0) {
25
+ totalRef.current = null;
26
+ setCompleted(0);
27
+ }
28
+ }, [visibleSteps.length]);
29
+ if (!onboarding || visibleSteps.length === 0)
30
+ return null;
31
+ const { dismissStep, ctx } = onboarding;
32
+ const currentStep = visibleSteps[0];
33
+ const total = totalRef.current || visibleSteps.length;
34
+ const progress = total > 1 ? Math.round((completed / total) * 100) : 0;
35
+ const StepComponent = currentStep.Component;
36
+ const completeCurrentStep = () => {
37
+ setSessionDismissed((previous) => new Set([...previous, currentStep.id]));
38
+ setCompleted((count) => count + 1);
39
+ };
40
+ const dismissCurrentStep = () => {
41
+ if (currentStep.persistDismissed)
42
+ dismissStep(currentStep.id);
43
+ completeCurrentStep();
44
+ };
45
+ return (_jsxs(Dialog, { open: true, fullWidth: true, maxWidth: "sm", disableEscapeKeyDown: currentStep.blocking, onClose: currentStep.blocking ? undefined : dismissCurrentStep, children: [_jsxs(DialogTitle, { sx: { pb: 0 }, children: [_jsx(Box, { sx: { display: 'flex', alignItems: 'center', justifyContent: 'space-between' }, children: _jsx(Typography, { variant: "subtitle2", color: "text.secondary", children: total > 1 ? t('Onboarding.STEP_COUNTER', { current: completed + 1, total }) : t('Onboarding.SETUP') }) }), total > 1 && _jsx(LinearProgress, { variant: "determinate", value: progress, sx: { mt: 1, borderRadius: 1 } })] }), _jsx(DialogContent, { sx: { pt: 2 }, children: _jsx(StepComponent, { onComplete: completeCurrentStep, onDismiss: dismissCurrentStep, ctx: ctx }) })] }));
46
+ }
47
+ export default OnboardingWizard;
@@ -0,0 +1,10 @@
1
+ import apiClient from '../auth/apiClient';
2
+ const STEP_CONFIG_URL = '/api/onboarding/step-config/';
3
+ export async function getOnboardingStepConfig() {
4
+ const response = await apiClient.get(STEP_CONFIG_URL);
5
+ return response.data;
6
+ }
7
+ export async function patchOnboardingStepConfig(payload) {
8
+ const response = await apiClient.patch(STEP_CONFIG_URL, payload);
9
+ return response.data;
10
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Pure onboarding step selection.
3
+ *
4
+ * @param {Array} descriptors Step descriptors in display order.
5
+ * @param {Object} configMap Enabled state keyed by dcm onboarding step key.
6
+ * @param {Object} ctx Runtime context supplied to each step condition.
7
+ * @param {Set} dismissedSet Persistently dismissed step ids.
8
+ * @returns {Array} Steps that should be shown.
9
+ */
10
+ export function selectActiveSteps(descriptors, configMap, ctx, dismissedSet = new Set()) {
11
+ return descriptors.filter((step) => {
12
+ if (configMap[step.id] === false)
13
+ return false;
14
+ if (step.persistDismissed && dismissedSet.has(step.id))
15
+ return false;
16
+ return step.condition(ctx);
17
+ });
18
+ }
@@ -0,0 +1,49 @@
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 Typography from '@mui/material/Typography';
9
+ import NotificationsIcon from '@mui/icons-material/Notifications';
10
+ import { getVapidPublicKey, patchNotificationPreferences, savePushSubscription, urlBase64ToUint8Array, } from '../../notifications/api';
11
+ export function BrowserPushStep({ onComplete, onDismiss }) {
12
+ const { t } = useTranslation();
13
+ const [loading, setLoading] = useState(false);
14
+ const [error, setError] = useState('');
15
+ const enable = async () => {
16
+ var _a;
17
+ setLoading(true);
18
+ setError('');
19
+ try {
20
+ const permission = await Notification.requestPermission();
21
+ if (permission !== 'granted') {
22
+ setError(t('NotificationSettings.PUSH_DENIED'));
23
+ return;
24
+ }
25
+ const vapidPublicKey = await getVapidPublicKey();
26
+ const registration = await navigator.serviceWorker.ready;
27
+ const subscription = await registration.pushManager.subscribe({
28
+ userVisibleOnly: true,
29
+ applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
30
+ });
31
+ await savePushSubscription(subscription.toJSON(), navigator.userAgent);
32
+ await patchNotificationPreferences({ push_opt_in: true });
33
+ onComplete();
34
+ }
35
+ catch (requestError) {
36
+ if (((_a = requestError === null || requestError === void 0 ? void 0 : requestError.response) === null || _a === void 0 ? void 0 : _a.status) === 409) {
37
+ setError(t('NotificationSettings.PUSH_CONFLICT'));
38
+ }
39
+ else {
40
+ setError(t('NotificationSettings.PUSH_ERROR'));
41
+ }
42
+ }
43
+ finally {
44
+ setLoading(false);
45
+ }
46
+ };
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') })] })] }));
48
+ }
49
+ export default BrowserPushStep;
@@ -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,31 @@
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 Typography from '@mui/material/Typography';
9
+ import CookieIcon from '@mui/icons-material/Cookie';
10
+ import { updateUserProfile } from '../../auth/authApi';
11
+ export function CookieConsentStep({ onComplete }) {
12
+ const { t } = useTranslation();
13
+ const [loading, setLoading] = useState(false);
14
+ const [error, setError] = useState('');
15
+ const accept = async () => {
16
+ setLoading(true);
17
+ setError('');
18
+ try {
19
+ await updateUserProfile({ accepted_convenience_cookies: true });
20
+ onComplete();
21
+ }
22
+ catch (_a) {
23
+ setError(t('Onboarding.COOKIE_CONSENT_ERROR'));
24
+ }
25
+ finally {
26
+ setLoading(false);
27
+ }
28
+ };
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') }) })] }));
30
+ }
31
+ export default CookieConsentStep;