@micha.bigler/ui-core-micha 2.8.2 → 2.10.0

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