@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.
- package/.github/workflows/publish.yml +4 -0
- package/dist/components/UserListComponent.js +81 -71
- package/dist/i18n/notificationsTranslations.js +17 -0
- package/dist/i18n/onboardingTranslations.js +17 -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 +47 -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 +31 -0
- package/package.json +7 -4
- package/src/components/UserListComponent.jsx +196 -122
- package/src/i18n/notificationsTranslations.ts +17 -0
- package/src/i18n/onboardingTranslations.ts +17 -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 +66 -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 +39 -0
- package/tests/NotificationSettings.test.jsx +82 -0
- package/tests/notificationsApi.test.js +41 -0
- 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
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import React, { useMemo, useState, useEffect, useCallback } from 'react';
|
|
3
|
-
import { Box, Typography, FormControl, Select, MenuItem, Button, Tooltip, CircularProgress, Alert, TextField, } from '@mui/material';
|
|
4
|
-
import { DataGrid } from '@mui/x-data-grid';
|
|
3
|
+
import { Box, Typography, FormControl, Select, MenuItem, Button, Tooltip, CircularProgress, Alert, TextField, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, TableSortLabel, TablePagination, } from '@mui/material';
|
|
5
4
|
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
|
6
5
|
import CancelIcon from '@mui/icons-material/Cancel';
|
|
7
6
|
import DeleteIcon from '@mui/icons-material/Delete';
|
|
@@ -15,6 +14,10 @@ export function UserListComponent({ roles = DEFAULT_ROLES, currentUser, extraCol
|
|
|
15
14
|
const [error, setError] = useState(null);
|
|
16
15
|
const [rowActionLoading, setRowActionLoading] = useState({});
|
|
17
16
|
const [searchQuery, setSearchQuery] = useState('');
|
|
17
|
+
const [sortField, setSortField] = useState('email');
|
|
18
|
+
const [sortDir, setSortDir] = useState('asc');
|
|
19
|
+
const [page, setPage] = useState(0);
|
|
20
|
+
const [pageSize, setPageSize] = useState(25);
|
|
18
21
|
const controlSx = { minWidth: 140 };
|
|
19
22
|
const actionButtonSx = { textTransform: 'none', minWidth: 90 };
|
|
20
23
|
const loadUsers = useCallback(async () => {
|
|
@@ -42,6 +45,9 @@ export function UserListComponent({ roles = DEFAULT_ROLES, currentUser, extraCol
|
|
|
42
45
|
useEffect(() => {
|
|
43
46
|
loadUsers();
|
|
44
47
|
}, [loadUsers, refreshTrigger]);
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
setPage(0);
|
|
50
|
+
}, [searchQuery]);
|
|
45
51
|
const handleDelete = async (userId) => {
|
|
46
52
|
if (!window.confirm(t('UserList.DELETE_CONFIRM', 'Are you sure you want to delete this user?')))
|
|
47
53
|
return;
|
|
@@ -211,16 +217,23 @@ export function UserListComponent({ roles = DEFAULT_ROLES, currentUser, extraCol
|
|
|
211
217
|
field: 'email',
|
|
212
218
|
headerName: t('Auth.EMAIL_LABEL', 'Email'),
|
|
213
219
|
minWidth: 220,
|
|
214
|
-
maxWidth: 340,
|
|
215
220
|
flex: 1,
|
|
221
|
+
sortable: true,
|
|
222
|
+
align: 'left',
|
|
223
|
+
headerAlign: 'left',
|
|
224
|
+
valueGetter: (row) => { var _a; return (_a = row === null || row === void 0 ? void 0 : row.email) !== null && _a !== void 0 ? _a : ''; },
|
|
225
|
+
renderCell: (row) => row.email,
|
|
216
226
|
},
|
|
217
227
|
{
|
|
218
228
|
field: 'name',
|
|
219
229
|
headerName: t('Profile.NAME_LABEL', 'Name'),
|
|
220
230
|
minWidth: 180,
|
|
221
|
-
maxWidth: 260,
|
|
222
231
|
flex: 0.9,
|
|
223
|
-
|
|
232
|
+
sortable: true,
|
|
233
|
+
align: 'left',
|
|
234
|
+
headerAlign: 'left',
|
|
235
|
+
valueGetter: (row) => getUserDisplayName(row),
|
|
236
|
+
renderCell: (row) => getUserDisplayName(row),
|
|
224
237
|
},
|
|
225
238
|
];
|
|
226
239
|
if (showNewColumn) {
|
|
@@ -228,14 +241,14 @@ export function UserListComponent({ roles = DEFAULT_ROLES, currentUser, extraCol
|
|
|
228
241
|
field: 'is_new',
|
|
229
242
|
headerName: t('UserList.NEW', 'New'),
|
|
230
243
|
minWidth: 90,
|
|
231
|
-
maxWidth: 110,
|
|
232
244
|
flex: 0.35,
|
|
245
|
+
sortable: true,
|
|
233
246
|
align: 'center',
|
|
234
247
|
headerAlign: 'center',
|
|
235
|
-
valueGetter: (
|
|
236
|
-
renderCell: (
|
|
237
|
-
var _a
|
|
238
|
-
return renderBooleanStatusIcon(Boolean(((
|
|
248
|
+
valueGetter: (row) => { var _a; return Boolean(((_a = row === null || row === void 0 ? void 0 : row.profile) === null || _a === void 0 ? void 0 : _a.is_new) || (row === null || row === void 0 ? void 0 : row.is_new)); },
|
|
249
|
+
renderCell: (row) => {
|
|
250
|
+
var _a;
|
|
251
|
+
return renderBooleanStatusIcon(Boolean(((_a = row === null || row === void 0 ? void 0 : row.profile) === null || _a === void 0 ? void 0 : _a.is_new) || (row === null || row === void 0 ? void 0 : row.is_new)), t('Common.YES', 'Yes'), t('Common.NO', 'No'));
|
|
239
252
|
},
|
|
240
253
|
});
|
|
241
254
|
}
|
|
@@ -244,14 +257,14 @@ export function UserListComponent({ roles = DEFAULT_ROLES, currentUser, extraCol
|
|
|
244
257
|
field: 'successful_login',
|
|
245
258
|
headerName: t('UserList.SUCCESSFUL_LOGIN', 'Successful Login'),
|
|
246
259
|
minWidth: 120,
|
|
247
|
-
maxWidth: 150,
|
|
248
260
|
flex: 0.4,
|
|
261
|
+
sortable: true,
|
|
249
262
|
align: 'center',
|
|
250
263
|
headerAlign: 'center',
|
|
251
|
-
valueGetter: (
|
|
252
|
-
renderCell: (
|
|
253
|
-
var _a
|
|
254
|
-
return renderBooleanStatusIcon(Boolean((
|
|
264
|
+
valueGetter: (row) => { var _a; return Boolean((_a = row === null || row === void 0 ? void 0 : row.successful_login) !== null && _a !== void 0 ? _a : row === null || row === void 0 ? void 0 : row.last_login); },
|
|
265
|
+
renderCell: (row) => {
|
|
266
|
+
var _a;
|
|
267
|
+
return renderBooleanStatusIcon(Boolean((_a = row === null || row === void 0 ? void 0 : row.successful_login) !== null && _a !== void 0 ? _a : row === null || row === void 0 ? void 0 : row.last_login), t('Common.YES', 'Yes'), t('Common.NO', 'No'));
|
|
255
268
|
},
|
|
256
269
|
});
|
|
257
270
|
}
|
|
@@ -260,28 +273,26 @@ export function UserListComponent({ roles = DEFAULT_ROLES, currentUser, extraCol
|
|
|
260
273
|
field: 'role',
|
|
261
274
|
headerName: t('UserList.ROLE', 'Role'),
|
|
262
275
|
minWidth: 180,
|
|
263
|
-
maxWidth: 240,
|
|
264
276
|
flex: 0.7,
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
},
|
|
277
|
+
sortable: true,
|
|
278
|
+
align: 'left',
|
|
279
|
+
headerAlign: 'left',
|
|
280
|
+
valueGetter: (row) => (row === null || row === void 0 ? void 0 : row.role) || 'none',
|
|
281
|
+
renderCell: (row) => (_jsx(FormControl, { size: "small", fullWidth: true, sx: controlSx, disabled: !canEdit(row), children: _jsx(Select, { value: row.role || 'none', onChange: (event) => handleChangeRole(row.id, event.target.value), variant: "outlined", children: roles.map((role) => _jsx(MenuItem, { value: role, children: role }, role)) }) })),
|
|
270
282
|
});
|
|
271
283
|
}
|
|
272
284
|
const mappedExtraColumns = visibleExtraColumns.map((column) => ({
|
|
273
285
|
field: `extra:${column.key}`,
|
|
274
286
|
headerName: typeof column.label === 'function' ? column.label(listContext) : column.label,
|
|
275
287
|
minWidth: Number(column.minWidth) || 180,
|
|
276
|
-
maxWidth: Number(column.maxWidth) || 320,
|
|
277
288
|
flex: Number(column.flex) || 0.9,
|
|
278
289
|
sortable: column.sortable !== false,
|
|
279
290
|
align: column.align || 'left',
|
|
280
291
|
headerAlign: column.align || 'left',
|
|
281
|
-
valueGetter: (
|
|
282
|
-
renderCell: (
|
|
283
|
-
user:
|
|
284
|
-
canEdit: canEdit(
|
|
292
|
+
valueGetter: (row) => getExtraColumnSortValue(column, row),
|
|
293
|
+
renderCell: (row) => column.renderCell({
|
|
294
|
+
user: row,
|
|
295
|
+
canEdit: canEdit(row),
|
|
285
296
|
currentUser,
|
|
286
297
|
extraContext,
|
|
287
298
|
t,
|
|
@@ -296,30 +307,27 @@ export function UserListComponent({ roles = DEFAULT_ROLES, currentUser, extraCol
|
|
|
296
307
|
field: 'actions',
|
|
297
308
|
headerName: t('Common.ACTIONS', 'Actions'),
|
|
298
309
|
minWidth: Math.max(220, 110 + (visibleRowActions.length + (showDeleteAction ? 1 : 0)) * 110),
|
|
299
|
-
maxWidth: Math.max(360, 120 + (visibleRowActions.length + (showDeleteAction ? 1 : 0)) * 120),
|
|
300
310
|
flex: 1.1,
|
|
301
311
|
sortable: false,
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
}), showDeleteAction && (_jsx(Tooltip, { title: t('Common.DELETE', 'Delete'), children: _jsx("span", { children: _jsx(Button, { size: "small", variant: "outlined", color: "error", startIcon: _jsx(DeleteIcon, {}), onClick: () => handleDelete(user.id), disabled: !canDelete(user), sx: actionButtonSx, children: t('Common.DELETE', 'Delete') }) }) }))] }));
|
|
322
|
-
},
|
|
312
|
+
align: 'left',
|
|
313
|
+
headerAlign: 'left',
|
|
314
|
+
valueGetter: null,
|
|
315
|
+
renderCell: (row) => (_jsxs(Box, { sx: { display: 'flex', flexWrap: 'wrap', gap: 1, alignItems: 'center', py: 0.5 }, children: [visibleRowActions.map((action) => {
|
|
316
|
+
const actionId = `${action.key}:${row.id}`;
|
|
317
|
+
const isBusy = Boolean(rowActionLoading[actionId]);
|
|
318
|
+
const isDisabled = typeof action.disabled === 'function'
|
|
319
|
+
? action.disabled({
|
|
320
|
+
user: row,
|
|
321
|
+
canEdit: canEdit(row),
|
|
322
|
+
currentUser,
|
|
323
|
+
extraContext,
|
|
324
|
+
t,
|
|
325
|
+
})
|
|
326
|
+
: false;
|
|
327
|
+
return (_jsx(Button, { size: "small", variant: "outlined", onClick: () => runRowAction(action, row), disabled: isBusy || isDisabled, sx: actionButtonSx, children: typeof action.label === 'function'
|
|
328
|
+
? action.label({ user: row, t, currentUser, canEdit: canEdit(row) })
|
|
329
|
+
: action.label }, `${action.key}-${row.id}`));
|
|
330
|
+
}), showDeleteAction && (_jsx(Tooltip, { title: t('Common.DELETE', 'Delete'), children: _jsx("span", { children: _jsx(Button, { size: "small", variant: "outlined", color: "error", startIcon: _jsx(DeleteIcon, {}), onClick: () => handleDelete(row.id), disabled: !canDelete(row), sx: actionButtonSx, children: t('Common.DELETE', 'Delete') }) }) }))] })),
|
|
323
331
|
};
|
|
324
332
|
return [...baseColumns, ...mappedExtraColumns, actionColumn];
|
|
325
333
|
}, [
|
|
@@ -339,27 +347,29 @@ export function UserListComponent({ roles = DEFAULT_ROLES, currentUser, extraCol
|
|
|
339
347
|
canEdit,
|
|
340
348
|
canDelete,
|
|
341
349
|
]);
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
350
|
+
const sortedUsers = useMemo(() => {
|
|
351
|
+
const col = columns.find((c) => c.field === sortField);
|
|
352
|
+
return [...filteredUsers].sort((a, b) => {
|
|
353
|
+
var _a, _b;
|
|
354
|
+
const aVal = (col === null || col === void 0 ? void 0 : col.valueGetter) ? col.valueGetter(a) : ((_a = a[sortField]) !== null && _a !== void 0 ? _a : '');
|
|
355
|
+
const bVal = (col === null || col === void 0 ? void 0 : col.valueGetter) ? col.valueGetter(b) : ((_b = b[sortField]) !== null && _b !== void 0 ? _b : '');
|
|
356
|
+
const cmp = String(aVal !== null && aVal !== void 0 ? aVal : '').localeCompare(String(bVal !== null && bVal !== void 0 ? bVal : ''), undefined, { numeric: true, sensitivity: 'base' });
|
|
357
|
+
return sortDir === 'asc' ? cmp : -cmp;
|
|
358
|
+
});
|
|
359
|
+
}, [filteredUsers, sortField, sortDir, columns]);
|
|
360
|
+
const pagedUsers = useMemo(() => sortedUsers.slice(page * pageSize, page * pageSize + pageSize), [sortedUsers, page, pageSize]);
|
|
361
|
+
const handleSortClick = (field) => {
|
|
362
|
+
if (sortField === field) {
|
|
363
|
+
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
|
|
364
|
+
}
|
|
365
|
+
else {
|
|
366
|
+
setSortField(field);
|
|
367
|
+
setSortDir('asc');
|
|
368
|
+
}
|
|
369
|
+
setPage(0);
|
|
370
|
+
};
|
|
371
|
+
return (_jsxs(Box, { children: [_jsx(Typography, { variant: "h6", gutterBottom: true, children: t('UserList.TITLE', 'All Users') }), _jsx(Box, { sx: { mb: 2, maxWidth: 420 }, children: _jsx(TextField, { fullWidth: true, size: "small", label: t('Common.SEARCH', 'Search'), placeholder: t('UserList.SEARCH_PLACEHOLDER', 'Search users...'), value: searchQuery, onChange: (event) => setSearchQuery(event.target.value) }) }), error && _jsx(Alert, { severity: "error", sx: { mb: 2 }, children: t(error) }), loading && (_jsx(Box, { sx: { display: 'flex', justifyContent: 'center', p: 3 }, children: _jsx(CircularProgress, {}) })), !loading && (_jsxs(Box, { sx: { width: '100%' }, children: [_jsx(TableContainer, { sx: { border: 1, borderColor: 'divider', borderRadius: 1 }, children: _jsxs(Table, { size: "small", children: [_jsx(TableHead, { sx: { bgcolor: 'action.hover' }, children: _jsx(TableRow, { children: columns.map((col) => (_jsx(TableCell, { align: col.headerAlign || col.align || 'left', sortDirection: sortField === col.field ? sortDir : false, sx: { minWidth: col.minWidth, fontWeight: 600, whiteSpace: 'nowrap' }, children: col.sortable !== false ? (_jsx(TableSortLabel, { active: sortField === col.field, direction: sortField === col.field ? sortDir : 'asc', onClick: () => handleSortClick(col.field), children: col.headerName })) : (col.headerName) }, col.field))) }) }), _jsx(TableBody, { children: pagedUsers.length === 0 ? (_jsx(TableRow, { children: _jsx(TableCell, { colSpan: columns.length, align: "center", sx: { py: 4, color: 'text.secondary' }, children: t('UserList.NO_USERS', 'No users found.') }) })) : (pagedUsers.map((row) => (_jsx(TableRow, { hover: true, children: columns.map((col) => (_jsx(TableCell, { align: col.align || 'left', sx: { verticalAlign: 'top', py: 1 }, children: col.renderCell(row) }, col.field))) }, row.id)))) })] }) }), _jsx(TablePagination, { component: "div", count: sortedUsers.length, page: page, onPageChange: (_, newPage) => setPage(newPage), rowsPerPage: pageSize, onRowsPerPageChange: (event) => {
|
|
372
|
+
setPageSize(Number(event.target.value));
|
|
373
|
+
setPage(0);
|
|
374
|
+
}, rowsPerPageOptions: [10, 25, 50, 100] })] }))] }));
|
|
365
375
|
}
|
|
@@ -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
|
+
});
|