Skip to content

Commit d955f6e

Browse files
authored
Merge pull request #20270 from mozilla/FXA-13279
feat(ui): Add web integration, desktop-only promo: mobile download QR code
2 parents cec090a + 8cc1b0f commit d955f6e

11 files changed

Lines changed: 891 additions & 265 deletions

File tree

libs/shared/assets/src/images/ff-logo.svg

Lines changed: 67 additions & 1 deletion
Loading

packages/fxa-settings/src/components/App/index.tsx

Lines changed: 269 additions & 264 deletions
Large diffs are not rendered by default.
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
## PromoQrMobile component
2+
## Promotional aside encouraging users to download the Firefox mobile app via QR code.
3+
4+
# "Your phone. Your rules." refers to the user being able to control what browser they use on their own phone.
5+
promo-qr-mobile-heading = Your phone. Your rules.
6+
# Appears next to a QR code that a user can scan to download the Firefox mobile app
7+
promo-qr-mobile-description = Scan to get the app
8+
# Note that for RTL languages, this should be translated as "the lower-left corner of your screen," instead of "the lower-right corner."
9+
promo-qr-mobile-qr-alt =
10+
.alt = QR code to download the { -brand-firefox } mobile app. Position your phone’s camera on the lower-right corner of your screen to scan it.
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4+
5+
import React from 'react';
6+
import { Meta } from '@storybook/react';
7+
import {
8+
LocationProvider,
9+
createHistory,
10+
createMemorySource,
11+
} from '@reach/router';
12+
import { withLocalization } from 'fxa-react/lib/storybooks';
13+
import { IntegrationType } from '../../models/integrations';
14+
import { PromoQrMobile } from '.';
15+
import AppLayout from '../AppLayout';
16+
17+
export default {
18+
title: 'Components/PromoQrMobile',
19+
component: PromoQrMobile,
20+
decorators: [withLocalization],
21+
} as Meta;
22+
23+
export const Default = () => {
24+
const history = createHistory(createMemorySource('/'));
25+
26+
return (
27+
<LocationProvider {...{ history }}>
28+
<PromoQrMobile integration={{ type: IntegrationType.Web }} />
29+
</LocationProvider>
30+
);
31+
};
32+
33+
export const WithCardAppLayout = () => {
34+
const history = createHistory(createMemorySource('/'));
35+
36+
return (
37+
<LocationProvider {...{ history }}>
38+
<AppLayout>
39+
<h1 className="card-header">Sign in</h1>
40+
<p className="mt-2">Continue to account settings</p>
41+
</AppLayout>
42+
<PromoQrMobile integration={{ type: IntegrationType.Web }} />
43+
</LocationProvider>
44+
);
45+
};
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4+
5+
import React from 'react';
6+
import { screen } from '@testing-library/react';
7+
import { createHistory, createMemorySource } from '@reach/router';
8+
import { PromoQrMobile, PromoQrMobileIntegration } from '.';
9+
import { IntegrationType } from '../../models/integrations';
10+
import { renderWithRouter } from '../../models/mocks';
11+
import GleanMetrics from '../../lib/glean';
12+
13+
jest.mock('../../lib/glean', () => ({
14+
__esModule: true,
15+
default: {
16+
promoQrMobile: {
17+
view: jest.fn(),
18+
},
19+
},
20+
}));
21+
22+
// jsdom does not implement matchMedia
23+
const mockMatchMedia = jest.fn();
24+
beforeAll(() => {
25+
Object.defineProperty(window, 'matchMedia', {
26+
writable: true,
27+
value: mockMatchMedia,
28+
});
29+
});
30+
31+
function createIntegration(type: IntegrationType): PromoQrMobileIntegration {
32+
return { type };
33+
}
34+
35+
function renderAtRoute(
36+
pathname: string,
37+
integration: PromoQrMobileIntegration
38+
) {
39+
const history = createHistory(createMemorySource(pathname));
40+
return renderWithRouter(<PromoQrMobile integration={integration} />, {
41+
history,
42+
});
43+
}
44+
45+
describe('PromoQrMobile', () => {
46+
beforeEach(() => {
47+
jest.clearAllMocks();
48+
mockMatchMedia.mockReturnValue({ matches: true });
49+
});
50+
51+
describe('visibility based on integration type', () => {
52+
it('renders for web integrations', () => {
53+
renderAtRoute('/', createIntegration(IntegrationType.Web));
54+
expect(screen.getByRole('complementary')).toBeInTheDocument();
55+
});
56+
57+
it('does not render for OAuth integrations', () => {
58+
renderAtRoute('/', createIntegration(IntegrationType.OAuthWeb));
59+
expect(screen.queryByRole('complementary')).not.toBeInTheDocument();
60+
});
61+
62+
it('does not render for Sync integrations', () => {
63+
renderAtRoute('/', createIntegration(IntegrationType.OAuthNative));
64+
expect(screen.queryByRole('complementary')).not.toBeInTheDocument();
65+
});
66+
});
67+
68+
describe('visibility based on route', () => {
69+
const webIntegration = createIntegration(IntegrationType.Web);
70+
71+
it.each([
72+
'/',
73+
'/signin',
74+
'/signin_totp_code',
75+
'/signin_recovery_choice',
76+
'/signin_recovery_code',
77+
'/signin_passwordless_code',
78+
'/signup',
79+
'/confirm_signup_code',
80+
'/inline_totp_setup',
81+
'/inline_recovery_setup',
82+
'/inline_recovery_key_setup',
83+
])('renders on %s', (route) => {
84+
renderAtRoute(route, webIntegration);
85+
expect(screen.getByRole('complementary')).toBeInTheDocument();
86+
});
87+
88+
it.each([
89+
'/reset_password',
90+
'/confirm_reset_password',
91+
'/complete_reset_password',
92+
'/settings',
93+
'/oauth',
94+
'/authorization',
95+
'/legal',
96+
])('does not render on %s', (route) => {
97+
renderAtRoute(route, webIntegration);
98+
expect(screen.queryByRole('complementary')).not.toBeInTheDocument();
99+
});
100+
});
101+
102+
describe('Glean view event', () => {
103+
const webIntegration = createIntegration(IntegrationType.Web);
104+
105+
it('fires the view event once on desktop', () => {
106+
mockMatchMedia.mockReturnValue({ matches: true });
107+
renderAtRoute('/', webIntegration);
108+
expect(GleanMetrics.promoQrMobile.view).toHaveBeenCalledTimes(1);
109+
});
110+
111+
it('does not fire the view event on mobile viewports', () => {
112+
mockMatchMedia.mockReturnValue({ matches: false });
113+
renderAtRoute('/', webIntegration);
114+
expect(GleanMetrics.promoQrMobile.view).not.toHaveBeenCalled();
115+
});
116+
117+
it('does not fire when integration is not web', () => {
118+
mockMatchMedia.mockReturnValue({ matches: true });
119+
renderAtRoute('/', createIntegration(IntegrationType.OAuthWeb));
120+
expect(GleanMetrics.promoQrMobile.view).not.toHaveBeenCalled();
121+
});
122+
123+
it('does not fire on excluded routes', () => {
124+
mockMatchMedia.mockReturnValue({ matches: true });
125+
renderAtRoute('/reset_password', webIntegration);
126+
expect(GleanMetrics.promoQrMobile.view).not.toHaveBeenCalled();
127+
});
128+
});
129+
130+
describe('content', () => {
131+
it('renders Firefox logo, heading, description, and QR code', () => {
132+
renderAtRoute('/', createIntegration(IntegrationType.Web));
133+
134+
expect(screen.getByAltText('Firefox logo')).toBeInTheDocument();
135+
expect(screen.getByText('Your phone. Your rules.')).toBeInTheDocument();
136+
expect(screen.getByText('Scan to get the app')).toBeInTheDocument();
137+
expect(
138+
screen.getByAltText(/QR code to download the Firefox mobile app/)
139+
).toBeInTheDocument();
140+
});
141+
});
142+
});
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4+
5+
import React, { useEffect, useRef } from 'react';
6+
import { useLocation } from '@reach/router';
7+
import { FtlMsg } from 'fxa-react/lib/utils';
8+
import ffLogo from '@fxa/shared/assets/images/ff-logo.svg';
9+
import qrMobileKitSrc from './qr-mobile-kit.svg';
10+
import { Integration, isWebIntegration } from '../../models/integrations';
11+
import GleanMetrics from '../../lib/glean';
12+
13+
export type PromoQrMobileIntegration = Pick<Integration, 'type'>;
14+
15+
// Must match the `desktop` breakpoint in packages/fxa-react/configs/tailwind.js.
16+
// This is a little fragile, but we're doing it to fire a Glean event only at
17+
// desktop when the QR code is shown. We won't worry about window resizing.
18+
const DESKTOP_MQ = '(min-width: 1024px)';
19+
20+
const QR_PROMO_ROUTE_PREFIXES = [
21+
'/signin',
22+
'/signup',
23+
'/confirm_signup',
24+
'/inline_totp_setup',
25+
'/inline_recovery_setup',
26+
'/inline_recovery_key_setup',
27+
];
28+
29+
function shouldShowPromo(pathname: string): boolean {
30+
if (pathname === '/') return true;
31+
return QR_PROMO_ROUTE_PREFIXES.some((prefix) => pathname.startsWith(prefix));
32+
}
33+
34+
export const PromoQrMobile = ({
35+
integration,
36+
}: {
37+
integration: PromoQrMobileIntegration;
38+
}) => {
39+
const location = useLocation();
40+
const hasLoggedView = useRef(false);
41+
42+
const visible =
43+
isWebIntegration(integration) && shouldShowPromo(location.pathname);
44+
45+
useEffect(() => {
46+
if (
47+
visible &&
48+
!hasLoggedView.current &&
49+
window.matchMedia(DESKTOP_MQ).matches
50+
) {
51+
hasLoggedView.current = true;
52+
GleanMetrics.promoQrMobile.view();
53+
}
54+
}, [visible]);
55+
56+
if (!visible) return <></>;
57+
58+
return (
59+
<aside className="hidden desktop:fixed desktop:flex desktop:flex-col desktop:items-center desktop:bottom-8 desktop:end-12">
60+
{/* We use 'img' here instead of inlined SVGs since they are heavier SVG assets and
61+
* are used across multiple pages - the browser will cache them */}
62+
<div className="me-5 text-center">
63+
<img src={ffLogo} alt="Firefox logo" className="w-6 h-6 mb-2 mx-auto" />
64+
<FtlMsg id="promo-qr-mobile-heading">
65+
<h2 className="text-md font-extrabold text-grey-900 dark:text-white">
66+
Your phone. Your rules.
67+
</h2>
68+
</FtlMsg>
69+
<FtlMsg id="promo-qr-mobile-description">
70+
<p className="text-sm text-grey-700 dark:text-grey-100">
71+
Scan to get the app
72+
</p>
73+
</FtlMsg>
74+
</div>
75+
<FtlMsg id="promo-qr-mobile-qr-alt" attrs={{ alt: true }}>
76+
<img
77+
src={qrMobileKitSrc}
78+
alt="QR code to download the Firefox mobile app. Position your phone’s camera on the lower-right corner of your screen to scan it."
79+
className="mx-auto max-h-44 mt-1"
80+
/>
81+
</FtlMsg>
82+
</aside>
83+
);
84+
};
85+
86+
export default PromoQrMobile;

0 commit comments

Comments
 (0)