@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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@micha.bigler/ui-core-micha",
3
- "version": "2.8.2",
3
+ "version": "2.9.0",
4
4
  "main": "dist/index.js",
5
5
  "module": "dist/index.js",
6
6
  "repository": {
@@ -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({ children, extraSteps = [], extraContext = EMPTY_EXTRA_CONTEXT }) {
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
- () => ({ user, pushState, emailOptedIn, ...extraContext }),
173
- [user, pushState, emailOptedIn, extraContext],
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 extraContext={extraContext} extraSteps={extraSteps}>{children}</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
+ });