From 224483740c03e00f9838852e1754ffc2d483028f Mon Sep 17 00:00:00 2001
From: Vijay Budhram
Date: Tue, 9 Jun 2026 11:10:04 -0400
Subject: [PATCH] feat(pair): generate pairing download QR per entrypoint
Because:
- The pairing download screen served one static QR image to everyone, so
mobile installs originating from a Send Tab flow could not be attributed
separately from generic downloads.
This commit:
- Adds a reusable, domain-agnostic QRCode component (qrcode.react) that
generates a QR from a value or renders a pre-rendered imageData URI, with an
optional center logo (modules excavated behind it so it stays scannable) and
a loading state.
- Adds isSendTabEntrypoint (now a type guard) and buildPairingDownloadUrl:
the generic mzl.la shortlink by default, and an Adjust deep-link
(campaign=send-tab, creative=) for Send Tab entrypoints so each
is individually attributable.
- Wires the component into the pairing download screen and removes the static
qr_code_firefox_mobile.svg asset.
---
packages/fxa-settings/package.json | 1 +
.../src/components/QRCode/index.stories.tsx | 37 +++++
.../src/components/QRCode/index.test.tsx | 91 ++++++++++++
.../src/components/QRCode/index.tsx | 139 ++++++++++++++++++
.../images/qr_code_firefox_mobile.svg | 107 --------------
packages/fxa-settings/src/lib/constants.ts | 7 +
.../fxa-settings/src/lib/utilities.test.ts | 33 +++++
packages/fxa-settings/src/lib/utilities.ts | 15 +-
.../pages/Pair/Index/firefox-logo-browser.svg | 1 +
.../src/pages/Pair/Index/firefox-logo.svg | 1 +
.../src/pages/Pair/Index/index.test.tsx | 42 +++++-
.../src/pages/Pair/Index/index.tsx | 19 ++-
yarn.lock | 10 ++
13 files changed, 387 insertions(+), 116 deletions(-)
create mode 100644 packages/fxa-settings/src/components/QRCode/index.stories.tsx
create mode 100644 packages/fxa-settings/src/components/QRCode/index.test.tsx
create mode 100644 packages/fxa-settings/src/components/QRCode/index.tsx
delete mode 100644 packages/fxa-settings/src/components/images/qr_code_firefox_mobile.svg
create mode 100644 packages/fxa-settings/src/pages/Pair/Index/firefox-logo-browser.svg
create mode 100644 packages/fxa-settings/src/pages/Pair/Index/firefox-logo.svg
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 (
+