diff --git a/packages/fxa-settings/src/components/Settings/PageSettings/index.test.tsx b/packages/fxa-settings/src/components/Settings/PageSettings/index.test.tsx index 4a0fec7347..c8f1bb84d3 100644 --- a/packages/fxa-settings/src/components/Settings/PageSettings/index.test.tsx +++ b/packages/fxa-settings/src/components/Settings/PageSettings/index.test.tsx @@ -6,9 +6,19 @@ import React from 'react'; import { screen } from '@testing-library/react'; import { userEvent } from '@testing-library/user-event'; import PageSettings from '.'; -import { renderWithRouter } from '../../../models/mocks'; +import { + MOCK_ACCOUNT, + mockAppContext, + renderWithRouter, +} from '../../../models/mocks'; import * as Metrics from '../../../lib/metrics'; import GleanMetrics from '../../../lib/glean'; +import { Account, AppContext } from '../../../models'; +import { + ALL_PRODUCT_PROMO_SERVICES, + ALL_PRODUCT_PROMO_SUBSCRIPTIONS, +} from '../../../pages/mocks'; +import { MOCK_SERVICES } from '../ConnectedServices/mocks'; jest.mock('../../../lib/metrics', () => ({ setProperties: jest.fn(), @@ -38,6 +48,9 @@ beforeEach(() => { }); describe('PageSettings', () => { + afterEach(() => { + jest.clearAllMocks(); + }); it('renders without imploding', async () => { renderWithRouter(); expect(screen.getByTestId('settings-profile')).toBeInTheDocument(); @@ -68,5 +81,42 @@ describe('PageSettings', () => { ); expect(GleanMetrics.deleteAccount.settingsSubmit).toHaveBeenCalled(); }); + + describe('product promo event', () => { + it('user does not have Monitor', async () => { + const account = { + ...MOCK_ACCOUNT, + attachedClients: [], + subscriptions: [], + } as unknown as Account; + renderWithRouter( + + + + ); + expect(GleanMetrics.accountPref.promoMonitorView).toBeCalledTimes(1); + expect(GleanMetrics.accountPref.promoMonitorView).toBeCalledWith({ + event: { reason: 'free' }, + }); + }); + it('user has all products and subscriptions', async () => { + const attachedClients = MOCK_SERVICES.filter((service) => + ALL_PRODUCT_PROMO_SERVICES.some( + (promoService) => promoService.name === service.name + ) + ); + const account = { + ...MOCK_ACCOUNT, + attachedClients, + subscriptions: ALL_PRODUCT_PROMO_SUBSCRIPTIONS, + } as unknown as Account; + renderWithRouter( + + + + ); + expect(GleanMetrics.accountPref.promoMonitorView).not.toBeCalled(); + }); + }); }); }); diff --git a/packages/fxa-settings/src/components/Settings/PageSettings/index.tsx b/packages/fxa-settings/src/components/Settings/PageSettings/index.tsx index 5485916292..3d7e72ff0c 100644 --- a/packages/fxa-settings/src/components/Settings/PageSettings/index.tsx +++ b/packages/fxa-settings/src/components/Settings/PageSettings/index.tsx @@ -2,7 +2,7 @@ * 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, useRef } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { Link, RouteComponentProps } from '@reach/router'; import Security from '../Security'; import { Profile } from '../Profile'; @@ -15,14 +15,17 @@ import { SETTINGS_PATH } from 'fxa-settings/src/constants'; import { Localized } from '@fluent/react'; import DataCollection from '../DataCollection'; import GleanMetrics from '../../../lib/glean'; -import ProductPromo, { ProductPromoType } from '../ProductPromo'; +import ProductPromo, { + getProductPromoData, + ProductPromoType, +} from '../ProductPromo'; import SideBar from '../Sidebar'; import NotificationPromoBanner from '../../NotificationPromoBanner'; import keyImage from '../../NotificationPromoBanner/key.svg'; import Head from 'fxa-react/components/Head'; export const PageSettings = (_: RouteComponentProps) => { - const { uid, recoveryKey } = useAccount(); + const { uid, recoveryKey, attachedClients, subscriptions } = useAccount(); const ftlMsgResolver = useFtlMsgResolver(); Metrics.setProperties({ @@ -35,6 +38,23 @@ export const PageSettings = (_: RouteComponentProps) => { GleanMetrics.accountPref.view(); }, []); + const [productPromoGleanEventSent, setProductPromoGleanEventSent] = + useState(false); + + useEffect(() => { + // We want this view event to fire whenever the account settings page view + // event fires, if the user is shown the promo. + const { gleanEvent, hasAllPromoProducts } = getProductPromoData( + attachedClients, + subscriptions + ); + if (!hasAllPromoProducts && !productPromoGleanEventSent) { + GleanMetrics.accountPref.promoMonitorView(gleanEvent); + // Keep track of this because `attachedClients` can change on disconnect + setProductPromoGleanEventSent(true); + } + }, [attachedClients, subscriptions, productPromoGleanEventSent]); + const accountRecoveryNotificationProps = { headerImage: keyImage, ctaText: ftlMsgResolver.getMsg( diff --git a/packages/fxa-settings/src/components/Settings/ProductPromo/index.test.tsx b/packages/fxa-settings/src/components/Settings/ProductPromo/index.test.tsx index 0a73227fe0..c669c9b408 100644 --- a/packages/fxa-settings/src/components/Settings/ProductPromo/index.test.tsx +++ b/packages/fxa-settings/src/components/Settings/ProductPromo/index.test.tsx @@ -6,15 +6,14 @@ import React from 'react'; import { fireEvent, screen, waitFor } from '@testing-library/react'; import ProductPromo, { ProductPromoType } from '.'; import { Account, AppContext } from '../../../models'; -import { MOCK_SERVICES } from '../ConnectedServices/mocks'; import { MozServices } from '../../../lib/types'; import { renderWithLocalizationProvider } from 'fxa-react/lib/test-utils/localizationProvider'; import { mockAppContext } from '../../../models/mocks'; import GleanMetrics from '../../../lib/glean'; - -// List all services this component handles -const PRODUCT_PROMO_SERVICES = [MozServices.Monitor]; -const PRODUCT_PROMO_SUBSCRIPTIONS = [{ productName: MozServices.MonitorPlus }]; +import { + ALL_PRODUCT_PROMO_SERVICES, + ALL_PRODUCT_PROMO_SUBSCRIPTIONS, +} from '../../../pages/mocks'; jest.mock('../../../lib/glean', () => ({ __esModule: true, @@ -32,12 +31,8 @@ describe('ProductPromo', () => { }); it('renders nothing if user has Monitor but not MonitorPlus, and MonitorPlus Promo is disabled', () => { - const services = MOCK_SERVICES.filter((service) => - // TODO: MozServices / string discrepancy, FXA-6802 - PRODUCT_PROMO_SERVICES.includes(service.name as MozServices) - ); const account = { - attachedClients: services, + attachedClients: ALL_PRODUCT_PROMO_SERVICES, subscriptions: [], } as unknown as Account; @@ -48,17 +43,12 @@ describe('ProductPromo', () => { ); expect(container.firstChild).toBeNull(); - expect(GleanMetrics.accountPref.promoMonitorView).not.toHaveBeenCalled(); }); it('renders nothing if user has all products and subscriptions', async () => { - const services = MOCK_SERVICES.filter((service) => - // TODO: MozServices / string discrepancy, FXA-6802 - PRODUCT_PROMO_SERVICES.includes(service.name as MozServices) - ); const account = { - attachedClients: services, - subscriptions: PRODUCT_PROMO_SUBSCRIPTIONS, + attachedClients: ALL_PRODUCT_PROMO_SERVICES, + subscriptions: ALL_PRODUCT_PROMO_SUBSCRIPTIONS, } as unknown as Account; const { container } = renderWithLocalizationProvider( @@ -68,7 +58,6 @@ describe('ProductPromo', () => { ); expect(container.firstChild).toBeNull(); - expect(GleanMetrics.accountPref.promoMonitorView).not.toHaveBeenCalled(); }); it('renders Monitor promo if user does not have Monitor', async () => { const account = { @@ -89,9 +78,6 @@ describe('ProductPromo', () => { 'href', 'https://monitor.mozilla.org/?utm_source=moz-account&utm_medium=product-partnership&utm_term=sidebar&utm_content=monitor-free&utm_campaign=settings-promo' ); - expect(GleanMetrics.accountPref.promoMonitorView).toBeCalledWith({ - event: { reason: 'free' }, - }); }); it('renders Monitor Plus promo if user does not have Monitor Plus', async () => { @@ -120,9 +106,6 @@ describe('ProductPromo', () => { 'href', 'https://monitor.mozilla.org/#pricing?utm_source=moz-account&utm_medium=product-partnership&utm_term=sidebar&utm_content=monitor-plus&utm_campaign=settings-promo' ); - expect(GleanMetrics.accountPref.promoMonitorView).toBeCalledWith({ - event: { reason: 'plus' }, - }); }); it('emits metric when user clicks call to action', async () => { diff --git a/packages/fxa-settings/src/components/Settings/ProductPromo/index.tsx b/packages/fxa-settings/src/components/Settings/ProductPromo/index.tsx index 08a7a15eed..4ff382885d 100644 --- a/packages/fxa-settings/src/components/Settings/ProductPromo/index.tsx +++ b/packages/fxa-settings/src/components/Settings/ProductPromo/index.tsx @@ -8,7 +8,7 @@ import monitorTextLogo from './monitor-text-logo.svg'; import { FtlMsg } from 'fxa-react/lib/utils'; import classNames from 'classnames'; import { MozServices } from '../../../lib/types'; -import { useAccount, useConfig } from '../../../models'; +import { AccountData, useAccount, useConfig } from '../../../models'; import { constructHrefWithUtm } from '../../../lib/utilities'; import { LINK } from '../../../constants'; import GleanMetrics from '../../../lib/glean'; @@ -27,13 +27,12 @@ export interface ProductPromoProps { monitorPlusEnabled?: boolean; } -export const ProductPromo = ({ - type = ProductPromoType.Sidebar, - monitorPlusEnabled = MONITOR_PLUS_ENABLED, -}: ProductPromoProps) => { - const { attachedClients, subscriptions } = useAccount(); - const { env } = useConfig(); - +export function getProductPromoData( + attachedClients: AccountData['attachedClients'], + subscriptions: AccountData['subscriptions'], + // Temporary until we work on MonitorPlus + monitorPlusEnabled = MONITOR_PLUS_ENABLED +) { const hasMonitor = attachedClients.some( ({ name }) => name === MozServices.Monitor ); @@ -42,10 +41,29 @@ export const ProductPromo = ({ ({ productName }) => productName === MozServices.MonitorPlus ); + // Temporary until we work on MonitorPlus const showMonitorPlusPromo = hasMonitor && !hasMonitorPlus && monitorPlusEnabled; + const hasAllPromoProducts = hasMonitor && !showMonitorPlusPromo; - if (hasMonitor && !showMonitorPlusPromo) { + const gleanEvent = showMonitorPlusPromo + ? { event: { reason: 'plus' } } + : { event: { reason: 'free' } }; + + return { showMonitorPlusPromo, hasAllPromoProducts, gleanEvent }; +} + +export const ProductPromo = ({ + type = ProductPromoType.Sidebar, + // Temporary until we work on MonitorPlus + monitorPlusEnabled = MONITOR_PLUS_ENABLED, +}: ProductPromoProps) => { + const { attachedClients, subscriptions } = useAccount(); + const { env } = useConfig(); + const { showMonitorPlusPromo, hasAllPromoProducts, gleanEvent } = + getProductPromoData(attachedClients, subscriptions, monitorPlusEnabled); + + if (hasAllPromoProducts) { return <>; } @@ -67,17 +85,6 @@ export const ProductPromo = ({ 'settings-promo' ); - const gleanEvent = showMonitorPlusPromo - ? { event: { reason: 'plus' } } - : { event: { reason: 'free' } }; - - // NOTE, this is a quick fix to prevent double 'view' event firing - // since we use this component in two places (sidebar + settings). - // We will want to refactor this to be less fragile. - if (type === ProductPromoType.Settings) { - GleanMetrics.accountPref.promoMonitorView(gleanEvent); - } - const promoContent = showMonitorPlusPromo ? ( <>

diff --git a/packages/fxa-settings/src/models/Account.ts b/packages/fxa-settings/src/models/Account.ts index 77252b6cce..e9fee2517e 100644 --- a/packages/fxa-settings/src/models/Account.ts +++ b/packages/fxa-settings/src/models/Account.ts @@ -83,6 +83,11 @@ export interface AttachedClient { refreshTokenId: string | null; } +interface Subscription { + created: number; + productName: string; +} + export interface AccountData { uid: hexstring; displayName: string | null; @@ -102,10 +107,7 @@ export interface AccountData { attachedClients: AttachedClient[]; linkedAccounts: LinkedAccount[]; totp: AccountTotp; - subscriptions: { - created: number; - productName: string; - }[]; + subscriptions: Subscription[]; securityEvents: SecurityEvent[]; } diff --git a/packages/fxa-settings/src/pages/Signin/SigninPushCodeConfirm/index.tsx b/packages/fxa-settings/src/pages/Signin/SigninPushCodeConfirm/index.tsx index c427afac30..838f2d3705 100644 --- a/packages/fxa-settings/src/pages/Signin/SigninPushCodeConfirm/index.tsx +++ b/packages/fxa-settings/src/pages/Signin/SigninPushCodeConfirm/index.tsx @@ -26,7 +26,7 @@ export type SigninPushCodeConfirmProps = { errorMessage?: string; }; -const ProductPromotion = () => { +const Products = () => { const products = [ { icon: monitorIcon, @@ -99,7 +99,7 @@ const LoginApprovedMessage = () => { Your login has been approved. Please close this window.

- + ); }; diff --git a/packages/fxa-settings/src/pages/mocks.tsx b/packages/fxa-settings/src/pages/mocks.tsx index 15d8e46521..418cd24644 100644 --- a/packages/fxa-settings/src/pages/mocks.tsx +++ b/packages/fxa-settings/src/pages/mocks.tsx @@ -66,3 +66,7 @@ export function mockLoadingSpinnerModule() { } export const MOCK_RECOVERY_KEY = 'ARJDF300TFEPRJ7SFYB8QVNVYT60WWS2'; export const MOCK_REMOTE_METADATA = JSON.stringify({}); +export const ALL_PRODUCT_PROMO_SERVICES = [{ name: MozServices.Monitor }]; +export const ALL_PRODUCT_PROMO_SUBSCRIPTIONS = [ + { productName: MozServices.MonitorPlus }, +];