@micha.bigler/ui-core-micha 2.8.1 → 2.9.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/dist/i18n/onboardingTranslations.js +4 -0
- package/dist/index.js +1 -0
- package/dist/onboarding/OnboardingProvider.js +85 -2
- 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 +94 -2
- package/src/onboarding/steps/PwaInstallStep.jsx +47 -0
- package/tests/OnboardingProvider.test.jsx +107 -3
- package/tests/onboardingDescriptors.test.js +46 -0
|
@@ -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';
|
|
@@ -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',
|
|
@@ -39,6 +40,26 @@ export const UNIVERSAL_STEP_DESCRIPTORS = [
|
|
|
39
40
|
titleKey: 'Onboarding.NOTIFICATIONS_TITLE',
|
|
40
41
|
Component: BrowserPushStep,
|
|
41
42
|
},
|
|
43
|
+
{
|
|
44
|
+
id: 'pwa_install',
|
|
45
|
+
// App-level opt-in (ctx.pwaInstallEnabled) gates this on top of device
|
|
46
|
+
// capability — a step being "universal" in the library means available
|
|
47
|
+
// to every app, not automatically shown by every app. Apps that haven't
|
|
48
|
+
// verified their PWA manifest/icons are actually installable must not
|
|
49
|
+
// pass pwaInstallEnabled, or this would show an iOS "Add to Home Screen"
|
|
50
|
+
// hint for an app that isn't really installable.
|
|
51
|
+
condition: (ctx) => {
|
|
52
|
+
var _a, _b, _c;
|
|
53
|
+
return Boolean(ctx.pwaInstallEnabled
|
|
54
|
+
&& !((_a = ctx.pwaInstall) === null || _a === void 0 ? void 0 : _a.isStandalone)
|
|
55
|
+
&& (Boolean((_b = ctx.pwaInstall) === null || _b === void 0 ? void 0 : _b.deferredPrompt) || Boolean((_c = ctx.pwaInstall) === null || _c === void 0 ? void 0 : _c.isIos)));
|
|
56
|
+
},
|
|
57
|
+
blocking: false,
|
|
58
|
+
skipable: true,
|
|
59
|
+
persistDismissed: true,
|
|
60
|
+
titleKey: 'Onboarding.PWA_INSTALL_TITLE',
|
|
61
|
+
Component: PwaInstallStep,
|
|
62
|
+
},
|
|
42
63
|
];
|
|
43
64
|
export const OnboardingContext = createContext(null);
|
|
44
65
|
export function useOnboarding() {
|
|
@@ -61,12 +82,31 @@ function getInitialPushState() {
|
|
|
61
82
|
&& 'PushManager' in window;
|
|
62
83
|
return supported ? null : { supported: false, subscribed: false };
|
|
63
84
|
}
|
|
64
|
-
|
|
85
|
+
function getInitialPwaInstallState() {
|
|
86
|
+
var _a;
|
|
87
|
+
if (typeof window === 'undefined' || typeof navigator === 'undefined') {
|
|
88
|
+
return { isStandalone: false, isIos: false, deferredPrompt: null };
|
|
89
|
+
}
|
|
90
|
+
const isStandalone = Boolean(((_a = window.matchMedia) === null || _a === void 0 ? void 0 : _a.call(window, '(display-mode: standalone)').matches) || navigator.standalone);
|
|
91
|
+
// iPadOS 13+ reports a desktop Safari/Mac user agent by default, so the
|
|
92
|
+
// classic /ipad/i UA sniff misses it — the standard workaround is
|
|
93
|
+
// detecting touch support on a reported Mac platform (real Macs report
|
|
94
|
+
// maxTouchPoints 0).
|
|
95
|
+
const isIos = /iphone|ipad|ipod/i.test(navigator.userAgent || '')
|
|
96
|
+
|| (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
|
97
|
+
return { isStandalone, isIos, deferredPrompt: null };
|
|
98
|
+
}
|
|
99
|
+
// Stable empty-object default — an inline `{}` default would create a new
|
|
100
|
+
// reference every render, invalidating the `ctx` memo below for every
|
|
101
|
+
// consumer even when no extraContext is passed.
|
|
102
|
+
const EMPTY_EXTRA_CONTEXT = {};
|
|
103
|
+
export function OnboardingProvider({ children, extraSteps = [], extraContext = EMPTY_EXTRA_CONTEXT, pwaInstallEnabled = false, }) {
|
|
65
104
|
const { user } = useContext(AuthContext);
|
|
66
105
|
const [configMap, setConfigMap] = useState({});
|
|
67
106
|
const [configLoaded, setConfigLoaded] = useState(false);
|
|
68
107
|
const [pushState, setPushState] = useState(getInitialPushState);
|
|
69
108
|
const [emailOptedIn, setEmailOptedIn] = useState(false);
|
|
109
|
+
const [pwaInstall, setPwaInstall] = useState(getInitialPwaInstallState);
|
|
70
110
|
const [dismissedSet, setDismissedSet] = useState(loadDismissedSteps);
|
|
71
111
|
const descriptors = useMemo(() => [...UNIVERSAL_STEP_DESCRIPTORS, ...extraSteps], [extraSteps]);
|
|
72
112
|
useEffect(() => {
|
|
@@ -113,6 +153,48 @@ export function OnboardingProvider({ children, extraSteps = [] }) {
|
|
|
113
153
|
});
|
|
114
154
|
return () => { cancelled = true; };
|
|
115
155
|
}, []);
|
|
156
|
+
// Capture beforeinstallprompt as early as possible (at provider mount, not
|
|
157
|
+
// when the wizard step happens to render) — Chrome fires it once, early,
|
|
158
|
+
// and calling preventDefault() late means losing the deferred prompt
|
|
159
|
+
// reference for good. Gated behind pwaInstallEnabled: calling
|
|
160
|
+
// preventDefault() unconditionally would suppress the browser's own native
|
|
161
|
+
// install UI for every app using this library, even ones that never opted
|
|
162
|
+
// in and have no replacement UI to show instead.
|
|
163
|
+
useEffect(() => {
|
|
164
|
+
var _a;
|
|
165
|
+
if (typeof window === 'undefined' || !pwaInstallEnabled)
|
|
166
|
+
return undefined;
|
|
167
|
+
const mediaQuery = (_a = window.matchMedia) === null || _a === void 0 ? void 0 : _a.call(window, '(display-mode: standalone)');
|
|
168
|
+
const handleDisplayModeChange = (event) => {
|
|
169
|
+
if (event.matches)
|
|
170
|
+
setPwaInstall((prev) => (Object.assign(Object.assign({}, prev), { isStandalone: true })));
|
|
171
|
+
};
|
|
172
|
+
const handleBeforeInstallPrompt = (event) => {
|
|
173
|
+
event.preventDefault();
|
|
174
|
+
setPwaInstall((prev) => (Object.assign(Object.assign({}, prev), { deferredPrompt: event })));
|
|
175
|
+
};
|
|
176
|
+
const handleAppInstalled = () => {
|
|
177
|
+
setPwaInstall((prev) => (Object.assign(Object.assign({}, prev), { isStandalone: true, deferredPrompt: null })));
|
|
178
|
+
};
|
|
179
|
+
if (mediaQuery === null || mediaQuery === void 0 ? void 0 : mediaQuery.addEventListener) {
|
|
180
|
+
mediaQuery.addEventListener('change', handleDisplayModeChange);
|
|
181
|
+
}
|
|
182
|
+
else if (mediaQuery === null || mediaQuery === void 0 ? void 0 : mediaQuery.addListener) {
|
|
183
|
+
mediaQuery.addListener(handleDisplayModeChange);
|
|
184
|
+
}
|
|
185
|
+
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
|
|
186
|
+
window.addEventListener('appinstalled', handleAppInstalled);
|
|
187
|
+
return () => {
|
|
188
|
+
if (mediaQuery === null || mediaQuery === void 0 ? void 0 : mediaQuery.removeEventListener) {
|
|
189
|
+
mediaQuery.removeEventListener('change', handleDisplayModeChange);
|
|
190
|
+
}
|
|
191
|
+
else if (mediaQuery === null || mediaQuery === void 0 ? void 0 : mediaQuery.removeListener) {
|
|
192
|
+
mediaQuery.removeListener(handleDisplayModeChange);
|
|
193
|
+
}
|
|
194
|
+
window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
|
|
195
|
+
window.removeEventListener('appinstalled', handleAppInstalled);
|
|
196
|
+
};
|
|
197
|
+
}, [pwaInstallEnabled]);
|
|
116
198
|
useEffect(() => {
|
|
117
199
|
let cancelled = false;
|
|
118
200
|
if (!user) {
|
|
@@ -146,7 +228,8 @@ export function OnboardingProvider({ children, extraSteps = [] }) {
|
|
|
146
228
|
return next;
|
|
147
229
|
});
|
|
148
230
|
}, []);
|
|
149
|
-
|
|
231
|
+
// extraContext is spread last: duplicate core keys intentionally override core values, so apps should use distinct names.
|
|
232
|
+
const ctx = useMemo(() => (Object.assign({ user, pushState, emailOptedIn, pwaInstall, pwaInstallEnabled }, extraContext)), [user, pushState, emailOptedIn, pwaInstall, pwaInstallEnabled, extraContext]);
|
|
150
233
|
const activeSteps = useMemo(() => {
|
|
151
234
|
if (!configLoaded || !user)
|
|
152
235
|
return [];
|
|
@@ -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';
|
|
@@ -13,6 +13,7 @@ import { selectActiveSteps } from './stepSelection';
|
|
|
13
13
|
import CookieConsentStep from './steps/CookieConsentStep';
|
|
14
14
|
import CompleteNameStep from './steps/CompleteNameStep';
|
|
15
15
|
import BrowserPushStep from './steps/BrowserPushStep';
|
|
16
|
+
import PwaInstallStep from './steps/PwaInstallStep';
|
|
16
17
|
|
|
17
18
|
export const UNIVERSAL_STEP_DESCRIPTORS = [
|
|
18
19
|
{
|
|
@@ -45,6 +46,25 @@ export const UNIVERSAL_STEP_DESCRIPTORS = [
|
|
|
45
46
|
titleKey: 'Onboarding.NOTIFICATIONS_TITLE',
|
|
46
47
|
Component: BrowserPushStep,
|
|
47
48
|
},
|
|
49
|
+
{
|
|
50
|
+
id: 'pwa_install',
|
|
51
|
+
// App-level opt-in (ctx.pwaInstallEnabled) gates this on top of device
|
|
52
|
+
// capability — a step being "universal" in the library means available
|
|
53
|
+
// to every app, not automatically shown by every app. Apps that haven't
|
|
54
|
+
// verified their PWA manifest/icons are actually installable must not
|
|
55
|
+
// pass pwaInstallEnabled, or this would show an iOS "Add to Home Screen"
|
|
56
|
+
// hint for an app that isn't really installable.
|
|
57
|
+
condition: (ctx) => Boolean(
|
|
58
|
+
ctx.pwaInstallEnabled
|
|
59
|
+
&& !ctx.pwaInstall?.isStandalone
|
|
60
|
+
&& (Boolean(ctx.pwaInstall?.deferredPrompt) || Boolean(ctx.pwaInstall?.isIos)),
|
|
61
|
+
),
|
|
62
|
+
blocking: false,
|
|
63
|
+
skipable: true,
|
|
64
|
+
persistDismissed: true,
|
|
65
|
+
titleKey: 'Onboarding.PWA_INSTALL_TITLE',
|
|
66
|
+
Component: PwaInstallStep,
|
|
67
|
+
},
|
|
48
68
|
];
|
|
49
69
|
|
|
50
70
|
export const OnboardingContext = createContext(null);
|
|
@@ -70,12 +90,36 @@ function getInitialPushState() {
|
|
|
70
90
|
return supported ? null : { supported: false, subscribed: false };
|
|
71
91
|
}
|
|
72
92
|
|
|
73
|
-
|
|
93
|
+
function getInitialPwaInstallState() {
|
|
94
|
+
if (typeof window === 'undefined' || typeof navigator === 'undefined') {
|
|
95
|
+
return { isStandalone: false, isIos: false, deferredPrompt: null };
|
|
96
|
+
}
|
|
97
|
+
const isStandalone = Boolean(
|
|
98
|
+
window.matchMedia?.('(display-mode: standalone)').matches || navigator.standalone,
|
|
99
|
+
);
|
|
100
|
+
// iPadOS 13+ reports a desktop Safari/Mac user agent by default, so the
|
|
101
|
+
// classic /ipad/i UA sniff misses it — the standard workaround is
|
|
102
|
+
// detecting touch support on a reported Mac platform (real Macs report
|
|
103
|
+
// maxTouchPoints 0).
|
|
104
|
+
const isIos = /iphone|ipad|ipod/i.test(navigator.userAgent || '')
|
|
105
|
+
|| (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
|
106
|
+
return { isStandalone, isIos, deferredPrompt: null };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Stable empty-object default — an inline `{}` default would create a new
|
|
110
|
+
// reference every render, invalidating the `ctx` memo below for every
|
|
111
|
+
// consumer even when no extraContext is passed.
|
|
112
|
+
const EMPTY_EXTRA_CONTEXT = {};
|
|
113
|
+
|
|
114
|
+
export function OnboardingProvider({
|
|
115
|
+
children, extraSteps = [], extraContext = EMPTY_EXTRA_CONTEXT, pwaInstallEnabled = false,
|
|
116
|
+
}) {
|
|
74
117
|
const { user } = useContext(AuthContext);
|
|
75
118
|
const [configMap, setConfigMap] = useState({});
|
|
76
119
|
const [configLoaded, setConfigLoaded] = useState(false);
|
|
77
120
|
const [pushState, setPushState] = useState(getInitialPushState);
|
|
78
121
|
const [emailOptedIn, setEmailOptedIn] = useState(false);
|
|
122
|
+
const [pwaInstall, setPwaInstall] = useState(getInitialPwaInstallState);
|
|
79
123
|
const [dismissedSet, setDismissedSet] = useState(loadDismissedSteps);
|
|
80
124
|
|
|
81
125
|
const descriptors = useMemo(
|
|
@@ -128,6 +172,48 @@ export function OnboardingProvider({ children, extraSteps = [] }) {
|
|
|
128
172
|
return () => { cancelled = true; };
|
|
129
173
|
}, []);
|
|
130
174
|
|
|
175
|
+
// Capture beforeinstallprompt as early as possible (at provider mount, not
|
|
176
|
+
// when the wizard step happens to render) — Chrome fires it once, early,
|
|
177
|
+
// and calling preventDefault() late means losing the deferred prompt
|
|
178
|
+
// reference for good. Gated behind pwaInstallEnabled: calling
|
|
179
|
+
// preventDefault() unconditionally would suppress the browser's own native
|
|
180
|
+
// install UI for every app using this library, even ones that never opted
|
|
181
|
+
// in and have no replacement UI to show instead.
|
|
182
|
+
useEffect(() => {
|
|
183
|
+
if (typeof window === 'undefined' || !pwaInstallEnabled) return undefined;
|
|
184
|
+
|
|
185
|
+
const mediaQuery = window.matchMedia?.('(display-mode: standalone)');
|
|
186
|
+
|
|
187
|
+
const handleDisplayModeChange = (event) => {
|
|
188
|
+
if (event.matches) setPwaInstall((prev) => ({ ...prev, isStandalone: true }));
|
|
189
|
+
};
|
|
190
|
+
const handleBeforeInstallPrompt = (event) => {
|
|
191
|
+
event.preventDefault();
|
|
192
|
+
setPwaInstall((prev) => ({ ...prev, deferredPrompt: event }));
|
|
193
|
+
};
|
|
194
|
+
const handleAppInstalled = () => {
|
|
195
|
+
setPwaInstall((prev) => ({ ...prev, isStandalone: true, deferredPrompt: null }));
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
if (mediaQuery?.addEventListener) {
|
|
199
|
+
mediaQuery.addEventListener('change', handleDisplayModeChange);
|
|
200
|
+
} else if (mediaQuery?.addListener) {
|
|
201
|
+
mediaQuery.addListener(handleDisplayModeChange);
|
|
202
|
+
}
|
|
203
|
+
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
|
|
204
|
+
window.addEventListener('appinstalled', handleAppInstalled);
|
|
205
|
+
|
|
206
|
+
return () => {
|
|
207
|
+
if (mediaQuery?.removeEventListener) {
|
|
208
|
+
mediaQuery.removeEventListener('change', handleDisplayModeChange);
|
|
209
|
+
} else if (mediaQuery?.removeListener) {
|
|
210
|
+
mediaQuery.removeListener(handleDisplayModeChange);
|
|
211
|
+
}
|
|
212
|
+
window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
|
|
213
|
+
window.removeEventListener('appinstalled', handleAppInstalled);
|
|
214
|
+
};
|
|
215
|
+
}, [pwaInstallEnabled]);
|
|
216
|
+
|
|
131
217
|
useEffect(() => {
|
|
132
218
|
let cancelled = false;
|
|
133
219
|
if (!user) {
|
|
@@ -162,7 +248,13 @@ export function OnboardingProvider({ children, extraSteps = [] }) {
|
|
|
162
248
|
});
|
|
163
249
|
}, []);
|
|
164
250
|
|
|
165
|
-
|
|
251
|
+
// extraContext is spread last: duplicate core keys intentionally override core values, so apps should use distinct names.
|
|
252
|
+
const ctx = useMemo(
|
|
253
|
+
() => ({
|
|
254
|
+
user, pushState, emailOptedIn, pwaInstall, pwaInstallEnabled, ...extraContext,
|
|
255
|
+
}),
|
|
256
|
+
[user, pushState, emailOptedIn, pwaInstall, pwaInstallEnabled, extraContext],
|
|
257
|
+
);
|
|
166
258
|
const activeSteps = useMemo(() => {
|
|
167
259
|
if (!configLoaded || !user) return [];
|
|
168
260
|
return selectActiveSteps(descriptors, configMap, ctx, dismissedSet);
|
|
@@ -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;
|
|
@@ -21,10 +21,28 @@ function Probe() {
|
|
|
21
21
|
return <span data-testid="email-opted-in">{String(onboarding?.ctx.emailOptedIn)}</span>;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
function
|
|
24
|
+
function ActiveStepsProbe() {
|
|
25
|
+
const onboarding = useOnboarding();
|
|
26
|
+
return <span data-testid="active-steps">{onboarding?.activeSteps.map((step) => step.id).join(',')}</span>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function renderProvider({ extraContext, extraSteps, pwaInstallEnabled, children = <Probe /> } = {}) {
|
|
25
30
|
return render(
|
|
26
|
-
<AuthContext.Provider value={{
|
|
27
|
-
|
|
31
|
+
<AuthContext.Provider value={{
|
|
32
|
+
user: {
|
|
33
|
+
id: 1,
|
|
34
|
+
accepted_privacy_statement: true,
|
|
35
|
+
first_name: 'Ada',
|
|
36
|
+
last_name: 'Lovelace',
|
|
37
|
+
},
|
|
38
|
+
}}>
|
|
39
|
+
<OnboardingProvider
|
|
40
|
+
extraContext={extraContext}
|
|
41
|
+
extraSteps={extraSteps}
|
|
42
|
+
pwaInstallEnabled={pwaInstallEnabled}
|
|
43
|
+
>
|
|
44
|
+
{children}
|
|
45
|
+
</OnboardingProvider>
|
|
28
46
|
</AuthContext.Provider>,
|
|
29
47
|
);
|
|
30
48
|
}
|
|
@@ -54,3 +72,89 @@ describe('OnboardingProvider notification preferences', () => {
|
|
|
54
72
|
expect(screen.getByTestId('email-opted-in').textContent).toBe('false');
|
|
55
73
|
});
|
|
56
74
|
});
|
|
75
|
+
|
|
76
|
+
describe('OnboardingProvider extra context', () => {
|
|
77
|
+
const unreadMessagesCondition = vi.fn((ctx) => (ctx.unreadCount || 0) > 0);
|
|
78
|
+
const unreadMessagesStep = {
|
|
79
|
+
id: 'unread_messages',
|
|
80
|
+
condition: unreadMessagesCondition,
|
|
81
|
+
persistDismissed: false,
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
beforeEach(() => {
|
|
85
|
+
cleanup();
|
|
86
|
+
vi.clearAllMocks();
|
|
87
|
+
onboardingApi.getOnboardingStepConfig.mockResolvedValue([]);
|
|
88
|
+
notificationsApi.getNotificationPreferences.mockResolvedValue({ email_opt_in: true });
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('shows an app step when its extra context condition is met', async () => {
|
|
92
|
+
renderProvider({
|
|
93
|
+
extraContext: { unreadCount: 2 },
|
|
94
|
+
extraSteps: [unreadMessagesStep],
|
|
95
|
+
children: <ActiveStepsProbe />,
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
await waitFor(() => expect(screen.getByTestId('active-steps').textContent).toContain('unread_messages'));
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it.each([
|
|
102
|
+
['zero', { unreadCount: 0 }],
|
|
103
|
+
['omitted', undefined],
|
|
104
|
+
])('does not show an app step when unreadCount is %s', async (_label, extraContext) => {
|
|
105
|
+
renderProvider({
|
|
106
|
+
extraContext,
|
|
107
|
+
extraSteps: [unreadMessagesStep],
|
|
108
|
+
children: <ActiveStepsProbe />,
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
await waitFor(() => expect(unreadMessagesCondition).toHaveBeenCalled());
|
|
112
|
+
expect(screen.getByTestId('active-steps').textContent).not.toContain('unread_messages');
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe('OnboardingProvider pwa_install capture', () => {
|
|
117
|
+
beforeEach(() => {
|
|
118
|
+
cleanup();
|
|
119
|
+
vi.clearAllMocks();
|
|
120
|
+
onboardingApi.getOnboardingStepConfig.mockResolvedValue([]);
|
|
121
|
+
notificationsApi.getNotificationPreferences.mockResolvedValue({ email_opt_in: true });
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
function dispatchBeforeInstallPrompt() {
|
|
125
|
+
const event = new Event('beforeinstallprompt', { cancelable: true });
|
|
126
|
+
event.prompt = vi.fn();
|
|
127
|
+
event.userChoice = Promise.resolve({ outcome: 'accepted' });
|
|
128
|
+
window.dispatchEvent(event);
|
|
129
|
+
return event;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
it('does not show pwa_install without the app opt-in prop, and does not suppress the browser default install UI', async () => {
|
|
133
|
+
renderProvider({ pwaInstallEnabled: false, children: <ActiveStepsProbe /> });
|
|
134
|
+
await waitFor(() => expect(onboardingApi.getOnboardingStepConfig).toHaveBeenCalled());
|
|
135
|
+
|
|
136
|
+
const event = dispatchBeforeInstallPrompt();
|
|
137
|
+
|
|
138
|
+
await waitFor(() => expect(screen.getByTestId('active-steps').textContent).not.toContain('pwa_install'));
|
|
139
|
+
// Regression guard: a non-opted-in app must keep the browser's own
|
|
140
|
+
// native install UI, since it has no replacement to show instead.
|
|
141
|
+
expect(event.defaultPrevented).toBe(false);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('shows pwa_install once opted in and a beforeinstallprompt event is captured', async () => {
|
|
145
|
+
renderProvider({ pwaInstallEnabled: true, children: <ActiveStepsProbe /> });
|
|
146
|
+
await waitFor(() => expect(onboardingApi.getOnboardingStepConfig).toHaveBeenCalled());
|
|
147
|
+
|
|
148
|
+
const event = dispatchBeforeInstallPrompt();
|
|
149
|
+
|
|
150
|
+
await waitFor(() => expect(screen.getByTestId('active-steps').textContent).toContain('pwa_install'));
|
|
151
|
+
expect(event.defaultPrevented).toBe(true);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('does not show pwa_install when opted in but no prompt was ever captured (non-iOS)', async () => {
|
|
155
|
+
renderProvider({ pwaInstallEnabled: true, children: <ActiveStepsProbe /> });
|
|
156
|
+
await waitFor(() => expect(onboardingApi.getOnboardingStepConfig).toHaveBeenCalled());
|
|
157
|
+
|
|
158
|
+
expect(screen.getByTestId('active-steps').textContent).not.toContain('pwa_install');
|
|
159
|
+
});
|
|
160
|
+
});
|
|
@@ -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', () => {
|
|
@@ -67,3 +68,48 @@ describe('browser_push descriptor condition', () => {
|
|
|
67
68
|
expect(activeSteps).toEqual([]);
|
|
68
69
|
});
|
|
69
70
|
});
|
|
71
|
+
|
|
72
|
+
describe('pwa_install descriptor condition', () => {
|
|
73
|
+
it('is inactive when the app has not opted in, even if the device could install', () => {
|
|
74
|
+
expect(pwaInstallDescriptor.condition({
|
|
75
|
+
pwaInstallEnabled: false,
|
|
76
|
+
pwaInstall: { deferredPrompt: {}, isStandalone: false, isIos: false },
|
|
77
|
+
})).toBe(false);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('is active when opted in and Android/Chrome captured a deferred install prompt', () => {
|
|
81
|
+
expect(pwaInstallDescriptor.condition({
|
|
82
|
+
pwaInstallEnabled: true,
|
|
83
|
+
pwaInstall: { deferredPrompt: {}, isStandalone: false, isIos: false },
|
|
84
|
+
})).toBe(true);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('is active when opted in on iOS (no deferred prompt exists there, only the hint)', () => {
|
|
88
|
+
expect(pwaInstallDescriptor.condition({
|
|
89
|
+
pwaInstallEnabled: true,
|
|
90
|
+
pwaInstall: { deferredPrompt: null, isStandalone: false, isIos: true },
|
|
91
|
+
})).toBe(true);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('is inactive when opted in but neither a deferred prompt nor iOS applies', () => {
|
|
95
|
+
expect(pwaInstallDescriptor.condition({
|
|
96
|
+
pwaInstallEnabled: true,
|
|
97
|
+
pwaInstall: { deferredPrompt: null, isStandalone: false, isIos: false },
|
|
98
|
+
})).toBe(false);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('is inactive once already running standalone, regardless of opt-in or device capability', () => {
|
|
102
|
+
expect(pwaInstallDescriptor.condition({
|
|
103
|
+
pwaInstallEnabled: true,
|
|
104
|
+
pwaInstall: { deferredPrompt: {}, isStandalone: true, isIos: false },
|
|
105
|
+
})).toBe(false);
|
|
106
|
+
expect(pwaInstallDescriptor.condition({
|
|
107
|
+
pwaInstallEnabled: true,
|
|
108
|
+
pwaInstall: { deferredPrompt: null, isStandalone: true, isIos: true },
|
|
109
|
+
})).toBe(false);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('is inactive with no pwaInstall context at all', () => {
|
|
113
|
+
expect(pwaInstallDescriptor.condition({ pwaInstallEnabled: true })).toBe(false);
|
|
114
|
+
});
|
|
115
|
+
});
|