@micha.bigler/ui-core-micha 2.8.2 → 2.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/WORK_ORDERS.md +20 -0
- package/dist/i18n/onboardingTranslations.js +4 -0
- package/dist/index.js +1 -0
- package/dist/onboarding/OnboardingProvider.js +134 -6
- package/dist/onboarding/OnboardingWizard.js +5 -1
- package/dist/onboarding/steps/PwaInstallStep.js +28 -0
- package/package.json +1 -1
- package/src/i18n/onboardingTranslations.ts +4 -0
- package/src/index.js +1 -0
- package/src/onboarding/OnboardingProvider.jsx +149 -9
- package/src/onboarding/OnboardingWizard.jsx +5 -1
- package/src/onboarding/steps/PwaInstallStep.jsx +47 -0
- package/tests/OnboardingProvider.test.jsx +191 -5
- package/tests/onboardingDescriptors.test.js +64 -1
package/WORK_ORDERS.md
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# WORK_ORDERS.md — ui-core-micha
|
|
2
|
+
|
|
3
|
+
Work-order register for this repo. Lightweight directory (not the full orders):
|
|
4
|
+
one row per WO with its implementation status. Convention, schema, and maintenance
|
|
5
|
+
rules are defined centrally in `webapps/AGENTS.md` → "Work-Order Register".
|
|
6
|
+
|
|
7
|
+
## Workstream prefixes
|
|
8
|
+
|
|
9
|
+
| Prefix | Workstream |
|
|
10
|
+
|---|---|
|
|
11
|
+
| `ONB-*` | Onboarding wizard (steps, conditions, persistence) |
|
|
12
|
+
|
|
13
|
+
Introduce a new prefix when none fits and add it here. New WOs always get a
|
|
14
|
+
prefixed ID; never reuse a bare flat number across workstreams.
|
|
15
|
+
|
|
16
|
+
## Register
|
|
17
|
+
|
|
18
|
+
| ID | Titel | Beschreibung | Datum | Status | Commit(s) | Notiz |
|
|
19
|
+
|---|---|---|---|---|---|---|
|
|
20
|
+
| ONB-1 | Per-app configurable notifications onboarding step | New `browserPush` prop `{nagUntil, showOnce}` on `OnboardingProvider`; parameterizes the `browser_push` descriptor's condition (default changes from implicit "all-channels" to "any-channel", stopping the over-nag); `showOnce` via a persisted `onboarding_seen` set (frozen-at-mount ref, no mid-session flicker) | 2026-07-17 | done | 1ae7c20 | Default behavior change affects cockpit and all consumers on their next ucm bump. jg-ferien companion WO pins the new version with `browserPush={{nagUntil: 'any-channel'}}` explicit (matches new default, but pinned explicitly per the WO). |
|
|
@@ -22,4 +22,8 @@ export const onboardingTranslations = {
|
|
|
22
22
|
'Onboarding.PUSH_NOTIFICATIONS_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.' },
|
|
23
23
|
'Onboarding.PUSH_ENABLED': { de: 'Push-Benachrichtigungen sind auf diesem Gerät aktiviert.', fr: 'Les notifications push sont activées sur cet appareil.', en: 'Push notifications are enabled on this device.', sw: 'Arifa za push zimewashwa kwenye kifaa hiki.' },
|
|
24
24
|
'Onboarding.SKIP': { de: 'Überspringen', fr: 'Passer', en: 'Skip', sw: 'Ruka' },
|
|
25
|
+
'Onboarding.PWA_INSTALL_TITLE': { de: 'App installieren', fr: 'Installer l’app', en: 'Install app', sw: 'Sakinisha programu' },
|
|
26
|
+
'Onboarding.PWA_INSTALL_BODY': { de: 'Installiere die App auf deinem Gerät für schnelleren Zugriff und ein App-ähnliches Erlebnis.', fr: 'Installez l’application sur votre appareil pour un accès plus rapide et une expérience similaire à une app native.', en: 'Install the app on your device for faster access and an app-like experience.', sw: 'Sakinisha programu kwenye kifaa chako kwa ufikiaji wa haraka na hali ya matumizi kama programu.' },
|
|
27
|
+
'Onboarding.PWA_INSTALL_IOS_BODY': { de: 'Zum Installieren: Teilen-Symbol antippen und „Zum Home-Bildschirm“ wählen.', fr: 'Pour installer : appuyez sur Partager, puis « Sur l’écran d’accueil ».', en: 'To install: tap Share, then “Add to Home Screen”.', sw: 'Kusakinisha: gusa Shiriki, kisha chagua “Ongeza kwenye Skrini ya Kwanza”.' },
|
|
28
|
+
'Onboarding.PWA_INSTALL_ACTION': { de: 'Installieren', fr: 'Installer', en: 'Install', sw: 'Sakinisha' },
|
|
25
29
|
};
|
package/dist/index.js
CHANGED
|
@@ -40,6 +40,7 @@ export { OnboardingWizard } from './onboarding/OnboardingWizard';
|
|
|
40
40
|
export { CookieConsentStep } from './onboarding/steps/CookieConsentStep';
|
|
41
41
|
export { CompleteNameStep } from './onboarding/steps/CompleteNameStep';
|
|
42
42
|
export { BrowserPushStep } from './onboarding/steps/BrowserPushStep';
|
|
43
|
+
export { PwaInstallStep } from './onboarding/steps/PwaInstallStep';
|
|
43
44
|
// --- 9. Translations ---
|
|
44
45
|
export { notificationsTranslations } from './i18n/notificationsTranslations';
|
|
45
46
|
export { onboardingTranslations } from './i18n/onboardingTranslations';
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
-
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState, } from 'react';
|
|
2
|
+
import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, } from 'react';
|
|
3
3
|
import { AuthContext } from '../auth/AuthContext';
|
|
4
4
|
import { getNotificationPreferences } from '../notifications/api';
|
|
5
5
|
import { getOnboardingStepConfig } from './api';
|
|
@@ -7,6 +7,7 @@ import { selectActiveSteps } from './stepSelection';
|
|
|
7
7
|
import CookieConsentStep from './steps/CookieConsentStep';
|
|
8
8
|
import CompleteNameStep from './steps/CompleteNameStep';
|
|
9
9
|
import BrowserPushStep from './steps/BrowserPushStep';
|
|
10
|
+
import PwaInstallStep from './steps/PwaInstallStep';
|
|
10
11
|
export const UNIVERSAL_STEP_DESCRIPTORS = [
|
|
11
12
|
{
|
|
12
13
|
id: 'cookie_consent',
|
|
@@ -30,8 +31,17 @@ export const UNIVERSAL_STEP_DESCRIPTORS = [
|
|
|
30
31
|
id: 'browser_push',
|
|
31
32
|
condition: (ctx) => {
|
|
32
33
|
var _a, _b;
|
|
33
|
-
|
|
34
|
-
|
|
34
|
+
const { nagUntil = 'any-channel', showOnce = false } = ctx.browserPush || {};
|
|
35
|
+
if (showOnce && ((_a = ctx.seenSteps) === null || _a === void 0 ? void 0 : _a.has('browser_push')))
|
|
36
|
+
return false;
|
|
37
|
+
if (nagUntil === 'off')
|
|
38
|
+
return false;
|
|
39
|
+
const supported = (_b = ctx.pushState) === null || _b === void 0 ? void 0 : _b.supported;
|
|
40
|
+
if (!supported)
|
|
41
|
+
return !ctx.emailOptedIn;
|
|
42
|
+
if (nagUntil === 'all-channels')
|
|
43
|
+
return !ctx.pushState.subscribed || !ctx.emailOptedIn;
|
|
44
|
+
return !ctx.pushState.subscribed && !ctx.emailOptedIn; // 'any-channel' (default)
|
|
35
45
|
},
|
|
36
46
|
blocking: false,
|
|
37
47
|
skipable: true,
|
|
@@ -39,6 +49,26 @@ export const UNIVERSAL_STEP_DESCRIPTORS = [
|
|
|
39
49
|
titleKey: 'Onboarding.NOTIFICATIONS_TITLE',
|
|
40
50
|
Component: BrowserPushStep,
|
|
41
51
|
},
|
|
52
|
+
{
|
|
53
|
+
id: 'pwa_install',
|
|
54
|
+
// App-level opt-in (ctx.pwaInstallEnabled) gates this on top of device
|
|
55
|
+
// capability — a step being "universal" in the library means available
|
|
56
|
+
// to every app, not automatically shown by every app. Apps that haven't
|
|
57
|
+
// verified their PWA manifest/icons are actually installable must not
|
|
58
|
+
// pass pwaInstallEnabled, or this would show an iOS "Add to Home Screen"
|
|
59
|
+
// hint for an app that isn't really installable.
|
|
60
|
+
condition: (ctx) => {
|
|
61
|
+
var _a, _b, _c;
|
|
62
|
+
return Boolean(ctx.pwaInstallEnabled
|
|
63
|
+
&& !((_a = ctx.pwaInstall) === null || _a === void 0 ? void 0 : _a.isStandalone)
|
|
64
|
+
&& (Boolean((_b = ctx.pwaInstall) === null || _b === void 0 ? void 0 : _b.deferredPrompt) || Boolean((_c = ctx.pwaInstall) === null || _c === void 0 ? void 0 : _c.isIos)));
|
|
65
|
+
},
|
|
66
|
+
blocking: false,
|
|
67
|
+
skipable: true,
|
|
68
|
+
persistDismissed: true,
|
|
69
|
+
titleKey: 'Onboarding.PWA_INSTALL_TITLE',
|
|
70
|
+
Component: PwaInstallStep,
|
|
71
|
+
},
|
|
42
72
|
];
|
|
43
73
|
export const OnboardingContext = createContext(null);
|
|
44
74
|
export function useOnboarding() {
|
|
@@ -54,6 +84,28 @@ function loadDismissedSteps() {
|
|
|
54
84
|
return new Set();
|
|
55
85
|
}
|
|
56
86
|
}
|
|
87
|
+
function loadSeenSteps() {
|
|
88
|
+
if (typeof window === 'undefined')
|
|
89
|
+
return new Set();
|
|
90
|
+
try {
|
|
91
|
+
return new Set(JSON.parse(window.localStorage.getItem('onboarding_seen')) || []);
|
|
92
|
+
}
|
|
93
|
+
catch (_a) {
|
|
94
|
+
return new Set();
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
function persistSeenStep(id) {
|
|
98
|
+
if (typeof window === 'undefined')
|
|
99
|
+
return;
|
|
100
|
+
try {
|
|
101
|
+
const seenSteps = loadSeenSteps();
|
|
102
|
+
seenSteps.add(id);
|
|
103
|
+
window.localStorage.setItem('onboarding_seen', JSON.stringify([...seenSteps]));
|
|
104
|
+
}
|
|
105
|
+
catch (_a) {
|
|
106
|
+
// Persistence is optional; the current session remains unaffected.
|
|
107
|
+
}
|
|
108
|
+
}
|
|
57
109
|
function getInitialPushState() {
|
|
58
110
|
const supported = typeof navigator !== 'undefined'
|
|
59
111
|
&& typeof window !== 'undefined'
|
|
@@ -61,17 +113,35 @@ function getInitialPushState() {
|
|
|
61
113
|
&& 'PushManager' in window;
|
|
62
114
|
return supported ? null : { supported: false, subscribed: false };
|
|
63
115
|
}
|
|
116
|
+
function getInitialPwaInstallState() {
|
|
117
|
+
var _a;
|
|
118
|
+
if (typeof window === 'undefined' || typeof navigator === 'undefined') {
|
|
119
|
+
return { isStandalone: false, isIos: false, deferredPrompt: null };
|
|
120
|
+
}
|
|
121
|
+
const isStandalone = Boolean(((_a = window.matchMedia) === null || _a === void 0 ? void 0 : _a.call(window, '(display-mode: standalone)').matches) || navigator.standalone);
|
|
122
|
+
// iPadOS 13+ reports a desktop Safari/Mac user agent by default, so the
|
|
123
|
+
// classic /ipad/i UA sniff misses it — the standard workaround is
|
|
124
|
+
// detecting touch support on a reported Mac platform (real Macs report
|
|
125
|
+
// maxTouchPoints 0).
|
|
126
|
+
const isIos = /iphone|ipad|ipod/i.test(navigator.userAgent || '')
|
|
127
|
+
|| (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
|
128
|
+
return { isStandalone, isIos, deferredPrompt: null };
|
|
129
|
+
}
|
|
64
130
|
// Stable empty-object default — an inline `{}` default would create a new
|
|
65
131
|
// reference every render, invalidating the `ctx` memo below for every
|
|
66
132
|
// consumer even when no extraContext is passed.
|
|
67
133
|
const EMPTY_EXTRA_CONTEXT = {};
|
|
68
|
-
|
|
134
|
+
const EMPTY_BROWSER_PUSH = {};
|
|
135
|
+
export function OnboardingProvider({ children, extraSteps = [], extraContext = EMPTY_EXTRA_CONTEXT, pwaInstallEnabled = false, browserPush = EMPTY_BROWSER_PUSH, }) {
|
|
69
136
|
const { user } = useContext(AuthContext);
|
|
70
137
|
const [configMap, setConfigMap] = useState({});
|
|
71
138
|
const [configLoaded, setConfigLoaded] = useState(false);
|
|
72
139
|
const [pushState, setPushState] = useState(getInitialPushState);
|
|
73
140
|
const [emailOptedIn, setEmailOptedIn] = useState(false);
|
|
141
|
+
const [pwaInstall, setPwaInstall] = useState(getInitialPwaInstallState);
|
|
74
142
|
const [dismissedSet, setDismissedSet] = useState(loadDismissedSteps);
|
|
143
|
+
const seenAtMountRef = useRef(loadSeenSteps());
|
|
144
|
+
const resolvedBrowserPush = useMemo(() => (Object.assign({ nagUntil: 'any-channel', showOnce: false }, browserPush)), [browserPush]);
|
|
75
145
|
const descriptors = useMemo(() => [...UNIVERSAL_STEP_DESCRIPTORS, ...extraSteps], [extraSteps]);
|
|
76
146
|
useEffect(() => {
|
|
77
147
|
let cancelled = false;
|
|
@@ -117,6 +187,48 @@ export function OnboardingProvider({ children, extraSteps = [], extraContext = E
|
|
|
117
187
|
});
|
|
118
188
|
return () => { cancelled = true; };
|
|
119
189
|
}, []);
|
|
190
|
+
// Capture beforeinstallprompt as early as possible (at provider mount, not
|
|
191
|
+
// when the wizard step happens to render) — Chrome fires it once, early,
|
|
192
|
+
// and calling preventDefault() late means losing the deferred prompt
|
|
193
|
+
// reference for good. Gated behind pwaInstallEnabled: calling
|
|
194
|
+
// preventDefault() unconditionally would suppress the browser's own native
|
|
195
|
+
// install UI for every app using this library, even ones that never opted
|
|
196
|
+
// in and have no replacement UI to show instead.
|
|
197
|
+
useEffect(() => {
|
|
198
|
+
var _a;
|
|
199
|
+
if (typeof window === 'undefined' || !pwaInstallEnabled)
|
|
200
|
+
return undefined;
|
|
201
|
+
const mediaQuery = (_a = window.matchMedia) === null || _a === void 0 ? void 0 : _a.call(window, '(display-mode: standalone)');
|
|
202
|
+
const handleDisplayModeChange = (event) => {
|
|
203
|
+
if (event.matches)
|
|
204
|
+
setPwaInstall((prev) => (Object.assign(Object.assign({}, prev), { isStandalone: true })));
|
|
205
|
+
};
|
|
206
|
+
const handleBeforeInstallPrompt = (event) => {
|
|
207
|
+
event.preventDefault();
|
|
208
|
+
setPwaInstall((prev) => (Object.assign(Object.assign({}, prev), { deferredPrompt: event })));
|
|
209
|
+
};
|
|
210
|
+
const handleAppInstalled = () => {
|
|
211
|
+
setPwaInstall((prev) => (Object.assign(Object.assign({}, prev), { isStandalone: true, deferredPrompt: null })));
|
|
212
|
+
};
|
|
213
|
+
if (mediaQuery === null || mediaQuery === void 0 ? void 0 : mediaQuery.addEventListener) {
|
|
214
|
+
mediaQuery.addEventListener('change', handleDisplayModeChange);
|
|
215
|
+
}
|
|
216
|
+
else if (mediaQuery === null || mediaQuery === void 0 ? void 0 : mediaQuery.addListener) {
|
|
217
|
+
mediaQuery.addListener(handleDisplayModeChange);
|
|
218
|
+
}
|
|
219
|
+
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
|
|
220
|
+
window.addEventListener('appinstalled', handleAppInstalled);
|
|
221
|
+
return () => {
|
|
222
|
+
if (mediaQuery === null || mediaQuery === void 0 ? void 0 : mediaQuery.removeEventListener) {
|
|
223
|
+
mediaQuery.removeEventListener('change', handleDisplayModeChange);
|
|
224
|
+
}
|
|
225
|
+
else if (mediaQuery === null || mediaQuery === void 0 ? void 0 : mediaQuery.removeListener) {
|
|
226
|
+
mediaQuery.removeListener(handleDisplayModeChange);
|
|
227
|
+
}
|
|
228
|
+
window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
|
|
229
|
+
window.removeEventListener('appinstalled', handleAppInstalled);
|
|
230
|
+
};
|
|
231
|
+
}, [pwaInstallEnabled]);
|
|
120
232
|
useEffect(() => {
|
|
121
233
|
let cancelled = false;
|
|
122
234
|
if (!user) {
|
|
@@ -150,13 +262,29 @@ export function OnboardingProvider({ children, extraSteps = [], extraContext = E
|
|
|
150
262
|
return next;
|
|
151
263
|
});
|
|
152
264
|
}, []);
|
|
265
|
+
const markStepSeen = useCallback((id) => {
|
|
266
|
+
persistSeenStep(id);
|
|
267
|
+
}, []);
|
|
153
268
|
// extraContext is spread last: duplicate core keys intentionally override core values, so apps should use distinct names.
|
|
154
|
-
const ctx = useMemo(() => (Object.assign({ user,
|
|
269
|
+
const ctx = useMemo(() => (Object.assign({ user,
|
|
270
|
+
pushState,
|
|
271
|
+
emailOptedIn,
|
|
272
|
+
pwaInstall,
|
|
273
|
+
pwaInstallEnabled, browserPush: resolvedBrowserPush, seenSteps: seenAtMountRef.current }, extraContext)), [
|
|
274
|
+
user,
|
|
275
|
+
pushState,
|
|
276
|
+
emailOptedIn,
|
|
277
|
+
pwaInstall,
|
|
278
|
+
pwaInstallEnabled,
|
|
279
|
+
resolvedBrowserPush,
|
|
280
|
+
seenAtMountRef.current,
|
|
281
|
+
extraContext,
|
|
282
|
+
]);
|
|
155
283
|
const activeSteps = useMemo(() => {
|
|
156
284
|
if (!configLoaded || !user)
|
|
157
285
|
return [];
|
|
158
286
|
return selectActiveSteps(descriptors, configMap, ctx, dismissedSet);
|
|
159
287
|
}, [configLoaded, user, descriptors, configMap, ctx, dismissedSet]);
|
|
160
|
-
const value = useMemo(() => ({ activeSteps, dismissStep, ctx, configMap, setConfigMap }), [activeSteps, dismissStep, ctx, configMap]);
|
|
288
|
+
const value = useMemo(() => ({ activeSteps, dismissStep, markStepSeen, ctx, configMap, setConfigMap }), [activeSteps, dismissStep, markStepSeen, ctx, configMap]);
|
|
161
289
|
return _jsx(OnboardingContext.Provider, { value: value, children: children });
|
|
162
290
|
}
|
|
@@ -18,6 +18,7 @@ export function OnboardingWizard() {
|
|
|
18
18
|
const [completed, setCompleted] = useState(0);
|
|
19
19
|
const activeSteps = (onboarding === null || onboarding === void 0 ? void 0 : onboarding.activeSteps) || [];
|
|
20
20
|
const visibleSteps = activeSteps.filter((step) => !sessionDismissed.has(step.id));
|
|
21
|
+
const currentStep = visibleSteps[0];
|
|
21
22
|
useEffect(() => {
|
|
22
23
|
if (visibleSteps.length > 0 && totalRef.current === null) {
|
|
23
24
|
totalRef.current = visibleSteps.length;
|
|
@@ -28,10 +29,13 @@ export function OnboardingWizard() {
|
|
|
28
29
|
setCompleted(0);
|
|
29
30
|
}
|
|
30
31
|
}, [visibleSteps.length]);
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
if (onboarding && currentStep)
|
|
34
|
+
onboarding.markStepSeen(currentStep.id);
|
|
35
|
+
}, [onboarding, currentStep === null || currentStep === void 0 ? void 0 : currentStep.id]);
|
|
31
36
|
if (!onboarding || visibleSteps.length === 0)
|
|
32
37
|
return null;
|
|
33
38
|
const { dismissStep, ctx } = onboarding;
|
|
34
|
-
const currentStep = visibleSteps[0];
|
|
35
39
|
const total = totalRef.current || visibleSteps.length;
|
|
36
40
|
const progress = total > 1 ? Math.round((completed / total) * 100) : 0;
|
|
37
41
|
const StepComponent = currentStep.Component;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { useTranslation } from 'react-i18next';
|
|
4
|
+
import Box from '@mui/material/Box';
|
|
5
|
+
import Button from '@mui/material/Button';
|
|
6
|
+
import Typography from '@mui/material/Typography';
|
|
7
|
+
import InstallMobileIcon from '@mui/icons-material/InstallMobile';
|
|
8
|
+
export function PwaInstallStep({ onComplete, onDismiss, ctx }) {
|
|
9
|
+
var _a, _b;
|
|
10
|
+
const { t } = useTranslation();
|
|
11
|
+
const isIos = Boolean((_a = ctx === null || ctx === void 0 ? void 0 : ctx.pwaInstall) === null || _a === void 0 ? void 0 : _a.isIos);
|
|
12
|
+
const deferredPrompt = (_b = ctx === null || ctx === void 0 ? void 0 : ctx.pwaInstall) === null || _b === void 0 ? void 0 : _b.deferredPrompt;
|
|
13
|
+
const handleInstall = async () => {
|
|
14
|
+
if (!deferredPrompt) {
|
|
15
|
+
onComplete();
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
deferredPrompt.prompt();
|
|
19
|
+
try {
|
|
20
|
+
await deferredPrompt.userChoice;
|
|
21
|
+
}
|
|
22
|
+
finally {
|
|
23
|
+
onComplete();
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
return (_jsxs(Box, { sx: { display: 'flex', flexDirection: 'column', gap: 2 }, children: [_jsxs(Box, { sx: { display: 'flex', alignItems: 'center', gap: 1 }, children: [_jsx(InstallMobileIcon, { color: "primary" }), _jsx(Typography, { variant: "h6", children: t('Onboarding.PWA_INSTALL_TITLE') })] }), _jsx(Typography, { variant: "body2", color: "text.secondary", children: t(isIos ? 'Onboarding.PWA_INSTALL_IOS_BODY' : 'Onboarding.PWA_INSTALL_BODY') }), _jsxs(Box, { sx: { display: 'flex', justifyContent: 'flex-end', gap: 1 }, children: [_jsx(Button, { variant: "text", onClick: onDismiss, children: t('Onboarding.SKIP') }), isIos ? (_jsx(Button, { variant: "contained", onClick: onComplete, children: t('Onboarding.CONTINUE') })) : (_jsx(Button, { variant: "contained", onClick: handleInstall, children: t('Onboarding.PWA_INSTALL_ACTION') }))] })] }));
|
|
27
|
+
}
|
|
28
|
+
export default PwaInstallStep;
|
package/package.json
CHANGED
|
@@ -22,4 +22,8 @@ export const onboardingTranslations = {
|
|
|
22
22
|
'Onboarding.PUSH_NOTIFICATIONS_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.' },
|
|
23
23
|
'Onboarding.PUSH_ENABLED': { de: 'Push-Benachrichtigungen sind auf diesem Gerät aktiviert.', fr: 'Les notifications push sont activées sur cet appareil.', en: 'Push notifications are enabled on this device.', sw: 'Arifa za push zimewashwa kwenye kifaa hiki.' },
|
|
24
24
|
'Onboarding.SKIP': { de: 'Überspringen', fr: 'Passer', en: 'Skip', sw: 'Ruka' },
|
|
25
|
+
'Onboarding.PWA_INSTALL_TITLE': { de: 'App installieren', fr: 'Installer l’app', en: 'Install app', sw: 'Sakinisha programu' },
|
|
26
|
+
'Onboarding.PWA_INSTALL_BODY': { de: 'Installiere die App auf deinem Gerät für schnelleren Zugriff und ein App-ähnliches Erlebnis.', fr: 'Installez l’application sur votre appareil pour un accès plus rapide et une expérience similaire à une app native.', en: 'Install the app on your device for faster access and an app-like experience.', sw: 'Sakinisha programu kwenye kifaa chako kwa ufikiaji wa haraka na hali ya matumizi kama programu.' },
|
|
27
|
+
'Onboarding.PWA_INSTALL_IOS_BODY': { de: 'Zum Installieren: Teilen-Symbol antippen und „Zum Home-Bildschirm“ wählen.', fr: 'Pour installer : appuyez sur Partager, puis « Sur l’écran d’accueil ».', en: 'To install: tap Share, then “Add to Home Screen”.', sw: 'Kusakinisha: gusa Shiriki, kisha chagua “Ongeza kwenye Skrini ya Kwanza”.' },
|
|
28
|
+
'Onboarding.PWA_INSTALL_ACTION': { de: 'Installieren', fr: 'Installer', en: 'Install', sw: 'Sakinisha' },
|
|
25
29
|
};
|
package/src/index.js
CHANGED
|
@@ -59,6 +59,7 @@ export { OnboardingWizard } from './onboarding/OnboardingWizard';
|
|
|
59
59
|
export { CookieConsentStep } from './onboarding/steps/CookieConsentStep';
|
|
60
60
|
export { CompleteNameStep } from './onboarding/steps/CompleteNameStep';
|
|
61
61
|
export { BrowserPushStep } from './onboarding/steps/BrowserPushStep';
|
|
62
|
+
export { PwaInstallStep } from './onboarding/steps/PwaInstallStep';
|
|
62
63
|
|
|
63
64
|
// --- 9. Translations ---
|
|
64
65
|
export { notificationsTranslations } from './i18n/notificationsTranslations';
|
|
@@ -4,6 +4,7 @@ import React, {
|
|
|
4
4
|
useContext,
|
|
5
5
|
useEffect,
|
|
6
6
|
useMemo,
|
|
7
|
+
useRef,
|
|
7
8
|
useState,
|
|
8
9
|
} from 'react';
|
|
9
10
|
import { AuthContext } from '../auth/AuthContext';
|
|
@@ -13,6 +14,7 @@ import { selectActiveSteps } from './stepSelection';
|
|
|
13
14
|
import CookieConsentStep from './steps/CookieConsentStep';
|
|
14
15
|
import CompleteNameStep from './steps/CompleteNameStep';
|
|
15
16
|
import BrowserPushStep from './steps/BrowserPushStep';
|
|
17
|
+
import PwaInstallStep from './steps/PwaInstallStep';
|
|
16
18
|
|
|
17
19
|
export const UNIVERSAL_STEP_DESCRIPTORS = [
|
|
18
20
|
{
|
|
@@ -35,16 +37,40 @@ export const UNIVERSAL_STEP_DESCRIPTORS = [
|
|
|
35
37
|
},
|
|
36
38
|
{
|
|
37
39
|
id: 'browser_push',
|
|
38
|
-
condition: (ctx) =>
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
condition: (ctx) => {
|
|
41
|
+
const { nagUntil = 'any-channel', showOnce = false } = ctx.browserPush || {};
|
|
42
|
+
if (showOnce && ctx.seenSteps?.has('browser_push')) return false;
|
|
43
|
+
if (nagUntil === 'off') return false;
|
|
44
|
+
const supported = ctx.pushState?.supported;
|
|
45
|
+
if (!supported) return !ctx.emailOptedIn;
|
|
46
|
+
if (nagUntil === 'all-channels') return !ctx.pushState.subscribed || !ctx.emailOptedIn;
|
|
47
|
+
return !ctx.pushState.subscribed && !ctx.emailOptedIn; // 'any-channel' (default)
|
|
48
|
+
},
|
|
42
49
|
blocking: false,
|
|
43
50
|
skipable: true,
|
|
44
51
|
persistDismissed: true,
|
|
45
52
|
titleKey: 'Onboarding.NOTIFICATIONS_TITLE',
|
|
46
53
|
Component: BrowserPushStep,
|
|
47
54
|
},
|
|
55
|
+
{
|
|
56
|
+
id: 'pwa_install',
|
|
57
|
+
// App-level opt-in (ctx.pwaInstallEnabled) gates this on top of device
|
|
58
|
+
// capability — a step being "universal" in the library means available
|
|
59
|
+
// to every app, not automatically shown by every app. Apps that haven't
|
|
60
|
+
// verified their PWA manifest/icons are actually installable must not
|
|
61
|
+
// pass pwaInstallEnabled, or this would show an iOS "Add to Home Screen"
|
|
62
|
+
// hint for an app that isn't really installable.
|
|
63
|
+
condition: (ctx) => Boolean(
|
|
64
|
+
ctx.pwaInstallEnabled
|
|
65
|
+
&& !ctx.pwaInstall?.isStandalone
|
|
66
|
+
&& (Boolean(ctx.pwaInstall?.deferredPrompt) || Boolean(ctx.pwaInstall?.isIos)),
|
|
67
|
+
),
|
|
68
|
+
blocking: false,
|
|
69
|
+
skipable: true,
|
|
70
|
+
persistDismissed: true,
|
|
71
|
+
titleKey: 'Onboarding.PWA_INSTALL_TITLE',
|
|
72
|
+
Component: PwaInstallStep,
|
|
73
|
+
},
|
|
48
74
|
];
|
|
49
75
|
|
|
50
76
|
export const OnboardingContext = createContext(null);
|
|
@@ -62,6 +88,26 @@ function loadDismissedSteps() {
|
|
|
62
88
|
}
|
|
63
89
|
}
|
|
64
90
|
|
|
91
|
+
function loadSeenSteps() {
|
|
92
|
+
if (typeof window === 'undefined') return new Set();
|
|
93
|
+
try {
|
|
94
|
+
return new Set(JSON.parse(window.localStorage.getItem('onboarding_seen')) || []);
|
|
95
|
+
} catch {
|
|
96
|
+
return new Set();
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function persistSeenStep(id) {
|
|
101
|
+
if (typeof window === 'undefined') return;
|
|
102
|
+
try {
|
|
103
|
+
const seenSteps = loadSeenSteps();
|
|
104
|
+
seenSteps.add(id);
|
|
105
|
+
window.localStorage.setItem('onboarding_seen', JSON.stringify([...seenSteps]));
|
|
106
|
+
} catch {
|
|
107
|
+
// Persistence is optional; the current session remains unaffected.
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
65
111
|
function getInitialPushState() {
|
|
66
112
|
const supported = typeof navigator !== 'undefined'
|
|
67
113
|
&& typeof window !== 'undefined'
|
|
@@ -70,18 +116,48 @@ function getInitialPushState() {
|
|
|
70
116
|
return supported ? null : { supported: false, subscribed: false };
|
|
71
117
|
}
|
|
72
118
|
|
|
119
|
+
function getInitialPwaInstallState() {
|
|
120
|
+
if (typeof window === 'undefined' || typeof navigator === 'undefined') {
|
|
121
|
+
return { isStandalone: false, isIos: false, deferredPrompt: null };
|
|
122
|
+
}
|
|
123
|
+
const isStandalone = Boolean(
|
|
124
|
+
window.matchMedia?.('(display-mode: standalone)').matches || navigator.standalone,
|
|
125
|
+
);
|
|
126
|
+
// iPadOS 13+ reports a desktop Safari/Mac user agent by default, so the
|
|
127
|
+
// classic /ipad/i UA sniff misses it — the standard workaround is
|
|
128
|
+
// detecting touch support on a reported Mac platform (real Macs report
|
|
129
|
+
// maxTouchPoints 0).
|
|
130
|
+
const isIos = /iphone|ipad|ipod/i.test(navigator.userAgent || '')
|
|
131
|
+
|| (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
|
132
|
+
return { isStandalone, isIos, deferredPrompt: null };
|
|
133
|
+
}
|
|
134
|
+
|
|
73
135
|
// Stable empty-object default — an inline `{}` default would create a new
|
|
74
136
|
// reference every render, invalidating the `ctx` memo below for every
|
|
75
137
|
// consumer even when no extraContext is passed.
|
|
76
138
|
const EMPTY_EXTRA_CONTEXT = {};
|
|
139
|
+
const EMPTY_BROWSER_PUSH = {};
|
|
77
140
|
|
|
78
|
-
export function OnboardingProvider({
|
|
141
|
+
export function OnboardingProvider({
|
|
142
|
+
children,
|
|
143
|
+
extraSteps = [],
|
|
144
|
+
extraContext = EMPTY_EXTRA_CONTEXT,
|
|
145
|
+
pwaInstallEnabled = false,
|
|
146
|
+
browserPush = EMPTY_BROWSER_PUSH,
|
|
147
|
+
}) {
|
|
79
148
|
const { user } = useContext(AuthContext);
|
|
80
149
|
const [configMap, setConfigMap] = useState({});
|
|
81
150
|
const [configLoaded, setConfigLoaded] = useState(false);
|
|
82
151
|
const [pushState, setPushState] = useState(getInitialPushState);
|
|
83
152
|
const [emailOptedIn, setEmailOptedIn] = useState(false);
|
|
153
|
+
const [pwaInstall, setPwaInstall] = useState(getInitialPwaInstallState);
|
|
84
154
|
const [dismissedSet, setDismissedSet] = useState(loadDismissedSteps);
|
|
155
|
+
const seenAtMountRef = useRef(loadSeenSteps());
|
|
156
|
+
|
|
157
|
+
const resolvedBrowserPush = useMemo(
|
|
158
|
+
() => ({ nagUntil: 'any-channel', showOnce: false, ...browserPush }),
|
|
159
|
+
[browserPush],
|
|
160
|
+
);
|
|
85
161
|
|
|
86
162
|
const descriptors = useMemo(
|
|
87
163
|
() => [...UNIVERSAL_STEP_DESCRIPTORS, ...extraSteps],
|
|
@@ -133,6 +209,48 @@ export function OnboardingProvider({ children, extraSteps = [], extraContext = E
|
|
|
133
209
|
return () => { cancelled = true; };
|
|
134
210
|
}, []);
|
|
135
211
|
|
|
212
|
+
// Capture beforeinstallprompt as early as possible (at provider mount, not
|
|
213
|
+
// when the wizard step happens to render) — Chrome fires it once, early,
|
|
214
|
+
// and calling preventDefault() late means losing the deferred prompt
|
|
215
|
+
// reference for good. Gated behind pwaInstallEnabled: calling
|
|
216
|
+
// preventDefault() unconditionally would suppress the browser's own native
|
|
217
|
+
// install UI for every app using this library, even ones that never opted
|
|
218
|
+
// in and have no replacement UI to show instead.
|
|
219
|
+
useEffect(() => {
|
|
220
|
+
if (typeof window === 'undefined' || !pwaInstallEnabled) return undefined;
|
|
221
|
+
|
|
222
|
+
const mediaQuery = window.matchMedia?.('(display-mode: standalone)');
|
|
223
|
+
|
|
224
|
+
const handleDisplayModeChange = (event) => {
|
|
225
|
+
if (event.matches) setPwaInstall((prev) => ({ ...prev, isStandalone: true }));
|
|
226
|
+
};
|
|
227
|
+
const handleBeforeInstallPrompt = (event) => {
|
|
228
|
+
event.preventDefault();
|
|
229
|
+
setPwaInstall((prev) => ({ ...prev, deferredPrompt: event }));
|
|
230
|
+
};
|
|
231
|
+
const handleAppInstalled = () => {
|
|
232
|
+
setPwaInstall((prev) => ({ ...prev, isStandalone: true, deferredPrompt: null }));
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
if (mediaQuery?.addEventListener) {
|
|
236
|
+
mediaQuery.addEventListener('change', handleDisplayModeChange);
|
|
237
|
+
} else if (mediaQuery?.addListener) {
|
|
238
|
+
mediaQuery.addListener(handleDisplayModeChange);
|
|
239
|
+
}
|
|
240
|
+
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
|
|
241
|
+
window.addEventListener('appinstalled', handleAppInstalled);
|
|
242
|
+
|
|
243
|
+
return () => {
|
|
244
|
+
if (mediaQuery?.removeEventListener) {
|
|
245
|
+
mediaQuery.removeEventListener('change', handleDisplayModeChange);
|
|
246
|
+
} else if (mediaQuery?.removeListener) {
|
|
247
|
+
mediaQuery.removeListener(handleDisplayModeChange);
|
|
248
|
+
}
|
|
249
|
+
window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
|
|
250
|
+
window.removeEventListener('appinstalled', handleAppInstalled);
|
|
251
|
+
};
|
|
252
|
+
}, [pwaInstallEnabled]);
|
|
253
|
+
|
|
136
254
|
useEffect(() => {
|
|
137
255
|
let cancelled = false;
|
|
138
256
|
if (!user) {
|
|
@@ -167,10 +285,32 @@ export function OnboardingProvider({ children, extraSteps = [], extraContext = E
|
|
|
167
285
|
});
|
|
168
286
|
}, []);
|
|
169
287
|
|
|
288
|
+
const markStepSeen = useCallback((id) => {
|
|
289
|
+
persistSeenStep(id);
|
|
290
|
+
}, []);
|
|
291
|
+
|
|
170
292
|
// extraContext is spread last: duplicate core keys intentionally override core values, so apps should use distinct names.
|
|
171
293
|
const ctx = useMemo(
|
|
172
|
-
() => ({
|
|
173
|
-
|
|
294
|
+
() => ({
|
|
295
|
+
user,
|
|
296
|
+
pushState,
|
|
297
|
+
emailOptedIn,
|
|
298
|
+
pwaInstall,
|
|
299
|
+
pwaInstallEnabled,
|
|
300
|
+
browserPush: resolvedBrowserPush,
|
|
301
|
+
seenSteps: seenAtMountRef.current,
|
|
302
|
+
...extraContext,
|
|
303
|
+
}),
|
|
304
|
+
[
|
|
305
|
+
user,
|
|
306
|
+
pushState,
|
|
307
|
+
emailOptedIn,
|
|
308
|
+
pwaInstall,
|
|
309
|
+
pwaInstallEnabled,
|
|
310
|
+
resolvedBrowserPush,
|
|
311
|
+
seenAtMountRef.current,
|
|
312
|
+
extraContext,
|
|
313
|
+
],
|
|
174
314
|
);
|
|
175
315
|
const activeSteps = useMemo(() => {
|
|
176
316
|
if (!configLoaded || !user) return [];
|
|
@@ -178,8 +318,8 @@ export function OnboardingProvider({ children, extraSteps = [], extraContext = E
|
|
|
178
318
|
}, [configLoaded, user, descriptors, configMap, ctx, dismissedSet]);
|
|
179
319
|
|
|
180
320
|
const value = useMemo(
|
|
181
|
-
() => ({ activeSteps, dismissStep, ctx, configMap, setConfigMap }),
|
|
182
|
-
[activeSteps, dismissStep, ctx, configMap],
|
|
321
|
+
() => ({ activeSteps, dismissStep, markStepSeen, ctx, configMap, setConfigMap }),
|
|
322
|
+
[activeSteps, dismissStep, markStepSeen, ctx, configMap],
|
|
183
323
|
);
|
|
184
324
|
|
|
185
325
|
return <OnboardingContext.Provider value={value}>{children}</OnboardingContext.Provider>;
|
|
@@ -18,6 +18,7 @@ export function OnboardingWizard() {
|
|
|
18
18
|
const [completed, setCompleted] = useState(0);
|
|
19
19
|
const activeSteps = onboarding?.activeSteps || [];
|
|
20
20
|
const visibleSteps = activeSteps.filter((step) => !sessionDismissed.has(step.id));
|
|
21
|
+
const currentStep = visibleSteps[0];
|
|
21
22
|
|
|
22
23
|
useEffect(() => {
|
|
23
24
|
if (visibleSteps.length > 0 && totalRef.current === null) {
|
|
@@ -30,10 +31,13 @@ export function OnboardingWizard() {
|
|
|
30
31
|
}
|
|
31
32
|
}, [visibleSteps.length]);
|
|
32
33
|
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
if (onboarding && currentStep) onboarding.markStepSeen(currentStep.id);
|
|
36
|
+
}, [onboarding, currentStep?.id]);
|
|
37
|
+
|
|
33
38
|
if (!onboarding || visibleSteps.length === 0) return null;
|
|
34
39
|
|
|
35
40
|
const { dismissStep, ctx } = onboarding;
|
|
36
|
-
const currentStep = visibleSteps[0];
|
|
37
41
|
const total = totalRef.current || visibleSteps.length;
|
|
38
42
|
const progress = total > 1 ? Math.round((completed / total) * 100) : 0;
|
|
39
43
|
const StepComponent = currentStep.Component;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { useTranslation } from 'react-i18next';
|
|
3
|
+
import Box from '@mui/material/Box';
|
|
4
|
+
import Button from '@mui/material/Button';
|
|
5
|
+
import Typography from '@mui/material/Typography';
|
|
6
|
+
import InstallMobileIcon from '@mui/icons-material/InstallMobile';
|
|
7
|
+
|
|
8
|
+
export function PwaInstallStep({ onComplete, onDismiss, ctx }) {
|
|
9
|
+
const { t } = useTranslation();
|
|
10
|
+
const isIos = Boolean(ctx?.pwaInstall?.isIos);
|
|
11
|
+
const deferredPrompt = ctx?.pwaInstall?.deferredPrompt;
|
|
12
|
+
|
|
13
|
+
const handleInstall = async () => {
|
|
14
|
+
if (!deferredPrompt) {
|
|
15
|
+
onComplete();
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
deferredPrompt.prompt();
|
|
19
|
+
try {
|
|
20
|
+
await deferredPrompt.userChoice;
|
|
21
|
+
} finally {
|
|
22
|
+
onComplete();
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
|
28
|
+
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
29
|
+
<InstallMobileIcon color="primary" />
|
|
30
|
+
<Typography variant="h6">{t('Onboarding.PWA_INSTALL_TITLE')}</Typography>
|
|
31
|
+
</Box>
|
|
32
|
+
<Typography variant="body2" color="text.secondary">
|
|
33
|
+
{t(isIos ? 'Onboarding.PWA_INSTALL_IOS_BODY' : 'Onboarding.PWA_INSTALL_BODY')}
|
|
34
|
+
</Typography>
|
|
35
|
+
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
|
|
36
|
+
<Button variant="text" onClick={onDismiss}>{t('Onboarding.SKIP')}</Button>
|
|
37
|
+
{isIos ? (
|
|
38
|
+
<Button variant="contained" onClick={onComplete}>{t('Onboarding.CONTINUE')}</Button>
|
|
39
|
+
) : (
|
|
40
|
+
<Button variant="contained" onClick={handleInstall}>{t('Onboarding.PWA_INSTALL_ACTION')}</Button>
|
|
41
|
+
)}
|
|
42
|
+
</Box>
|
|
43
|
+
</Box>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export default PwaInstallStep;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// @vitest-environment jsdom
|
|
2
2
|
import React from 'react';
|
|
3
|
-
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
-
import { cleanup, render, screen, waitFor } from '@testing-library/react';
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
5
5
|
|
|
6
6
|
const onboardingApi = vi.hoisted(() => ({
|
|
7
7
|
getOnboardingStepConfig: vi.fn(),
|
|
@@ -23,10 +23,25 @@ function Probe() {
|
|
|
23
23
|
|
|
24
24
|
function ActiveStepsProbe() {
|
|
25
25
|
const onboarding = useOnboarding();
|
|
26
|
-
return
|
|
26
|
+
return (
|
|
27
|
+
<>
|
|
28
|
+
<span data-testid="active-steps">{onboarding?.activeSteps.map((step) => step.id).join(',')}</span>
|
|
29
|
+
<span data-testid="push-subscribed">{String(onboarding?.ctx.pushState?.subscribed)}</span>
|
|
30
|
+
</>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function MarkStepSeenProbe() {
|
|
35
|
+
const onboarding = useOnboarding();
|
|
36
|
+
return (
|
|
37
|
+
<>
|
|
38
|
+
<ActiveStepsProbe />
|
|
39
|
+
<button type="button" onClick={() => onboarding.markStepSeen('browser_push')}>Mark browser push seen</button>
|
|
40
|
+
</>
|
|
41
|
+
);
|
|
27
42
|
}
|
|
28
43
|
|
|
29
|
-
function renderProvider({ extraContext, extraSteps, children = <Probe /> } = {}) {
|
|
44
|
+
function renderProvider({ browserPush, extraContext, extraSteps, pwaInstallEnabled, children = <Probe /> } = {}) {
|
|
30
45
|
return render(
|
|
31
46
|
<AuthContext.Provider value={{
|
|
32
47
|
user: {
|
|
@@ -36,7 +51,14 @@ function renderProvider({ extraContext, extraSteps, children = <Probe /> } = {})
|
|
|
36
51
|
last_name: 'Lovelace',
|
|
37
52
|
},
|
|
38
53
|
}}>
|
|
39
|
-
<OnboardingProvider
|
|
54
|
+
<OnboardingProvider
|
|
55
|
+
extraContext={extraContext}
|
|
56
|
+
extraSteps={extraSteps}
|
|
57
|
+
pwaInstallEnabled={pwaInstallEnabled}
|
|
58
|
+
browserPush={browserPush}
|
|
59
|
+
>
|
|
60
|
+
{children}
|
|
61
|
+
</OnboardingProvider>
|
|
40
62
|
</AuthContext.Provider>,
|
|
41
63
|
);
|
|
42
64
|
}
|
|
@@ -106,3 +128,167 @@ describe('OnboardingProvider extra context', () => {
|
|
|
106
128
|
expect(screen.getByTestId('active-steps').textContent).not.toContain('unread_messages');
|
|
107
129
|
});
|
|
108
130
|
});
|
|
131
|
+
|
|
132
|
+
describe('OnboardingProvider pwa_install capture', () => {
|
|
133
|
+
beforeEach(() => {
|
|
134
|
+
cleanup();
|
|
135
|
+
vi.clearAllMocks();
|
|
136
|
+
onboardingApi.getOnboardingStepConfig.mockResolvedValue([]);
|
|
137
|
+
notificationsApi.getNotificationPreferences.mockResolvedValue({ email_opt_in: true });
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
function dispatchBeforeInstallPrompt() {
|
|
141
|
+
const event = new Event('beforeinstallprompt', { cancelable: true });
|
|
142
|
+
event.prompt = vi.fn();
|
|
143
|
+
event.userChoice = Promise.resolve({ outcome: 'accepted' });
|
|
144
|
+
window.dispatchEvent(event);
|
|
145
|
+
return event;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
it('does not show pwa_install without the app opt-in prop, and does not suppress the browser default install UI', async () => {
|
|
149
|
+
renderProvider({ pwaInstallEnabled: false, children: <ActiveStepsProbe /> });
|
|
150
|
+
await waitFor(() => expect(onboardingApi.getOnboardingStepConfig).toHaveBeenCalled());
|
|
151
|
+
|
|
152
|
+
const event = dispatchBeforeInstallPrompt();
|
|
153
|
+
|
|
154
|
+
await waitFor(() => expect(screen.getByTestId('active-steps').textContent).not.toContain('pwa_install'));
|
|
155
|
+
// Regression guard: a non-opted-in app must keep the browser's own
|
|
156
|
+
// native install UI, since it has no replacement to show instead.
|
|
157
|
+
expect(event.defaultPrevented).toBe(false);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('shows pwa_install once opted in and a beforeinstallprompt event is captured', async () => {
|
|
161
|
+
renderProvider({ pwaInstallEnabled: true, children: <ActiveStepsProbe /> });
|
|
162
|
+
await waitFor(() => expect(onboardingApi.getOnboardingStepConfig).toHaveBeenCalled());
|
|
163
|
+
|
|
164
|
+
const event = dispatchBeforeInstallPrompt();
|
|
165
|
+
|
|
166
|
+
await waitFor(() => expect(screen.getByTestId('active-steps').textContent).toContain('pwa_install'));
|
|
167
|
+
expect(event.defaultPrevented).toBe(true);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('does not show pwa_install when opted in but no prompt was ever captured (non-iOS)', async () => {
|
|
171
|
+
renderProvider({ pwaInstallEnabled: true, children: <ActiveStepsProbe /> });
|
|
172
|
+
await waitFor(() => expect(onboardingApi.getOnboardingStepConfig).toHaveBeenCalled());
|
|
173
|
+
|
|
174
|
+
expect(screen.getByTestId('active-steps').textContent).not.toContain('pwa_install');
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
describe('OnboardingProvider browser_push configuration', () => {
|
|
179
|
+
const serviceWorkerDescriptor = Object.getOwnPropertyDescriptor(navigator, 'serviceWorker');
|
|
180
|
+
const pushManagerDescriptor = Object.getOwnPropertyDescriptor(window, 'PushManager');
|
|
181
|
+
const localStorageDescriptor = Object.getOwnPropertyDescriptor(window, 'localStorage');
|
|
182
|
+
const localStorageItems = new Map();
|
|
183
|
+
|
|
184
|
+
function installLocalStorage() {
|
|
185
|
+
Object.defineProperty(window, 'localStorage', {
|
|
186
|
+
configurable: true,
|
|
187
|
+
value: {
|
|
188
|
+
getItem: (key) => localStorageItems.get(key) || null,
|
|
189
|
+
setItem: (key, value) => localStorageItems.set(key, String(value)),
|
|
190
|
+
clear: () => localStorageItems.clear(),
|
|
191
|
+
},
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function installPushState(subscribed) {
|
|
196
|
+
Object.defineProperty(navigator, 'serviceWorker', {
|
|
197
|
+
configurable: true,
|
|
198
|
+
value: {
|
|
199
|
+
ready: Promise.resolve({
|
|
200
|
+
pushManager: { getSubscription: () => Promise.resolve(subscribed ? {} : null) },
|
|
201
|
+
}),
|
|
202
|
+
},
|
|
203
|
+
});
|
|
204
|
+
Object.defineProperty(window, 'PushManager', {
|
|
205
|
+
configurable: true,
|
|
206
|
+
value: window.PushManager || function PushManager() {},
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function restorePushSupport() {
|
|
211
|
+
if (serviceWorkerDescriptor) {
|
|
212
|
+
Object.defineProperty(navigator, 'serviceWorker', serviceWorkerDescriptor);
|
|
213
|
+
} else {
|
|
214
|
+
delete navigator.serviceWorker;
|
|
215
|
+
}
|
|
216
|
+
if (pushManagerDescriptor) {
|
|
217
|
+
Object.defineProperty(window, 'PushManager', pushManagerDescriptor);
|
|
218
|
+
} else {
|
|
219
|
+
delete window.PushManager;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function restoreLocalStorage() {
|
|
224
|
+
if (localStorageDescriptor) {
|
|
225
|
+
Object.defineProperty(window, 'localStorage', localStorageDescriptor);
|
|
226
|
+
} else {
|
|
227
|
+
delete window.localStorage;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
beforeEach(() => {
|
|
232
|
+
cleanup();
|
|
233
|
+
vi.clearAllMocks();
|
|
234
|
+
installLocalStorage();
|
|
235
|
+
window.localStorage.clear();
|
|
236
|
+
onboardingApi.getOnboardingStepConfig.mockResolvedValue([]);
|
|
237
|
+
notificationsApi.getNotificationPreferences.mockResolvedValue({ email_opt_in: false });
|
|
238
|
+
installPushState(true);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
afterEach(() => {
|
|
242
|
+
restorePushSupport();
|
|
243
|
+
restoreLocalStorage();
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('uses any-channel by default and all-channels when explicitly configured', async () => {
|
|
247
|
+
const defaultRender = renderProvider({ children: <ActiveStepsProbe /> });
|
|
248
|
+
|
|
249
|
+
await waitFor(() => expect(screen.getByTestId('push-subscribed').textContent).toBe('true'));
|
|
250
|
+
expect(screen.getByTestId('active-steps').textContent).not.toContain('browser_push');
|
|
251
|
+
|
|
252
|
+
defaultRender.unmount();
|
|
253
|
+
renderProvider({
|
|
254
|
+
browserPush: { nagUntil: 'all-channels' },
|
|
255
|
+
children: <ActiveStepsProbe />,
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
await waitFor(() => expect(screen.getByTestId('push-subscribed').textContent).toBe('true'));
|
|
259
|
+
expect(screen.getByTestId('active-steps').textContent).toContain('browser_push');
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('never shows browser_push when nagUntil is off', async () => {
|
|
263
|
+
renderProvider({ browserPush: { nagUntil: 'off' }, children: <ActiveStepsProbe /> });
|
|
264
|
+
|
|
265
|
+
await waitFor(() => expect(screen.getByTestId('push-subscribed').textContent).toBe('true'));
|
|
266
|
+
expect(screen.getByTestId('active-steps').textContent).not.toContain('browser_push');
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it('persists showOnce for the next provider session without hiding the current session step', async () => {
|
|
270
|
+
installPushState(false);
|
|
271
|
+
const firstRender = renderProvider({
|
|
272
|
+
browserPush: { showOnce: true },
|
|
273
|
+
children: <MarkStepSeenProbe />,
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
await waitFor(() => expect(screen.getByTestId('push-subscribed').textContent).toBe('false'));
|
|
277
|
+
await waitFor(() => expect(screen.getByTestId('active-steps').textContent).toContain('browser_push'));
|
|
278
|
+
|
|
279
|
+
fireEvent.click(screen.getByRole('button', { name: 'Mark browser push seen' }));
|
|
280
|
+
|
|
281
|
+
expect(screen.getByTestId('active-steps').textContent).toContain('browser_push');
|
|
282
|
+
expect(JSON.parse(window.localStorage.getItem('onboarding_seen'))).toContain('browser_push');
|
|
283
|
+
|
|
284
|
+
firstRender.unmount();
|
|
285
|
+
renderProvider({
|
|
286
|
+
browserPush: { showOnce: true },
|
|
287
|
+
extraSteps: [{ id: 'provider_loaded', condition: () => true, persistDismissed: false }],
|
|
288
|
+
children: <ActiveStepsProbe />,
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
await waitFor(() => expect(screen.getByTestId('active-steps').textContent).toContain('provider_loaded'));
|
|
292
|
+
expect(screen.getByTestId('active-steps').textContent).not.toContain('browser_push');
|
|
293
|
+
});
|
|
294
|
+
});
|
|
@@ -4,6 +4,7 @@ import { selectActiveSteps } from '../src/onboarding/stepSelection';
|
|
|
4
4
|
|
|
5
5
|
const cookieConsentDescriptor = UNIVERSAL_STEP_DESCRIPTORS.find((step) => step.id === 'cookie_consent');
|
|
6
6
|
const browserPushDescriptor = UNIVERSAL_STEP_DESCRIPTORS.find((step) => step.id === 'browser_push');
|
|
7
|
+
const pwaInstallDescriptor = UNIVERSAL_STEP_DESCRIPTORS.find((step) => step.id === 'pwa_install');
|
|
7
8
|
|
|
8
9
|
describe('cookie_consent descriptor condition', () => {
|
|
9
10
|
it('is active when the user has not accepted the privacy statement, regardless of cookies', () => {
|
|
@@ -27,12 +28,29 @@ describe('cookie_consent descriptor condition', () => {
|
|
|
27
28
|
});
|
|
28
29
|
|
|
29
30
|
describe('browser_push descriptor condition', () => {
|
|
30
|
-
it('is active
|
|
31
|
+
it('is active by default only when neither supported channel is configured', () => {
|
|
32
|
+
expect(browserPushDescriptor.condition({
|
|
33
|
+
pushState: { supported: true, subscribed: false },
|
|
34
|
+
emailOptedIn: false,
|
|
35
|
+
})).toBe(true);
|
|
31
36
|
expect(browserPushDescriptor.condition({
|
|
32
37
|
pushState: { supported: true, subscribed: false },
|
|
33
38
|
emailOptedIn: true,
|
|
39
|
+
})).toBe(false);
|
|
40
|
+
expect(browserPushDescriptor.condition({
|
|
41
|
+
pushState: { supported: true, subscribed: true },
|
|
42
|
+
emailOptedIn: false,
|
|
43
|
+
})).toBe(false);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('keeps the legacy condition when configured for all-channels', () => {
|
|
47
|
+
expect(browserPushDescriptor.condition({
|
|
48
|
+
browserPush: { nagUntil: 'all-channels' },
|
|
49
|
+
pushState: { supported: true, subscribed: false },
|
|
50
|
+
emailOptedIn: true,
|
|
34
51
|
})).toBe(true);
|
|
35
52
|
expect(browserPushDescriptor.condition({
|
|
53
|
+
browserPush: { nagUntil: 'all-channels' },
|
|
36
54
|
pushState: { supported: true, subscribed: true },
|
|
37
55
|
emailOptedIn: false,
|
|
38
56
|
})).toBe(true);
|
|
@@ -67,3 +85,48 @@ describe('browser_push descriptor condition', () => {
|
|
|
67
85
|
expect(activeSteps).toEqual([]);
|
|
68
86
|
});
|
|
69
87
|
});
|
|
88
|
+
|
|
89
|
+
describe('pwa_install descriptor condition', () => {
|
|
90
|
+
it('is inactive when the app has not opted in, even if the device could install', () => {
|
|
91
|
+
expect(pwaInstallDescriptor.condition({
|
|
92
|
+
pwaInstallEnabled: false,
|
|
93
|
+
pwaInstall: { deferredPrompt: {}, isStandalone: false, isIos: false },
|
|
94
|
+
})).toBe(false);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('is active when opted in and Android/Chrome captured a deferred install prompt', () => {
|
|
98
|
+
expect(pwaInstallDescriptor.condition({
|
|
99
|
+
pwaInstallEnabled: true,
|
|
100
|
+
pwaInstall: { deferredPrompt: {}, isStandalone: false, isIos: false },
|
|
101
|
+
})).toBe(true);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('is active when opted in on iOS (no deferred prompt exists there, only the hint)', () => {
|
|
105
|
+
expect(pwaInstallDescriptor.condition({
|
|
106
|
+
pwaInstallEnabled: true,
|
|
107
|
+
pwaInstall: { deferredPrompt: null, isStandalone: false, isIos: true },
|
|
108
|
+
})).toBe(true);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('is inactive when opted in but neither a deferred prompt nor iOS applies', () => {
|
|
112
|
+
expect(pwaInstallDescriptor.condition({
|
|
113
|
+
pwaInstallEnabled: true,
|
|
114
|
+
pwaInstall: { deferredPrompt: null, isStandalone: false, isIos: false },
|
|
115
|
+
})).toBe(false);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('is inactive once already running standalone, regardless of opt-in or device capability', () => {
|
|
119
|
+
expect(pwaInstallDescriptor.condition({
|
|
120
|
+
pwaInstallEnabled: true,
|
|
121
|
+
pwaInstall: { deferredPrompt: {}, isStandalone: true, isIos: false },
|
|
122
|
+
})).toBe(false);
|
|
123
|
+
expect(pwaInstallDescriptor.condition({
|
|
124
|
+
pwaInstallEnabled: true,
|
|
125
|
+
pwaInstall: { deferredPrompt: null, isStandalone: true, isIos: true },
|
|
126
|
+
})).toBe(false);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('is inactive with no pwaInstall context at all', () => {
|
|
130
|
+
expect(pwaInstallDescriptor.condition({ pwaInstallEnabled: true })).toBe(false);
|
|
131
|
+
});
|
|
132
|
+
});
|