diff --git a/packages/fxa-settings/package.json b/packages/fxa-settings/package.json index c3ca1dddd45..03e34e6d7e9 100644 --- a/packages/fxa-settings/package.json +++ b/packages/fxa-settings/package.json @@ -169,6 +169,7 @@ "postcss-loader": "^8.1.1", "postcss-normalize": "^13.0.1", "postcss-preset-env": "^10.0.5", + "qrcode.react": "^4.2.0", "react-app-polyfill": "^3.0.0", "react-async-hook": "^4.0.0", "react-dev-utils": "^12.0.1", diff --git a/packages/fxa-settings/src/components/QRCode/index.stories.tsx b/packages/fxa-settings/src/components/QRCode/index.stories.tsx new file mode 100644 index 00000000000..5c3fc20965b --- /dev/null +++ b/packages/fxa-settings/src/components/QRCode/index.stories.tsx @@ -0,0 +1,37 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { Meta } from '@storybook/react'; +import { withLocalization } from 'fxa-react/lib/storybooks'; +import QRCode from '.'; +import firefoxLogo from '../../pages/Pair/Index/firefox-logo-browser.svg'; + +export default { + title: 'Components/QRCode', + component: QRCode, + decorators: [withLocalization], +} as Meta; + +const value = 'https://app.adjust.com/2uo1qc?campaign=send-tab&creative=demo'; + +export const Default = () => ( + +); + +export const WithLogo = () => ( + +); + +export const Loading = () => ( + +); diff --git a/packages/fxa-settings/src/components/QRCode/index.test.tsx b/packages/fxa-settings/src/components/QRCode/index.test.tsx new file mode 100644 index 00000000000..890246a4e76 --- /dev/null +++ b/packages/fxa-settings/src/components/QRCode/index.test.tsx @@ -0,0 +1,91 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import React from 'react'; +import { act, screen } from '@testing-library/react'; +import { renderWithLocalizationProvider } from 'fxa-react/lib/test-utils/localizationProvider'; +import QRCode from './index'; + +const VALUE = 'https://app.adjust.com/2uo1qc?campaign=send-tab'; +const LABEL = 'QR code to download Firefox for mobile'; +const IMAGE_DATA = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=='; + +describe('QRCode', () => { + it('renders an accessible image labelled by localizedLabel', () => { + renderWithLocalizationProvider( + + ); + expect(screen.getByRole('img', { name: LABEL })).toBeInTheDocument(); + }); + + it('renders a pre-rendered QR image when imageData is provided', () => { + renderWithLocalizationProvider( + + ); + expect(screen.getByRole('img', { name: LABEL })).toHaveAttribute( + 'src', + IMAGE_DATA + ); + }); + + it('does not generate an SVG QR code when imageData is provided', () => { + const { container } = renderWithLocalizationProvider( + + ); + expect(container.querySelector('svg')).not.toBeInTheDocument(); + }); + + it('forwards the provided className to the container', () => { + const { container } = renderWithLocalizationProvider( + + ); + expect(container.firstChild).toHaveClass('my-10', 'mx-auto'); + }); + + it('overlays the logo when logoSrc is provided', () => { + const { container } = renderWithLocalizationProvider( + + ); + expect(container.querySelector('img')).toHaveAttribute('src', 'logo.svg'); + }); + + it('renders no logo when logoSrc is omitted', () => { + const { container } = renderWithLocalizationProvider( + + ); + expect(container.querySelector('img')).not.toBeInTheDocument(); + }); + + it('shows the loading indicator while loading', () => { + renderWithLocalizationProvider( + + ); + expect(screen.getByTestId('qrcode-loading')).toBeInTheDocument(); + expect(screen.getByTestId('loading-spinner')).toBeInTheDocument(); + }); + + it('does not show the loading indicator when not loading', () => { + renderWithLocalizationProvider( + + ); + expect(screen.queryByTestId('qrcode-loading')).not.toBeInTheDocument(); + }); + + it('shows the loading indicator for loadingDelayMs, then reveals the QR', () => { + jest.useFakeTimers(); + try { + renderWithLocalizationProvider( + + ); + expect(screen.getByTestId('qrcode-loading')).toBeInTheDocument(); + act(() => { + jest.advanceTimersByTime(1000); + }); + expect(screen.queryByTestId('qrcode-loading')).not.toBeInTheDocument(); + } finally { + jest.useRealTimers(); + } + }); +}); diff --git a/packages/fxa-settings/src/components/QRCode/index.tsx b/packages/fxa-settings/src/components/QRCode/index.tsx new file mode 100644 index 00000000000..64e38ab2f47 --- /dev/null +++ b/packages/fxa-settings/src/components/QRCode/index.tsx @@ -0,0 +1,139 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import React, { useEffect, useState } from 'react'; +import classNames from 'classnames'; +import { QRCodeSVG } from 'qrcode.react'; +import LoadingSpinner from 'fxa-react/components/LoadingSpinner'; + +// Transparent 1x1 PNG; excavates modules behind the logo so its chip never clips them. +const TRANSPARENT_PIXEL = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=='; + +/** Logo footprint as a fraction of the QR size. */ +const LOGO_RATIO = 0.2; +/** White padding (px) around the logo, matching the chip's Tailwind `p-3`. */ +const LOGO_PADDING_PX = 12; + +type QRCodeBaseProps = { + /** Accessible label for the rendered QR image. */ + localizedLabel: string; + /** Rendered size in pixels. */ + size?: number; + /** Error correction level; defaults to 'H' so an overlaid logo stays scannable. */ + level?: 'L' | 'M' | 'Q' | 'H'; + /** Optional logo to overlay in the center of the QR code (generated QR only). */ + logoSrc?: string; + /** Minimum QR version (1-40); higher means more, smaller modules. Defaults to 1 for larger modules. */ + minVersion?: number; + /** Show a loading indicator in place of the QR (e.g. while the value is fetched). */ + loading?: boolean; + /** Artificial delay (ms) holding the loading state so the animation registers. Omit for none. */ + loadingDelayMs?: number; + className?: string; +}; + +/** + * Exactly one source is required: either a `value` to encode, or a pre-rendered + * QR as an image data URI (`imageData`, e.g. base64 PNG/SVG) rendered as-is. + */ +export type QRCodeProps = QRCodeBaseProps & + ({ value: string; imageData?: never } | { imageData: string; value?: never }); + +/** + * Domain-agnostic QR code. While loading, a centered spinner replaces the QR, + * which fades in once loading ends. + */ +const QRCode = ({ + value, + imageData, + localizedLabel, + size = 224, + level = 'H', + logoSrc, + minVersion = 1, + loading = false, + loadingDelayMs, + className, +}: QRCodeProps) => { + const [delaying, setDelaying] = useState(!!loadingDelayMs); + useEffect(() => { + if (!loadingDelayMs) { + setDelaying(false); + return; + } + setDelaying(true); + const timer = setTimeout(() => setDelaying(false), loadingDelayMs); + return () => clearTimeout(timer); + }, [loadingDelayMs]); + + const isLoading = loading || delaying; + const logoSize = Math.round(size * LOGO_RATIO); + const excavateSize = logoSize + LOGO_PADDING_PX * 2; + const imageSettings = logoSrc + ? { + src: TRANSPARENT_PIXEL, + width: excavateSize, + height: excavateSize, + excavate: true, + } + : undefined; + return ( +
+ {isLoading && ( +
+ +
+ )} +
+ {imageData ? ( + {localizedLabel} + ) : ( + + )} + {logoSrc && !imageData && ( + + + + )} +
+
+ ); +}; + +export default QRCode; diff --git a/packages/fxa-settings/src/components/images/qr_code_firefox_mobile.svg b/packages/fxa-settings/src/components/images/qr_code_firefox_mobile.svg deleted file mode 100644 index a4d72e031bd..00000000000 --- a/packages/fxa-settings/src/components/images/qr_code_firefox_mobile.svg +++ /dev/null @@ -1,107 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/packages/fxa-settings/src/lib/constants.ts b/packages/fxa-settings/src/lib/constants.ts index c13d51d72e8..7d33153d30f 100644 --- a/packages/fxa-settings/src/lib/constants.ts +++ b/packages/fxa-settings/src/lib/constants.ts @@ -132,6 +132,13 @@ export const Constants = { DOWNLOAD_LINK_PAIRING_APP: 'https://app.adjust.com/2uo1qc?campaign=%(campaign)s&creative=%(creative)s&adgroup=android&fallback=https://play.google.com/store/apps/details?id=org.mozilla.firefox', + // Generic pairing download QR target (Mozilla shortlink). + DOWNLOAD_LINK_PAIRING_QR_DEFAULT: 'https://mzl.la/3NDxAIS', + + // Send-tab QR target; no adgroup/fallback so Adjust routes by the scanning device's OS. + DOWNLOAD_LINK_PAIRING_QR_SEND_TAB: + 'https://app.adjust.com/2uo1qc?campaign=%(campaign)s&creative=%(creative)s', + MOZ_ORG_SYNC_GET_STARTED_LINK: 'https://www.mozilla.org/firefox/sync?utm_source=fx-website&utm_medium=fx-accounts&utm_campaign=fx-signup&utm_content=fx-sync-get-started', //eslint-disable-line max-len diff --git a/packages/fxa-settings/src/lib/utilities.test.ts b/packages/fxa-settings/src/lib/utilities.test.ts index babd3ede8c0..420386a8e9f 100644 --- a/packages/fxa-settings/src/lib/utilities.test.ts +++ b/packages/fxa-settings/src/lib/utilities.test.ts @@ -3,6 +3,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { + buildPairingDownloadUrl, constructHrefWithUtm, deepMerge, formatSecret, @@ -395,3 +396,35 @@ describe('isSendTabEntrypoint', () => { expect(isSendTabEntrypoint('')).toBe(false); }); }); + +describe('buildPairingDownloadUrl', () => { + const DEFAULT_URL = 'https://mzl.la/3NDxAIS'; + + it('points send-tab entrypoints at the Adjust tracker so installs are attributable', () => { + const url = new URL(buildPairingDownloadUrl('send-tab-toolbar-icon')); + expect(url.host).toBe('app.adjust.com'); + }); + + it('sets campaign=send-tab for every send-tab entrypoint', () => { + for (const entrypoint of SEND_TAB_ENTRYPOINTS) { + const url = new URL(buildPairingDownloadUrl(entrypoint)); + expect(url.searchParams.get('campaign')).toBe('send-tab'); + } + }); + + it('records the specific entrypoint as the creative for per-entrypoint attribution', () => { + const url = new URL(buildPairingDownloadUrl('send-tab-account-menu')); + expect(url.searchParams.get('creative')).toBe('send-tab-account-menu'); + }); + + it('returns the generic Mozilla download link for a non-send-tab entrypoint', () => { + expect(buildPairingDownloadUrl('fxa_app_menu')).toBe(DEFAULT_URL); + }); + + it.each([undefined, null, ''])( + 'returns the generic Mozilla download link when entrypoint is %p', + (entrypoint) => { + expect(buildPairingDownloadUrl(entrypoint)).toBe(DEFAULT_URL); + } + ); +}); diff --git a/packages/fxa-settings/src/lib/utilities.ts b/packages/fxa-settings/src/lib/utilities.ts index 50a82508f0d..f15915ef3b3 100644 --- a/packages/fxa-settings/src/lib/utilities.ts +++ b/packages/fxa-settings/src/lib/utilities.ts @@ -6,6 +6,8 @@ import base32Encode from 'base32-encode'; import { AttachedClient } from '../models/Account'; import { navigate, NavigateFn, NavigateOptions } from '@reach/router'; import { SEND_TAB_ENTRYPOINTS } from '../constants'; +import { Constants } from './constants'; +import { interpolate } from './error-utils'; // Various utilities that don't fit in a standalone lib @@ -293,6 +295,17 @@ export function getPairingErrorMessage(err: unknown): string { /** Whether the given entrypoint originated from a Firefox "Send Tab" UI. */ export function isSendTabEntrypoint( entrypoint: string | null | undefined -): boolean { +): entrypoint is string { return !!entrypoint && SEND_TAB_ENTRYPOINTS.has(entrypoint); } + +/** URL for the pairing QR: an attributable Adjust link for send-tab, else the generic Mozilla link. */ +export function buildPairingDownloadUrl(entrypoint?: string | null): string { + if (!isSendTabEntrypoint(entrypoint)) { + return Constants.DOWNLOAD_LINK_PAIRING_QR_DEFAULT; + } + return interpolate(Constants.DOWNLOAD_LINK_PAIRING_QR_SEND_TAB, { + campaign: 'send-tab', + creative: entrypoint, + }); +} diff --git a/packages/fxa-settings/src/pages/Pair/Index/firefox-logo-browser.svg b/packages/fxa-settings/src/pages/Pair/Index/firefox-logo-browser.svg new file mode 100644 index 00000000000..c382310ecd0 --- /dev/null +++ b/packages/fxa-settings/src/pages/Pair/Index/firefox-logo-browser.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/fxa-settings/src/pages/Pair/Index/firefox-logo.svg b/packages/fxa-settings/src/pages/Pair/Index/firefox-logo.svg new file mode 100644 index 00000000000..2c7a9c1b7c6 --- /dev/null +++ b/packages/fxa-settings/src/pages/Pair/Index/firefox-logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/fxa-settings/src/pages/Pair/Index/index.test.tsx b/packages/fxa-settings/src/pages/Pair/Index/index.test.tsx index 1b48c4331dc..bd9841a95f9 100644 --- a/packages/fxa-settings/src/pages/Pair/Index/index.test.tsx +++ b/packages/fxa-settings/src/pages/Pair/Index/index.test.tsx @@ -64,6 +64,26 @@ jest.mock('../../../lib/glean', () => ({ }, })); +// Stub QRCode so the test can read the encoded URL without decoding an SVG; covered in its own test. +jest.mock('../../../components/QRCode', () => ({ + __esModule: true, + default: ({ + value, + localizedLabel, + }: { + value: string; + localizedLabel: string; + }) => {localizedLabel}, +})); + +const sendTabIntegration = { + data: { entrypoint: 'send-tab-toolbar-icon' }, +} as unknown as React.ComponentProps['integration']; + +const webIntegration = { + data: { entrypoint: 'fxa_app_menu' }, +} as unknown as React.ComponentProps['integration']; + describe('Pair', () => { // jsdom's default UA lacks "Firefox", which would trip the mount-effect UA check and redirect to /pair/unsupported. const realUserAgent = navigator.userAgent; @@ -190,8 +210,10 @@ describe('Pair', () => { }); describe('download screen', () => { - async function renderAndNavigateToDownload(): Promise { - await renderPair(); + async function renderAndNavigateToDownload( + props: React.ComponentProps = {} + ): Promise { + await renderPair(props); fireEvent.click(screen.getByLabelText(/I don’t have Firefox for mobile/)); fireEvent.click(screen.getByRole('button', { name: 'Continue' })); } @@ -210,6 +232,22 @@ describe('Pair', () => { ).toBeInTheDocument(); }); + it('encodes a send-tab campaign in the QR for a send-tab entrypoint', async () => { + await renderAndNavigateToDownload({ integration: sendTabIntegration }); + const url = new URL( + screen.getByTestId('pair-qr').getAttribute('data-value')! + ); + expect(url.searchParams.get('campaign')).toBe('send-tab'); + expect(url.searchParams.get('creative')).toBe('send-tab-toolbar-icon'); + }); + + it('encodes the generic Mozilla download link for a non-send-tab entrypoint', async () => { + await renderAndNavigateToDownload({ integration: webIntegration }); + expect(screen.getByTestId('pair-qr').getAttribute('data-value')).toBe( + 'https://mzl.la/3NDxAIS' + ); + }); + it('fires view Glean event when download screen renders', async () => { await renderAndNavigateToDownload(); expect(GleanMetrics.cadFireFox.view).toHaveBeenCalled(); diff --git a/packages/fxa-settings/src/pages/Pair/Index/index.tsx b/packages/fxa-settings/src/pages/Pair/Index/index.tsx index d3b252f8ad0..3ecd4e6f80e 100644 --- a/packages/fxa-settings/src/pages/Pair/Index/index.tsx +++ b/packages/fxa-settings/src/pages/Pair/Index/index.tsx @@ -23,10 +23,14 @@ import firefox, { FirefoxCommand, } from '../../../lib/channels/firefox'; import { hardNavigate } from 'fxa-react/lib/utils'; -import qrCodeFirefoxMobile from '../../../components/images/qr_code_firefox_mobile.svg'; +import QRCode from '../../../components/QRCode'; +import firefoxLogo from './firefox-logo-browser.svg'; import mobileFirefoxIcon from './mobile-ff.svg'; import mobileDownloadIcon from './mobile-download.svg'; -import { isSendTabEntrypoint } from '../../../lib/utilities'; +import { + buildPairingDownloadUrl, + isSendTabEntrypoint, +} from '../../../lib/utilities'; import type { PairOrigin } from '../../Signin/utils'; import type { SigninLocationState } from '../../Signin/interfaces'; import type { Integration } from '../../../models'; @@ -266,10 +270,13 @@ const Pair = ({ the camera on your mobile device:

- {localizedQRCodeLabel}
  • diff --git a/yarn.lock b/yarn.lock index 0bf6c6b362e..ee0041a2fc7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -31553,6 +31553,7 @@ __metadata: postcss-normalize: "npm:^13.0.1" postcss-preset-env: "npm:^10.0.5" prop-types: "npm:^15.8.1" + qrcode.react: "npm:^4.2.0" raw-loader: "npm:^4.0.2" react-app-polyfill: "npm:^3.0.0" react-async-hook: "npm:^4.0.0" @@ -45576,6 +45577,15 @@ __metadata: languageName: node linkType: hard +"qrcode.react@npm:^4.2.0": + version: 4.2.0 + resolution: "qrcode.react@npm:4.2.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + checksum: 10c0/68c691d130e5fda2f57cee505ed7aea840e7d02033100687b764601f9595e1116e34c13876628a93e1a5c2b85e4efc27d30b2fda72e2050c02f3e1c4e998d248 + languageName: node + linkType: hard + "qrcode@npm:^1.5.1": version: 1.5.4 resolution: "qrcode@npm:1.5.4"