@micha.bigler/ui-core-micha 2.4.5 → 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 (33) hide show
  1. package/.github/workflows/publish.yml +4 -0
  2. package/dist/components/UserListComponent.js +81 -71
  3. package/dist/i18n/notificationsTranslations.js +17 -0
  4. package/dist/i18n/onboardingTranslations.js +17 -0
  5. package/dist/index.js +14 -0
  6. package/dist/notifications/NotificationSettings.js +148 -0
  7. package/dist/notifications/api.js +41 -0
  8. package/dist/notifications/serviceWorker/sw.js +49 -0
  9. package/dist/onboarding/OnboardingProvider.js +133 -0
  10. package/dist/onboarding/OnboardingWizard.js +47 -0
  11. package/dist/onboarding/api.js +10 -0
  12. package/dist/onboarding/stepSelection.js +18 -0
  13. package/dist/onboarding/steps/BrowserPushStep.js +49 -0
  14. package/dist/onboarding/steps/CompleteNameStep.js +37 -0
  15. package/dist/onboarding/steps/CookieConsentStep.js +31 -0
  16. package/package.json +7 -4
  17. package/src/components/UserListComponent.jsx +196 -122
  18. package/src/i18n/notificationsTranslations.ts +17 -0
  19. package/src/i18n/onboardingTranslations.ts +17 -0
  20. package/src/index.js +22 -0
  21. package/src/notifications/NotificationSettings.jsx +189 -0
  22. package/src/notifications/api.js +49 -0
  23. package/src/notifications/serviceWorker/sw.js +50 -0
  24. package/src/onboarding/OnboardingProvider.jsx +153 -0
  25. package/src/onboarding/OnboardingWizard.jsx +66 -0
  26. package/src/onboarding/api.js +13 -0
  27. package/src/onboarding/stepSelection.js +16 -0
  28. package/src/onboarding/steps/BrowserPushStep.jsx +63 -0
  29. package/src/onboarding/steps/CompleteNameStep.jsx +46 -0
  30. package/src/onboarding/steps/CookieConsentStep.jsx +39 -0
  31. package/tests/NotificationSettings.test.jsx +82 -0
  32. package/tests/notificationsApi.test.js +41 -0
  33. package/tests/stepSelection.test.js +53 -0
@@ -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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@micha.bigler/ui-core-micha",
3
- "version": "2.4.5",
3
+ "version": "2.6.1",
4
4
  "main": "dist/index.js",
5
5
  "module": "dist/index.js",
6
6
  "repository": {
@@ -12,7 +12,6 @@
12
12
  "@emotion/react": "^11.14.0",
13
13
  "@emotion/styled": "^11.14.1",
14
14
  "@mui/material": "^7.3.11",
15
- "@mui/x-data-grid": "^8.28.6",
16
15
  "axios": "^1.16.1",
17
16
  "i18next": "^25.10.10 || ^26.0.0",
18
17
  "qrcode.react": "^4.2.0",
@@ -23,12 +22,16 @@
23
22
  "react-router-dom": "^7.15.1"
24
23
  },
25
24
  "scripts": {
26
- "build": "tsc -p tsconfig.build.json"
25
+ "build": "tsc -p tsconfig.build.json",
26
+ "test": "vitest run"
27
27
  },
28
28
  "devDependencies": {
29
29
  "@mui/icons-material": "^7.3.11",
30
+ "@testing-library/react": "^16.3.2",
30
31
  "i18next": "^26.2.0",
32
+ "jsdom": "^29.1.1",
31
33
  "react-i18next": "^17.0.8",
32
- "typescript": "^6.0.3"
34
+ "typescript": "^6.0.3",
35
+ "vitest": "^4.1.10"
33
36
  }
34
37
  }