@micha.bigler/ui-core-micha 2.8.2 → 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 +80 -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 +86 -3
- package/src/onboarding/steps/PwaInstallStep.jsx +47 -0
- package/tests/OnboardingProvider.test.jsx +54 -2
- 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,16 +82,31 @@ function getInitialPushState() {
|
|
|
61
82
|
&& 'PushManager' in window;
|
|
62
83
|
return supported ? null : { supported: false, subscribed: false };
|
|
63
84
|
}
|
|
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
|
+
}
|
|
64
99
|
// Stable empty-object default — an inline `{}` default would create a new
|
|
65
100
|
// reference every render, invalidating the `ctx` memo below for every
|
|
66
101
|
// consumer even when no extraContext is passed.
|
|
67
102
|
const EMPTY_EXTRA_CONTEXT = {};
|
|
68
|
-
export function OnboardingProvider({ children, extraSteps = [], extraContext = EMPTY_EXTRA_CONTEXT }) {
|
|
103
|
+
export function OnboardingProvider({ children, extraSteps = [], extraContext = EMPTY_EXTRA_CONTEXT, pwaInstallEnabled = false, }) {
|
|
69
104
|
const { user } = useContext(AuthContext);
|
|
70
105
|
const [configMap, setConfigMap] = useState({});
|
|
71
106
|
const [configLoaded, setConfigLoaded] = useState(false);
|
|
72
107
|
const [pushState, setPushState] = useState(getInitialPushState);
|
|
73
108
|
const [emailOptedIn, setEmailOptedIn] = useState(false);
|
|
109
|
+
const [pwaInstall, setPwaInstall] = useState(getInitialPwaInstallState);
|
|
74
110
|
const [dismissedSet, setDismissedSet] = useState(loadDismissedSteps);
|
|
75
111
|
const descriptors = useMemo(() => [...UNIVERSAL_STEP_DESCRIPTORS, ...extraSteps], [extraSteps]);
|
|
76
112
|
useEffect(() => {
|
|
@@ -117,6 +153,48 @@ export function OnboardingProvider({ children, extraSteps = [], extraContext = E
|
|
|
117
153
|
});
|
|
118
154
|
return () => { cancelled = true; };
|
|
119
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]);
|
|
120
198
|
useEffect(() => {
|
|
121
199
|
let cancelled = false;
|
|
122
200
|
if (!user) {
|
|
@@ -151,7 +229,7 @@ export function OnboardingProvider({ children, extraSteps = [], extraContext = E
|
|
|
151
229
|
});
|
|
152
230
|
}, []);
|
|
153
231
|
// extraContext is spread last: duplicate core keys intentionally override core values, so apps should use distinct names.
|
|
154
|
-
const ctx = useMemo(() => (Object.assign({ user, pushState, emailOptedIn }, extraContext)), [user, pushState, emailOptedIn, extraContext]);
|
|
232
|
+
const ctx = useMemo(() => (Object.assign({ user, pushState, emailOptedIn, pwaInstall, pwaInstallEnabled }, extraContext)), [user, pushState, emailOptedIn, pwaInstall, pwaInstallEnabled, extraContext]);
|
|
155
233
|
const activeSteps = useMemo(() => {
|
|
156
234
|
if (!configLoaded || !user)
|
|
157
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,17 +90,36 @@ function getInitialPushState() {
|
|
|
70
90
|
return supported ? null : { supported: false, subscribed: false };
|
|
71
91
|
}
|
|
72
92
|
|
|
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
|
+
|
|
73
109
|
// Stable empty-object default — an inline `{}` default would create a new
|
|
74
110
|
// reference every render, invalidating the `ctx` memo below for every
|
|
75
111
|
// consumer even when no extraContext is passed.
|
|
76
112
|
const EMPTY_EXTRA_CONTEXT = {};
|
|
77
113
|
|
|
78
|
-
export function OnboardingProvider({
|
|
114
|
+
export function OnboardingProvider({
|
|
115
|
+
children, extraSteps = [], extraContext = EMPTY_EXTRA_CONTEXT, pwaInstallEnabled = false,
|
|
116
|
+
}) {
|
|
79
117
|
const { user } = useContext(AuthContext);
|
|
80
118
|
const [configMap, setConfigMap] = useState({});
|
|
81
119
|
const [configLoaded, setConfigLoaded] = useState(false);
|
|
82
120
|
const [pushState, setPushState] = useState(getInitialPushState);
|
|
83
121
|
const [emailOptedIn, setEmailOptedIn] = useState(false);
|
|
122
|
+
const [pwaInstall, setPwaInstall] = useState(getInitialPwaInstallState);
|
|
84
123
|
const [dismissedSet, setDismissedSet] = useState(loadDismissedSteps);
|
|
85
124
|
|
|
86
125
|
const descriptors = useMemo(
|
|
@@ -133,6 +172,48 @@ export function OnboardingProvider({ children, extraSteps = [], extraContext = E
|
|
|
133
172
|
return () => { cancelled = true; };
|
|
134
173
|
}, []);
|
|
135
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
|
+
|
|
136
217
|
useEffect(() => {
|
|
137
218
|
let cancelled = false;
|
|
138
219
|
if (!user) {
|
|
@@ -169,8 +250,10 @@ export function OnboardingProvider({ children, extraSteps = [], extraContext = E
|
|
|
169
250
|
|
|
170
251
|
// extraContext is spread last: duplicate core keys intentionally override core values, so apps should use distinct names.
|
|
171
252
|
const ctx = useMemo(
|
|
172
|
-
() => ({
|
|
173
|
-
|
|
253
|
+
() => ({
|
|
254
|
+
user, pushState, emailOptedIn, pwaInstall, pwaInstallEnabled, ...extraContext,
|
|
255
|
+
}),
|
|
256
|
+
[user, pushState, emailOptedIn, pwaInstall, pwaInstallEnabled, extraContext],
|
|
174
257
|
);
|
|
175
258
|
const activeSteps = useMemo(() => {
|
|
176
259
|
if (!configLoaded || !user) return [];
|
|
@@ -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;
|
|
@@ -26,7 +26,7 @@ function ActiveStepsProbe() {
|
|
|
26
26
|
return <span data-testid="active-steps">{onboarding?.activeSteps.map((step) => step.id).join(',')}</span>;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
function renderProvider({ extraContext, extraSteps, children = <Probe /> } = {}) {
|
|
29
|
+
function renderProvider({ extraContext, extraSteps, pwaInstallEnabled, children = <Probe /> } = {}) {
|
|
30
30
|
return render(
|
|
31
31
|
<AuthContext.Provider value={{
|
|
32
32
|
user: {
|
|
@@ -36,7 +36,13 @@ function renderProvider({ extraContext, extraSteps, children = <Probe /> } = {})
|
|
|
36
36
|
last_name: 'Lovelace',
|
|
37
37
|
},
|
|
38
38
|
}}>
|
|
39
|
-
<OnboardingProvider
|
|
39
|
+
<OnboardingProvider
|
|
40
|
+
extraContext={extraContext}
|
|
41
|
+
extraSteps={extraSteps}
|
|
42
|
+
pwaInstallEnabled={pwaInstallEnabled}
|
|
43
|
+
>
|
|
44
|
+
{children}
|
|
45
|
+
</OnboardingProvider>
|
|
40
46
|
</AuthContext.Provider>,
|
|
41
47
|
);
|
|
42
48
|
}
|
|
@@ -106,3 +112,49 @@ describe('OnboardingProvider extra context', () => {
|
|
|
106
112
|
expect(screen.getByTestId('active-steps').textContent).not.toContain('unread_messages');
|
|
107
113
|
});
|
|
108
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
|
+
});
|