@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
@@ -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
- valueGetter: (_value, row) => getUserDisplayName(row),
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: (_value, 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)); },
236
- renderCell: (params) => {
237
- var _a, _b, _c;
238
- return renderBooleanStatusIcon(Boolean(((_b = (_a = params.row) === null || _a === void 0 ? void 0 : _a.profile) === null || _b === void 0 ? void 0 : _b.is_new) || ((_c = params.row) === null || _c === void 0 ? void 0 : _c.is_new)), t('Common.YES', 'Yes'), t('Common.NO', 'No'));
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: (_value, 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); },
252
- renderCell: (params) => {
253
- var _a, _b, _c;
254
- return renderBooleanStatusIcon(Boolean((_b = (_a = params.row) === null || _a === void 0 ? void 0 : _a.successful_login) !== null && _b !== void 0 ? _b : (_c = params.row) === null || _c === void 0 ? void 0 : _c.last_login), t('Common.YES', 'Yes'), t('Common.NO', 'No'));
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
- valueGetter: (_value, row) => (row === null || row === void 0 ? void 0 : row.role) || 'none',
266
- renderCell: (params) => {
267
- const user = params.row;
268
- return (_jsx(FormControl, { size: "small", fullWidth: true, sx: controlSx, disabled: !canEdit(user), children: _jsx(Select, { value: user.role || 'none', onChange: (event) => handleChangeRole(user.id, event.target.value), variant: "outlined", children: roles.map((role) => _jsx(MenuItem, { value: role, children: role }, role)) }) }));
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: (_value, row) => getExtraColumnSortValue(column, row),
282
- renderCell: (params) => column.renderCell({
283
- user: params.row,
284
- canEdit: canEdit(params.row),
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
- filterable: false,
303
- disableColumnMenu: true,
304
- renderCell: (params) => {
305
- const user = params.row;
306
- return (_jsxs(Box, { sx: { display: 'flex', flexWrap: 'wrap', gap: 1, alignItems: 'center', py: 0.5 }, children: [visibleRowActions.map((action) => {
307
- const actionId = `${action.key}:${user.id}`;
308
- const isBusy = Boolean(rowActionLoading[actionId]);
309
- const isDisabled = typeof action.disabled === 'function'
310
- ? action.disabled({
311
- user,
312
- canEdit: canEdit(user),
313
- currentUser,
314
- extraContext,
315
- t,
316
- })
317
- : false;
318
- return (_jsx(Button, { size: "small", variant: "outlined", onClick: () => runRowAction(action, user), disabled: isBusy || isDisabled, sx: actionButtonSx, children: typeof action.label === 'function'
319
- ? action.label({ user, t, currentUser, canEdit: canEdit(user) })
320
- : action.label }, `${action.key}-${user.id}`));
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
- 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 && (_jsx(Box, { sx: { width: '100%', minHeight: 520 }, children: _jsx(DataGrid, { rows: filteredUsers, columns: columns, disableRowSelectionOnClick: true, showToolbar: true, getRowHeight: () => 'auto', pageSizeOptions: [10, 25, 50, 100], initialState: {
343
- sorting: { sortModel: [{ field: 'email', sort: 'asc' }] },
344
- pagination: { paginationModel: { pageSize: 25, page: 0 } },
345
- }, localeText: {
346
- noRowsLabel: t('UserList.NO_USERS', 'No users found.'),
347
- toolbarQuickFilterPlaceholder: t('UserList.SEARCH_PLACEHOLDER', 'Search users...'),
348
- }, slotProps: {
349
- toolbar: {
350
- showQuickFilter: true,
351
- quickFilterProps: { debounceMs: 300 },
352
- },
353
- }, sx: {
354
- border: 1,
355
- borderColor: 'divider',
356
- '& .MuiDataGrid-cell': {
357
- display: 'flex',
358
- alignItems: 'flex-start',
359
- py: 1,
360
- },
361
- '& .MuiDataGrid-columnHeaders': {
362
- bgcolor: 'action.hover',
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
+ });