Skip to content

Commit cbb6a2a

Browse files
feat: Add desktop PWA install banner (#1209) (#1484)
* feat: Add desktop PWA install banner (#1209) Introduce a PWA installation prompt banenr for desktop layouts. The banner will be integrated in Header and HomeHeader components, to ensure to be displayed on top of each page. * fix: pwa banner flicker This commit fixes an issue where the usePWA hook led to an initial falsy 'generic' bannerState. The generic bannerState will actualy render the banner. The 'hidden' bannerState which will prevent the rendering was detected with a short delay - which led to shortly render the banner (flicker). The fix is to adjust the defaultBanner state to hidden instead of generic + to explicit check for 'generic' bannerState conditions to be true. * minor design tweak --------- Co-authored-by: annarhughes <annaraehughes@live.co.uk>
1 parent 517329b commit cbb6a2a

File tree

5 files changed

+84
-13
lines changed

5 files changed

+84
-13
lines changed
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
'use client';
2+
3+
import usePWA from '@/lib/hooks/usePwa';
4+
import AddBoxOutlinedIcon from '@mui/icons-material/AddBoxOutlined';
5+
import IosShareIcon from '@mui/icons-material/IosShare';
6+
import { Button, Paper, Stack, Typography, useMediaQuery, useTheme } from '@mui/material';
7+
import { useTranslations } from 'next-intl';
8+
9+
export const DesktopPwaBanner = () => {
10+
const { bannerState, declineInstallation, install } = usePWA();
11+
const t = useTranslations('Shared.pwaBanner');
12+
const theme = useTheme();
13+
const isSmallScreen = useMediaQuery(theme.breakpoints.down('md'));
14+
15+
if (isSmallScreen || bannerState === 'Hidden') return null;
16+
17+
return (
18+
<Paper
19+
elevation={1}
20+
sx={{
21+
px: 2,
22+
py: 1.5,
23+
display: 'flex',
24+
alignItems: 'center',
25+
gap: bannerState === 'Generic' ? 0 : '1rem',
26+
justifyContent: bannerState === 'Generic' ? 'space-between' : 'flex-start',
27+
backgroundColor: 'common.white',
28+
width: '100%',
29+
}}
30+
>
31+
<Typography variant="body2" sx={{ fontWeight: 500, whiteSpace: 'nowrap' }}>
32+
{t(bannerState === 'Generic' ? 'mobileDescription' : 'iosDescription')}
33+
</Typography>
34+
35+
{bannerState === 'Generic' ? (
36+
<Stack direction="row" spacing={2}>
37+
<Button
38+
onClick={declineInstallation}
39+
variant="outlined"
40+
color="secondary"
41+
size="small"
42+
sx={{ px: 2, minWidth: 'auto', whiteSpace: 'nowrap' }}
43+
>
44+
{t('button-decline-label')}
45+
</Button>
46+
<Button
47+
onClick={install}
48+
variant="contained"
49+
color="secondary"
50+
size="small"
51+
sx={{ px: 2, minWidth: 'auto', whiteSpace: 'nowrap' }}
52+
>
53+
{t('button-install-label')}
54+
</Button>
55+
</Stack>
56+
) : (
57+
<Stack direction="row" gap={2}>
58+
<Stack alignItems="center" direction="row" gap={1}>
59+
<Typography variant="body1">{t('iosStep1')}</Typography>
60+
<IosShareIcon />
61+
</Stack>
62+
<Stack alignItems="center" direction="row" gap={1}>
63+
<Typography variant="body1">{t('iosStep2')}</Typography>
64+
<AddBoxOutlinedIcon />
65+
</Stack>
66+
</Stack>
67+
)}
68+
</Paper>
69+
);
70+
};

components/layout/Header.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { ISbRichtext } from '@storyblok/react/rsc';
1313
import { useTranslations } from 'next-intl';
1414
import Image, { StaticImageData } from 'next/image';
1515
import { render } from 'storyblok-rich-text-react-renderer';
16+
import { DesktopPwaBanner } from '../banner/DesktopPwaBanner';
1617

1718
export interface HeaderProps {
1819
title: string;
@@ -106,6 +107,7 @@ const Header = (props: HeaderProps) => {
106107

107108
return (
108109
<Container sx={headerContainerStyle}>
110+
<DesktopPwaBanner />
109111
<Box sx={textContainerStyle}>
110112
<IconButton
111113
sx={backButtonStyle}

components/layout/HomeHeader.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { useTranslations } from 'next-intl';
1111
import Image, { StaticImageData } from 'next/image';
1212
import * as React from 'react';
1313
import { render } from 'storyblok-rich-text-react-renderer';
14+
import { DesktopPwaBanner } from '../banner/DesktopPwaBanner';
1415

1516
interface HeaderProps {
1617
title:
@@ -74,6 +75,7 @@ const Header = (props: HeaderProps) => {
7475

7576
return (
7677
<Container sx={headerContainerStyles}>
78+
<DesktopPwaBanner />
7779
<Box sx={textContainerStyle}>
7880
<Box sx={textContentStyle}>
7981
<Typography variant="h1" component="h1" mb={3}>

lib/hooks/usePWA.test.ts

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -46,11 +46,6 @@ describe('usePWA hook', () => {
4646
})),
4747
});
4848

49-
Object.defineProperty(window, 'navigator', {
50-
value: { userAgent: 'iphone' },
51-
writable: true,
52-
});
53-
5449
Cookies.get = jest.fn();
5550
Cookies.set = jest.fn();
5651

@@ -71,6 +66,7 @@ describe('usePWA hook', () => {
7166
});
7267

7368
it('should show Generic banner initially', () => {
69+
(window as any).beforeinstallpromptEvent = {};
7470
Cookies.get = jest.fn().mockReturnValue(undefined);
7571

7672
const { result } = renderHook(() => usePWA());
@@ -151,8 +147,10 @@ describe('usePWA hook', () => {
151147
const addEventListenerSpy = jest.spyOn(window, 'addEventListener');
152148
const { result } = renderHook(() => usePWA());
153149

154-
// Simulate install event
155150
act(() => {
151+
// Simulate user click on pwa banner (custom banner ui) install button - this will open the native browser install modal
152+
result.current.install();
153+
// Simulate user clicked on install on native pwa modal - this will fire an appinstalled event.
156154
const handler = addEventListenerSpy.mock.calls.find(
157155
([event]) => event === 'appinstalled',
158156
)?.[1] as EventListener;

lib/hooks/usePwa.ts

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,16 +22,11 @@ interface BeforeInstallPromptEvent extends Event {
2222
const PWA_BANNER_DISMISSED = 'pwaBannerDismissed';
2323

2424
export default function usePWA() {
25-
const [bannerState, setBannerState] = useState<PwaBannerState>('Generic');
25+
const [bannerState, setBannerState] = useState<PwaBannerState>('Hidden');
2626
const [installAttempted, setInstallAttempted] = useState(false);
2727
const dispatch = useAppDispatch();
28-
2928
const user = useTypedSelector((state) => state.user);
3029
const userCookiesAccepted = user.cookiesAccepted || Cookies.get('analyticsConsent') === 'true';
31-
const isIos = useMemo(
32-
() => /iphone|ipad|ipod/.test(window.navigator.userAgent.toLowerCase()),
33-
[],
34-
);
3530

3631
const getPwaMetaData = useMemo(() => {
3732
const userAgent = window.navigator.userAgent;
@@ -89,6 +84,9 @@ export default function usePWA() {
8984
const pwaBannerDismissedCookie = Boolean(Cookies.get(PWA_BANNER_DISMISSED));
9085
const isStandalone =
9186
typeof window !== 'undefined' && window.matchMedia('(display-mode: standalone)').matches;
87+
const isIos =
88+
typeof window !== 'undefined' && /iphone|ipad|ipod/.test(window?.navigator.userAgent.toLowerCase());
89+
9290
const isHidden =
9391
pwaBannerDismissedCookie ||
9492
user.pwaDismissed ||
@@ -97,13 +95,14 @@ export default function usePWA() {
9795

9896
if (isHidden && bannerState !== 'Hidden') setBannerState('Hidden');
9997
if (installAttempted && isIos && bannerState !== 'Ios') setBannerState('Ios');
98+
if (!isHidden && !installAttempted && bannerState !== 'Generic') setBannerState('Generic');
10099

101100
window.addEventListener('appinstalled', appInstalledCb);
102101

103102
return () => {
104103
window.removeEventListener('appinstalled', appInstalledCb);
105104
};
106-
}, [isIos, user.pwaDismissed, installAttempted, bannerState]);
105+
}, [user.pwaDismissed, installAttempted, bannerState]);
107106

108107
return {
109108
declineInstallation,

0 commit comments

Comments
 (0)