diff --git a/locales-pending/landing-redesign-experiment.ftl b/locales-pending/landing-redesign-experiment.ftl new file mode 100644 index 00000000000..c6b53952668 --- /dev/null +++ b/locales-pending/landing-redesign-experiment.ftl @@ -0,0 +1,115 @@ +# 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/. + +### Landing page redesign experiment + +## Hero + +# Hero title + +landing-redesign-hero-title = Find where your personal info is exposed — and take it back +landing-redesign-hero-lead = { -brand-monitor } finds where your info has been exposed, such as your home address, phone number, or email, and helps you protect your privacy. + +# Hero CTA + +landing-redesign-hero-cta-input-label = Enter your email address to start your free account +landing-redesign-hero-cta-button-label = Get started + +# Hero content + +landing-redesign-hero-list-item-title-one = { -brand-monitor } works for you around the clock +landing-redesign-hero-list-item-description-one = Get data breach monitoring, account protection guidance, and automatic removal from data broker sites. + +landing-redesign-hero-list-item-title-two = Saves you up to 50 hours annually +landing-redesign-hero-list-item-description-two = { -brand-monitor } simplifies the time-consuming task of removing your information from websites that sell or share it. + +landing-redesign-hero-list-item-title-three = Over 10 million users trust { -brand-monitor } +landing-redesign-hero-list-item-description-three = Join the { -brand-monitor } community and start taking back control of your personal privacy online. + +# Banner CTA + +landing-redesign-banner-cta-header = There’s a $240 billion industry of data brokers selling your private information for profit. It’s time to take back your privacy. +landing-redesign-banner-cta-subheader = Create a free account to see if your personal data has been exposed by data brokers and data breaches. +landing-redesign-banner-cta-button-label = Start your free account + +# Info blocks + +landing-redesign-info-block-one-label = Proactive protection +landing-redesign-info-block-one-title = Stay protected with continuous data monitoring +landing-redesign-info-block-one-description = { -brand-mozilla-monitor } continuously scans the web for exposure of your personal data. By monitoring multiple types of personal information, you are better protected from identity theft or hackers. + +landing-redesign-info-block-two-label = Enhanced privacy +landing-redesign-info-block-two-title = Take control of your privacy with automated data removal +landing-redesign-info-block-two-description = { -brand-monitor-plus } automatically removes your data from hundreds of data brokers. This process saves you time and effort to reduce your online footprint and gives you more control over where your personal information is shared. + +landing-redesign-info-block-three-label = Stay informed +landing-redesign-info-block-three-title = Act fast with timely notifications +landing-redesign-info-block-three-description = With { -brand-monitor }, you will be notified when your data is found in data breaches or data brokers, allowing you to take action such as securing accounts, updating passwords, or requesting data removal, reducing the risk of privacy threats. + +# Pricing plans + +landing-redesign-pricing-plans-section-title = Choose your level of protection +landing-redesign-pricing-plans-section-description = Your privacy is our priority, so data breach monitoring is always free. For more robust protection, { -brand-monitor-plus } includes continuous automatic removal of your personal information. +landing-redesign-pricing-plans-cards-title = Pricing plans + +landing-redesign-pricing-plans-card-plus-label = Recommended +landing-redesign-pricing-plans-card-plus-title = { -brand-monitor-plus } +landing-redesign-pricing-plans-card-plus-subtitle = Automatic data removal requests +landing-redesign-pricing-plans-card-plus-cta-label = Get { -brand-monitor-plus } +# Variables: +# $data_broker_sites_total_num is the total number of data broker sites available to scan. It will always be more than 1. +landing-redesign-pricing-plans-card-plus-feature-item-one = { + $data_broker_sites_total_num -> + *[other] Monthly scans of { $data_broker_sites_total_num }+ data brokers that may be selling your personal info +} +landing-redesign-pricing-plans-card-plus-feature-item-two = Automatic removal of personal info from data broker sites +landing-redesign-pricing-plans-card-plus-feature-item-three = Continuous monitoring for data broker exposures and data breaches +landing-redesign-pricing-plans-card-plus-feature-item-four = Receive data broker and data breach exposure alerts +# Variables: +# $discountPercentage is the percentage you can save subscribing to an annual/yearly plan +landing-redesign-pricing-plans-card-plus-feature-item-five = Save { $discountPercentage }% with a yearly { -brand-monitor-plus } subscription +# There is not much room in the UI for this string: +# Abbreviating “month” with “mo”. +# Variables: +# $monthlyPrice (string) - annual plan's price per month, including currency, e.g. "$13.37" +landing-redesign-pricing-plans-card-plus-cta-yearly = { $monthlyPrice }/mo +# Variables: +# $yearlyPrice (string) - annual plan's price in total, including currency, e.g. "$13.37" +landing-redesign-pricing-plans-card-plus-cta-yearly-sum = { $yearlyPrice } total +# There is not much room in the UI for this string: +# Abbreviating “month” with “mo”. +# Variables: +# $monthlyPrice (string) - monthly plan's price, including currency, e.g. "$13.37" +landing-redesign-pricing-plans-card-plus-cta-monthly = { $monthlyPrice }/mo + +landing-redesign-pricing-plans-card-free-title = { -brand-monitor } +landing-redesign-pricing-plans-card-free-subtitle = Free breach alerts +landing-redesign-pricing-plans-card-free-cta-label = Get { -brand-monitor } (Free) +# Variables: +# $data_broker_sites_total_num is the total number of data broker sites available to scan. It will always be more than 1. +landing-redesign-pricing-plans-card-free-feature-item-one = { + $data_broker_sites_total_num -> + *[other] One-time scan of { $data_broker_sites_total_num }+ data brokers that may be selling your personal info +} +landing-redesign-pricing-plans-card-free-feature-item-two = Guided manual removal of personal info from data broker sites +landing-redesign-pricing-plans-card-free-feature-item-three = Continuous monitoring for data breach exposures +landing-redesign-pricing-plans-card-free-feature-item-four = Receive data breach exposure alerts +landing-redesign-pricing-plans-card-free-feature-item-five = Upgrade to { -brand-monitor-plus } anytime for automated protection + +# Logo block + +landing-redesign-logo-block-title = Trusted by 10 million people worldwide +landing-redesign-logo-block-description = Since 2018, we’ve helped people in 237 countries protect their data when it has been exposed. + +# FAQ (Frequently Asked Questions) + +landing-redesign-faq-section-title = Questions and answers +landing-redesign-faq-expand-button-alt = Open +landing-redesign-faq-close-button-alt = Close +landing-redesign-faq-sumo-link-label = Read all FAQs + +# Banner CTA with input + +landing-redesign-cta-input-banner-header = Take back control of your data +landing-redesign-cta-input-banner-subheader = Enter your email address to create a free account and see where your personal data is exposed online. diff --git a/src/app/(proper_react)/(redesign)/(public)/FreeScanCta.tsx b/src/app/(proper_react)/(redesign)/(public)/FreeScanCta.tsx index 291776214f3..439f5c13359 100644 --- a/src/app/(proper_react)/(redesign)/(public)/FreeScanCta.tsx +++ b/src/app/(proper_react)/(redesign)/(public)/FreeScanCta.tsx @@ -20,20 +20,27 @@ import { useViewTelemetry } from "../../../hooks/useViewTelemetry"; export const FreeScanCta = ( props: Props & { experimentData: ExperimentData["Features"]; + hasFloatingLabel?: boolean; + showCtaOnly?: boolean; }, ) => { const l10n = useL10n(); const [cookies] = useCookies(["attributionsFirstTouch"]); const metricsFlowContext = useContext(AccountsMetricsFlowContext); - const telemetryButtonId = `${props.eventId.cta}-${props.experimentData["landing-page-free-scan-cta"].variant}`; + const freeScanVariantId = props.experimentData["landing-page-free-scan-cta"] + .enabled + ? `-${props.experimentData["landing-page-free-scan-cta"].variant}` + : ""; + const telemetryButtonId = `${props.eventId.cta}${freeScanVariantId}`; const refViewTelemetry = useViewTelemetry("ctaButton", { button_id: telemetryButtonId, }); if ( - !props.experimentData["landing-page-free-scan-cta"].enabled || - props.experimentData["landing-page-free-scan-cta"].variant === - "ctaWithEmail" + (!props.experimentData["landing-page-free-scan-cta"].enabled || + props.experimentData["landing-page-free-scan-cta"].variant === + "ctaWithEmail") && + !props.showCtaOnly ) { return ( ); } @@ -74,12 +85,13 @@ export const FreeScanCta = ( ); }} > - {l10n.getString( - props.experimentData["landing-page-free-scan-cta"].variant === - "ctaOnly" - ? "landing-all-hero-emailform-submit-label" - : "landing-all-hero-emailform-submit-sign-up-label", - )} + {props.ctaLabel ?? + l10n.getString( + props.experimentData["landing-page-free-scan-cta"].variant === + "ctaOnly" + ? "landing-all-hero-emailform-submit-label" + : "landing-all-hero-emailform-submit-sign-up-label", + )} ); diff --git a/src/app/(proper_react)/(redesign)/(public)/LandingViewRedesign.module.scss b/src/app/(proper_react)/(redesign)/(public)/LandingViewRedesign.module.scss deleted file mode 100644 index a56803ec8bf..00000000000 --- a/src/app/(proper_react)/(redesign)/(public)/LandingViewRedesign.module.scss +++ /dev/null @@ -1,7 +0,0 @@ -@import "../../../tokens"; - -.wrapper { - display: flex; - flex-direction: column; - height: 100%; -} diff --git a/src/app/(proper_react)/(redesign)/(public)/LandingViewRedesign.test.tsx b/src/app/(proper_react)/(redesign)/(public)/LandingViewRedesign.test.tsx deleted file mode 100644 index 781f1d8ae96..00000000000 --- a/src/app/(proper_react)/(redesign)/(public)/LandingViewRedesign.test.tsx +++ /dev/null @@ -1,235 +0,0 @@ -/* 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 { it, expect } from "@jest/globals"; -import { composeStory } from "@storybook/react"; -import { render, screen, within } from "@testing-library/react"; -import { userEvent } from "@testing-library/user-event"; -import { axe } from "jest-axe"; -import { signIn, useSession } from "next-auth/react"; -import { useTelemetry } from "../../../hooks/useTelemetry"; -import Meta, { - LandingRedesignNonUs, - LandingRedesignUs, -} from "./LandingViewRedesign.stories"; -import { deleteAllCookies } from "../../../functions/client/deleteAllCookies"; - -jest.mock("next-auth/react", () => { - return { - signIn: jest.fn(), - useSession: jest.fn(() => { - return {}; - }), - }; -}); -jest.mock("next/navigation", () => ({ - useSearchParams: () => ({ - toString: jest.fn(), - }), - usePathname: jest.fn(), -})); - -jest.mock("../../../hooks/useTelemetry"); - -beforeEach(() => { - // For reasons that are unclear to me, the mock implementation defind in the - // call to `jest.mock` above forgets the implementation. I've spent way too - // long debugging that already, so I'm settling for this :( - const mockedUseSession = useSession as jest.Mock; - mockedUseSession.mockReturnValue({}); - - // Make the rebrand announcement banner show up by default - deleteAllCookies(); -}); - -describe("When Premium is not available", () => { - it("passes the axe accessibility test suite", async () => { - const ComposedLanding = composeStory(LandingRedesignNonUs, Meta); - const { container } = render(); - expect(await axe(container)).toHaveNoViolations(); - }); - - it("does not show a 'Sign In' button in the header if the user is signed in", () => { - const mockedUseSession = useSession as jest.Mock< - ReturnType, - Parameters - >; - mockedUseSession.mockReturnValue({ - data: { - user: { - email: "arbitrary@example.com", - }, - expires: "2023-06-18T14:48:00.000Z", - }, - status: "authenticated", - update: () => Promise.resolve(null), - }); - - const ComposedLanding = composeStory(LandingRedesignNonUs, Meta); - render(); - - const signInButton = screen.queryByRole("button", { - name: "Sign In", - }); - - expect(signInButton).not.toBeInTheDocument(); - }); - - it("shows a 'Sign In' button in the header if the user is not signed in", async () => { - const ComposedLanding = composeStory(LandingRedesignNonUs, Meta); - render(); - - const user = userEvent.setup(); - - const signInButtons = screen.getAllByRole("button", { - name: "Sign In", - }); - await user.click(signInButtons[0]); - await user.click(signInButtons[1]); - expect(signIn).toHaveBeenCalledTimes(2); - }); - - it("counts the number of clicks on the sign-in button at the top", async () => { - const mockedRecord = useTelemetry(); - const ComposedLanding = composeStory(LandingRedesignNonUs, Meta); - render(); - - const user = userEvent.setup(); - - const signInButtons = screen.getAllByRole("button", { - name: "Sign In", - }); - await user.click(signInButtons[0]); - await user.click(signInButtons[1]); - expect(signIn).toHaveBeenCalledTimes(2); - - expect(mockedRecord).toHaveBeenCalledWith( - "ctaButton", - "click", - expect.objectContaining({ - button_id: "sign_in", - }), - ); - }); -}); - -describe("When Premium is available", () => { - it("passes the axe accessibility test suite", async () => { - const ComposedLanding = composeStory(LandingRedesignUs, Meta); - const { container } = render(); - expect(await axe(container)).toHaveNoViolations(); - }); - - it.each([ - { - name: "How it works", - id: "navbar_how_it_works", - }, - { - name: "Pricing", - id: "navbar_pricing", - }, - { - name: "FAQs", - id: "navbar_faqs", - }, - { - name: "Recent data breaches", - id: "navbar_recent_breaches", - }, - ])("counts the number of clicks %s link in top navbar", async (link) => { - const mockedRecord = useTelemetry(); - const ComposedLanding = composeStory(LandingRedesignUs, Meta); - render(); - - const user = userEvent.setup(); - - const navbarLinks = screen.getAllByRole("link", { name: link.name }); - // jsdom will complain about not being able to navigate to a different page - // after clicking the link; suppress that error, as it's not relevant to the - // test: - jest - .spyOn(console, "error") - .mockImplementationOnce(() => undefined) - .mockImplementationOnce(() => undefined); - await user.click(navbarLinks[0]); - await user.click(navbarLinks[1]); - expect(mockedRecord).toHaveBeenNthCalledWith( - 2, - "link", - "click", - expect.objectContaining({ - link_id: link.id, - }), - ); - }); -}); - -describe("Account deletion confirmation", () => { - it("does not show a confirmaton message if the user has just deleted their account", () => { - global.fetch = jest.fn().mockImplementation(() => - Promise.resolve({ - success: true, - json: jest.fn(() => ({ - flowData: null, - })), - }), - ); - document.cookie = "justDeletedAccount=justDeletedAccount; max-age=0"; - - const ComposedLanding = composeStory(LandingRedesignNonUs, Meta); - render(); - - const alert = screen.queryByRole("alert"); - - expect(alert).not.toBeInTheDocument(); - }); - - it("shows a confirmaton message if the user has just deleted their account", () => { - global.fetch = jest.fn().mockImplementation(() => - Promise.resolve({ - success: true, - json: jest.fn(() => ({ - flowData: null, - })), - }), - ); - document.cookie = "justDeletedAccount=justDeletedAccount"; - - const ComposedLanding = composeStory(LandingRedesignNonUs, Meta); - render(); - - const alert = screen.getByRole("alert"); - const confirmationMessage = within(alert).getByText( - "Your ⁨Monitor⁩ account is now deleted.", - ); - - expect(alert).toBeInTheDocument(); - expect(confirmationMessage).toBeInTheDocument(); - }); - - it("hides the 'account deletion' confirmation message when the user dismisses it", async () => { - global.fetch = jest.fn().mockImplementation(() => - Promise.resolve({ - success: true, - json: jest.fn(() => ({ - flowData: null, - })), - }), - ); - const user = userEvent.setup(); - document.cookie = "justDeletedAccount=justDeletedAccount"; - - const ComposedLanding = composeStory(LandingRedesignNonUs, Meta); - render(); - - const alert = screen.getByRole("alert"); - const dismissButton = within(alert).getByRole("button", { - name: "Dismiss", - }); - await user.click(dismissButton); - - expect(alert).not.toBeInTheDocument(); - }); -}); diff --git a/src/app/(proper_react)/(redesign)/(public)/LandingViewRedesign.tsx b/src/app/(proper_react)/(redesign)/(public)/LandingViewRedesign.tsx deleted file mode 100644 index 0ed9bed03de..00000000000 --- a/src/app/(proper_react)/(redesign)/(public)/LandingViewRedesign.tsx +++ /dev/null @@ -1,27 +0,0 @@ -/* 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 { ExperimentData } from "../../../../telemetry/generated/nimbus/experiments"; -import { ExtendedReactLocalization } from "../../../functions/l10n"; -import { AccountDeletionNotification } from "./AccountDeletionNotification"; -import styles from "./LandingViewRedesign.module.scss"; - -export type Props = { - countryCode: string; - eligibleForPremium: boolean; - experimentData: ExperimentData["Features"]; - l10n: ExtendedReactLocalization; - scanLimitReached: boolean; -}; - -export const View = (props: Props) => { - return ( - <> - -
-

{props.l10n.getString("landing-all-hero-title")}

-
- - ); -}; diff --git a/src/app/(proper_react)/(redesign)/(public)/LandingViewRedesign/LandingViewRedesign.module.scss b/src/app/(proper_react)/(redesign)/(public)/LandingViewRedesign/LandingViewRedesign.module.scss new file mode 100644 index 00000000000..b61fb29c512 --- /dev/null +++ b/src/app/(proper_react)/(redesign)/(public)/LandingViewRedesign/LandingViewRedesign.module.scss @@ -0,0 +1,43 @@ +@use "../../../../tokens"; + +.wrapper { + display: flex; + flex-direction: column; + gap: tokens.$layout-md; + height: 100%; + padding: 0 tokens.$layout-2xs tokens.$layout-sm; + + @media screen and (min-width: tokens.$screen-sm) { + padding: 0 tokens.$layout-sm tokens.$layout-sm; + } + + @media screen and (min-width: tokens.$screen-lg) { + padding: 0 tokens.$layout-md tokens.$layout-sm; + } + + section { + align-items: center; + border-radius: tokens.$border-radius-xl; + display: flex; + flex-direction: column; + justify-content: center; + gap: tokens.$layout-sm; + width: 100%; + + &.hasBackground { + background-color: tokens.$color-grey-05; + padding: tokens.$layout-sm; + } + } + + h3 { + font: tokens.$text-title-xs; + font-family: var(--font-inter); + font-weight: 400; + line-height: 1.25em; + } + + b { + font-weight: 700; + } +} diff --git a/src/app/(proper_react)/(redesign)/(public)/LandingViewRedesign.stories.tsx b/src/app/(proper_react)/(redesign)/(public)/LandingViewRedesign/LandingViewRedesign.stories.tsx similarity index 56% rename from src/app/(proper_react)/(redesign)/(public)/LandingViewRedesign.stories.tsx rename to src/app/(proper_react)/(redesign)/(public)/LandingViewRedesign/LandingViewRedesign.stories.tsx index 24f6e7a47a4..1ee7a466acc 100644 --- a/src/app/(proper_react)/(redesign)/(public)/LandingViewRedesign.stories.tsx +++ b/src/app/(proper_react)/(redesign)/(public)/LandingViewRedesign/LandingViewRedesign.stories.tsx @@ -3,18 +3,23 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import type { Meta, StoryObj } from "@storybook/react"; -import { View, Props as ViewProps } from "./LandingViewRedesign"; -import { getL10n } from "../../../functions/l10n/storybookAndJest"; -import { PublicShell } from "./PublicShell"; -import { defaultExperimentData } from "../../../../telemetry/generated/nimbus/experiments"; -import { AccountsMetricsFlowProvider } from "../../../../contextProviders/accounts-metrics-flow"; -import { CONST_URL_MONITOR_LANDING_PAGE_ID } from "../../../../constants"; +import { View, LandingPageProps } from "../LandingViewRedesign"; +import { PublicShell } from "../PublicShell"; +import { getL10n } from "../../../../functions/l10n/storybookAndJest"; +import { defaultExperimentData } from "../../../../../telemetry/generated/nimbus/experiments"; +import { AccountsMetricsFlowProvider } from "../../../../../contextProviders/accounts-metrics-flow"; +import { CONST_URL_MONITOR_LANDING_PAGE_ID } from "../../../../../constants"; const meta: Meta = { title: "Pages/Public/Landing page/Redesign", - component: (props: ViewProps) => { - const experimentData = - props.experimentData ?? defaultExperimentData["Features"]; + component: (props: LandingPageProps) => { + const experimentData = props.experimentData ?? { + ...defaultExperimentData["Features"], + "landing-page-redesign-plus-eligible-experiment": { + enabled: true, + variant: "redesign", + }, + }; return ( = { }} > @@ -74,29 +73,3 @@ export const LandingRedesignUsScanLimit: Story = { scanLimitReached: true, }, }; - -export const LandingRedesignNonUs: Story = { - name: "Non-US visitors", - args: { - eligibleForPremium: false, - countryCode: "nz", - }, -}; - -export const LandingRedesignNonUsDe: Story = { - name: "German", - args: { - eligibleForPremium: false, - countryCode: "de", - l10n: getL10n("de"), - }, -}; - -export const LandingRedesignNonUsFr: Story = { - name: "French", - args: { - eligibleForPremium: false, - countryCode: "fr", - l10n: getL10n("fr"), - }, -}; diff --git a/src/app/(proper_react)/(redesign)/(public)/LandingViewRedesign/LandingViewRedesign.test.tsx b/src/app/(proper_react)/(redesign)/(public)/LandingViewRedesign/LandingViewRedesign.test.tsx new file mode 100644 index 00000000000..e5f1c085c0d --- /dev/null +++ b/src/app/(proper_react)/(redesign)/(public)/LandingViewRedesign/LandingViewRedesign.test.tsx @@ -0,0 +1,709 @@ +/* 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 { it, expect } from "@jest/globals"; +import { composeStory } from "@storybook/react"; +import { + getByRole, + getByText, + queryByText, + render, + screen, + waitFor, + within, +} from "@testing-library/react"; +import { userEvent } from "@testing-library/user-event"; +import { axe } from "jest-axe"; +import { signIn, useSession } from "next-auth/react"; +import Meta, { + LandingRedesignUs, + LandingRedesignUsScanLimit, +} from "./LandingViewRedesign.stories"; +import { useTelemetry } from "../../../../hooks/useTelemetry"; +import { deleteAllCookies } from "../../../../functions/client/deleteAllCookies"; + +jest.mock("next-auth/react", () => { + return { + signIn: jest.fn(), + useSession: jest.fn(() => { + return {}; + }), + }; +}); +jest.mock("next/navigation", () => ({ + useSearchParams: () => ({ + toString: jest.fn(), + }), + usePathname: jest.fn(), +})); + +jest.mock("../../../../hooks/useTelemetry"); + +beforeEach(() => { + // For reasons that are unclear to me, the mock implementation defind in the + // call to `jest.mock` above forgets the implementation. I've spent way too + // long debugging that already, so I'm settling for this :( + const mockedUseSession = useSession as jest.Mock; + mockedUseSession.mockReturnValue({}); + + // Make the rebrand announcement banner show up by default + deleteAllCookies(); +}); + +describe("Navigation and authentication", () => { + it("passes the axe accessibility test suite", async () => { + const ComposedLanding = composeStory(LandingRedesignUs, Meta); + const { container } = render(); + expect(await axe(container)).toHaveNoViolations(); + }); + + it("does not show a 'Sign In' button in the header if the user is signed in", () => { + const mockedUseSession = useSession as jest.Mock< + ReturnType, + Parameters + >; + mockedUseSession.mockReturnValue({ + data: { + user: { + email: "arbitrary@example.com", + }, + expires: "2023-06-18T14:48:00.000Z", + }, + status: "authenticated", + update: () => Promise.resolve(null), + }); + + const ComposedLanding = composeStory(LandingRedesignUs, Meta); + render(); + + const signInButton = screen.queryByRole("button", { + name: "Sign In", + }); + + expect(signInButton).not.toBeInTheDocument(); + }); + + it("shows a 'Sign In' button in the header if the user is not signed in", async () => { + const ComposedLanding = composeStory(LandingRedesignUs, Meta); + render(); + + const user = userEvent.setup(); + + const signInButtons = screen.getAllByRole("button", { + name: "Sign In", + }); + await user.click(signInButtons[0]); + await user.click(signInButtons[1]); + expect(signIn).toHaveBeenCalledTimes(2); + }); + + it("counts the number of clicks on the sign-in button at the top", async () => { + const mockedRecord = useTelemetry(); + const ComposedLanding = composeStory(LandingRedesignUs, Meta); + render(); + + const user = userEvent.setup(); + + const signInButtons = screen.getAllByRole("button", { + name: "Sign In", + }); + await user.click(signInButtons[0]); + await user.click(signInButtons[1]); + expect(signIn).toHaveBeenCalledTimes(2); + + expect(mockedRecord).toHaveBeenCalledWith( + "ctaButton", + "click", + expect.objectContaining({ + button_id: "sign_in", + }), + ); + }); + + it.each([ + { + name: "How it works", + id: "navbar_how_it_works", + }, + { + name: "Pricing", + id: "navbar_pricing", + }, + { + name: "FAQs", + id: "navbar_faqs", + }, + { + name: "Recent data breaches", + id: "navbar_recent_breaches", + }, + ])("counts the number of clicks %s link in top navbar", async (link) => { + const mockedRecord = useTelemetry(); + const ComposedLanding = composeStory(LandingRedesignUs, Meta); + render(); + + const user = userEvent.setup(); + + const navbarLinks = screen.getAllByRole("link", { name: link.name }); + // jsdom will complain about not being able to navigate to a different page + // after clicking the link; suppress that error, as it's not relevant to the + // test: + jest + .spyOn(console, "error") + // we expect the error to get logged twice (once for each click below) + .mockImplementationOnce(() => undefined) + .mockImplementationOnce(() => undefined); + await user.click(navbarLinks[0]); + await user.click(navbarLinks[1]); + expect(mockedRecord).toHaveBeenNthCalledWith( + 2, + "link", + "click", + expect.objectContaining({ + link_id: link.id, + }), + ); + }); +}); + +describe("FAQ", () => { + it.each([ + { + question: "What kinds of websites sell my personal information?", + answer: + "Certain websites are in the business of collecting and selling people’s personal information without their consent, which is unfortunately legal in the US. These sites are called data brokers and they make up a $240 billion dollar industry. They use sophisticated methods to collect personal, financial, location, and even health information, often without your consent or even your knowledge. They’ll sell what they’ve collected to third parties, profiting from your information and leaving you open to violations of your privacy and security.", + }, + { + question: "How does continuous data removal work?", + answer: + "Every month, we use the information you provided about yourself (name, location and birthdate) to search across ⁨190⁩ data broker sites that sell people’s private information. If we find your data on any of these sites, we initiate the request for removal. This feature is available for ⁨Monitor Plus⁩ users only.", + }, + { + question: "What exactly is a data breach?", + answer: + "A data breach happens when personal or private information gets exposed, stolen or copied without permission. These security incidents can result from cyber attacks on websites, apps or any database where people’s personal information resides. A data breach can also happen accidentally, like if someone’s login credentials get posted publicly.", + }, + { + question: "I just found out I’m in a data breach. What do I do next?", + answer: + "Visit ⁨Mozilla Monitor⁩ to learn what to do after a data breach and get guided steps to resolve exposures of your personal info. Hackers rely on people reusing passwords, so it’s important to create strong, unique passwords for all your accounts. Keep your passwords in a safe place that only you have access to; this could be the same place where you store important documents or a password manager.", + }, + { + question: "What information gets exposed in data breaches?", + answer: + "Not all breaches expose all the same info. It just depends on what hackers can access. Many data breaches expose email addresses and passwords. Others expose more sensitive information such as credit card numbers, PIN numbers, and social security numbers.", + }, + ])("opens and closes the FAQ accordion item %s", async (item) => { + const user = userEvent.setup(); + const ComposedDashboard = composeStory(LandingRedesignUs, Meta); + render(); + const faqQuestion = screen.getByRole("button", { + name: new RegExp(item.question), + }); + await user.click(faqQuestion); + const faqAnswer = screen.getByText(item.answer, { exact: false }); + expect(faqAnswer).toHaveAttribute("aria-hidden", "false"); + await user.click(faqQuestion); + expect(faqAnswer).toHaveAttribute("aria-hidden", "true"); + }); + + it("only opens one FAQ at a time", async () => { + const user = userEvent.setup(); + const ComposedDashboard = composeStory(LandingRedesignUs, Meta); + render(); + const faqQuestion1 = screen.getByRole("button", { + // Partial match to avoid the CloseIcon svg + name: new RegExp("What kinds of websites sell my personal information?"), + }); + await user.click(faqQuestion1); + const faqAnswer1 = screen.getByText( + "Certain websites are in the business of collecting and selling people’s personal information without their consent, which is unfortunately legal in the US.", + { exact: false }, + ); + expect(faqAnswer1).toHaveAttribute("aria-hidden", "false"); + const faqQuestion2 = screen.getByRole("button", { + // Partial match to avoid the CloseIcon svg + name: new RegExp("How does continuous data removal work?"), + }); + await user.click(faqQuestion2); + expect(faqAnswer1).toHaveAttribute("aria-hidden", "true"); + }); + + it("opens the read all FAQ link into a new page", async () => { + const user = userEvent.setup(); + const ComposedDashboard = composeStory(LandingRedesignUs, Meta); + render(); + + const seeAllFaqBtn = screen.getByRole("link", { name: "Read all FAQs" }); + await user.click(seeAllFaqBtn); + expect(seeAllFaqBtn).toHaveAttribute("target", "_blank"); + + // jsdom will complain about not being able to navigate to a different page + // after clicking the link; suppress that error, as it's not relevant to the + // test: + jest.spyOn(console, "error").mockImplementationOnce(() => undefined); + }); +}); + +describe("Pricing plan", () => { + it("can switch from the yearly to the monthly plan with the keyboard", async () => { + const user = userEvent.setup(); + + const ComposedDashboard = composeStory(LandingRedesignUs, Meta); + render(); + const plusCard = screen.getByLabelText("Monitor Plus"); + + // Regular expressions: + // + // \$ Starts with the character `$`, + // + // (.+?) followed by one or more (`+`) arbitrary characters (`.`), until + // the next part of the regular expression matches… + // + // All that combines to a string like "$160.44 total". + const priceRegex = /\$(.+?) total/; + expect(getByText(plusCard, priceRegex)).toBeInTheDocument(); + + const yearlyToggle = getByRole(plusCard, "radio", { name: "Yearly" }); + await user.click(yearlyToggle); + await user.keyboard("[ArrowRight][Space]"); + + expect(queryByText(plusCard, priceRegex)).not.toBeInTheDocument(); + }); + + it("can switch from the yearly to the monthly plan with the mouse", async () => { + const user = userEvent.setup(); + + const ComposedDashboard = composeStory(LandingRedesignUs, Meta); + render(); + const plusCard = screen.getByLabelText("Monitor Plus"); + + // Regular expressions: + // + // \$ Starts with the character `$`, + // + // (.+?) followed by one or more (`+`) arbitrary characters (`.`), until + // the next part of the regular expression matches… + // + // All that combines to a string like "$160.44 total". + const priceRegex = /\$(.+?) total/; + expect(getByText(plusCard, priceRegex)).toBeInTheDocument(); + + const monthlyToggle = getByRole(plusCard, "radio", { name: "Monthly" }); + await user.click(monthlyToggle); + + expect(queryByText(plusCard, priceRegex)).not.toBeInTheDocument(); + }); + + it("rounds the percentage savings of the yearly plan down (i.e. under- rather than overpromise) to whole numbers", async () => { + const user = userEvent.setup(); + + const ComposedDashboard = composeStory(LandingRedesignUs, Meta); + render(); + const plusCard = screen.getByLabelText("Monitor Plus"); + + // Regular expression: + // + // Save Starts with the characters `Save `, + // + // (.+?) followed by one or more (`+`) arbitrary characters (`.`), until + // the next part of the regular expression matches… + // + // % …which is a single `%` character. + // + // All that combines to a string like "Save 68%". + const savingsEl = getByText(plusCard, /Save (.+?)%/); + // Regular expressions: + // + // \$ Starts with the character `$`, + // + // (.+?) followed by one or more (`+`) arbitrary characters (`.`), until + // the next part of the regular expression matches… + // + // \/mo. …which consists of the characters `/mo`. + // + // All that combines to a string like "Save 13.37%". + const priceRegex = /\$(.+?)\/mo/; + const yearlyPriceEl = getByText(plusCard, priceRegex); + const yearlyPrice = Number.parseFloat( + yearlyPriceEl.textContent!.split("$")[1].split("/")[0], + ); + const monthlyToggle = getByRole(plusCard, "radio", { name: "Monthly" }); + await user.click(monthlyToggle); + const monthlyPriceEl = getByText(plusCard, priceRegex); + const monthlyPrice = Number.parseFloat( + monthlyPriceEl.textContent!.split("$")[1].split("/")[0], + ); + + expect(savingsEl.textContent).not.toMatch("."); + expect( + Number.parseInt( + savingsEl + .textContent!.split("%")[0] + .split("Save ")[1] + // Replace the special characters Fluent inserts around variables: + .replace("⁨", "") + .replace("⁩", ""), + 10, + ), + ).toBeLessThanOrEqual(((monthlyPrice - yearlyPrice) * 100) / monthlyPrice); + }); + + it("can move to the subscribe button with the keyboard", async () => { + const user = userEvent.setup(); + + const ComposedDashboard = composeStory(LandingRedesignUs, Meta); + render(); + const plusCard = screen.getByLabelText("Monitor Plus"); + + const plusSubscribeButton = getByRole(plusCard, "link", { + name: "Get ⁨Monitor Plus⁩", + }); + expect(plusSubscribeButton).not.toHaveFocus(); + + const yearlyToggle = getByRole(plusCard, "radio", { name: "Yearly" }); + await user.click(yearlyToggle); + await user.keyboard("[Tab]"); + + expect(plusSubscribeButton).toHaveFocus(); + }); + + it("can initiate sign in from the pricing plan", async () => { + const user = userEvent.setup(); + + const ComposedDashboard = composeStory(LandingRedesignUs, Meta); + render(); + const plusCard = screen.getByLabelText("Monitor Plus"); + + expect(signIn).not.toHaveBeenCalled(); + + const yearlyToggle = getByRole(plusCard, "radio", { name: "Yearly" }); + await user.click(yearlyToggle); + await user.keyboard("[Tab][Tab][Space]"); + + expect(signIn).toHaveBeenCalledTimes(1); + }); + + it("can initiate sign in from the Monitor (Free) pricing card", async () => { + const user = userEvent.setup(); + + const ComposedDashboard = composeStory(LandingRedesignUs, Meta); + render(); + const freeCard = screen.getByLabelText("Monitor"); + + expect(signIn).not.toHaveBeenCalled(); + + const signInButton = getByRole(freeCard, "button", { + name: "Get ⁨Monitor⁩ (Free)", + }); + await user.click(signInButton); + + expect(signIn).toHaveBeenCalledTimes(1); + }); + + it("counts the number of clicks on the pricing plan billing period toggle", async () => { + const mockedRecord = useTelemetry(); + const ComposedDashboard = composeStory(LandingRedesignUs, Meta); + render(); + + const user = userEvent.setup(); + const plusCard = screen.getByLabelText("Monitor Plus"); + const monthlyToggle = getByRole(plusCard, "radio", { name: "Monthly" }); + const yearlyToggle = getByRole(plusCard, "radio", { name: "Yearly" }); + + await user.click(monthlyToggle); + expect(mockedRecord).toHaveBeenCalledWith( + "button", + "click", + expect.objectContaining({ + button_id: "selected_monthly_plan", + }), + ); + + await user.click(yearlyToggle); + expect(mockedRecord).toHaveBeenCalledWith( + "button", + "click", + expect.objectContaining({ + button_id: "selected_yearly_plan", + }), + ); + }); + + it("counts the number of clicks on the pricing plan free tier button", async () => { + const mockedRecord = useTelemetry(); + const ComposedDashboard = composeStory(LandingRedesignUs, Meta); + render(); + + const user = userEvent.setup(); + const freeCard = screen.getByLabelText("Monitor"); + const freeButton = getByRole(freeCard, "button", { + name: "Get ⁨Monitor⁩ (Free)", + }); + + await user.click(freeButton); + expect(mockedRecord).toHaveBeenCalledWith( + "ctaButton", + "click", + expect.objectContaining({ + button_id: "clicked_free_pricing_card", + }), + ); + }); + + it("counts the number of clicks on the pricing plan upsell button", async () => { + const mockedRecord = useTelemetry(); + const ComposedDashboard = composeStory(LandingRedesignUs, Meta); + render(); + + const user = userEvent.setup(); + const plusCard = screen.getByLabelText("Monitor Plus"); + const monthlyToggle = getByRole(plusCard, "radio", { name: "Monthly" }); + const upsellButton = getByRole(plusCard, "link", { + name: "Get ⁨Monitor Plus⁩", + }); + // jsdom will complain about not being able to navigate to a different page + // after clicking the link; suppress that error, as it's not relevant to the + // test: + jest + .spyOn(console, "error") + .mockImplementationOnce(() => undefined) + .mockImplementationOnce(() => undefined); + + await user.click(upsellButton); + expect(mockedRecord).toHaveBeenCalledWith( + "upgradeIntent", + "click", + expect.objectContaining({ + button_id: "purchase_yearly_landing_page", + }), + ); + + await user.click(monthlyToggle); + await user.click(upsellButton); + expect(mockedRecord).toHaveBeenCalledWith( + "upgradeIntent", + "click", + expect.objectContaining({ + button_id: "purchase_monthly_landing_page", + }), + ); + }); + + it("counts the number of clicks on the pricing card billing period toggle", async () => { + const mockedRecord = useTelemetry(); + const ComposedDashboard = composeStory(LandingRedesignUs, Meta); + render(); + + const user = userEvent.setup(); + const plusCard = screen.getByLabelText("Monitor Plus"); + const monthlyToggle = getByRole(plusCard, "radio", { name: "Monthly" }); + const yearlyToggle = getByRole(plusCard, "radio", { name: "Yearly" }); + + await user.click(monthlyToggle); + expect(mockedRecord).toHaveBeenCalledWith( + "button", + "click", + expect.objectContaining({ + button_id: "selected_monthly_plan", + }), + ); + + await user.click(yearlyToggle); + expect(mockedRecord).toHaveBeenCalledWith( + "button", + "click", + expect.objectContaining({ + button_id: "selected_yearly_plan", + }), + ); + }); + + it("counts the number of clicks on the pricing card upsell button", async () => { + const mockedRecord = useTelemetry(); + const ComposedDashboard = composeStory(LandingRedesignUs, Meta); + render(); + + const user = userEvent.setup(); + const plusCard = screen.getByLabelText("Monitor Plus"); + const upsellButton = getByRole(plusCard, "link", { + name: "Get ⁨Monitor Plus⁩", + }); + const monthlyToggle = getByRole(plusCard, "radio", { name: "Monthly" }); + // jsdom will complain about not being able to navigate to a different page + // after clicking the link; suppress that error, as it's not relevant to the + // test: + jest + .spyOn(console, "error") + .mockImplementationOnce(() => undefined) + .mockImplementationOnce(() => undefined); + + await user.click(upsellButton); + expect(mockedRecord).toHaveBeenCalledWith( + "upgradeIntent", + "click", + expect.objectContaining({ + button_id: "purchase_yearly_landing_page", + }), + ); + + await user.click(monthlyToggle); + await user.click(upsellButton); + expect(mockedRecord).toHaveBeenCalledWith( + "upgradeIntent", + "click", + expect.objectContaining({ + button_id: "purchase_monthly_landing_page", + }), + ); + }); + + it("counts the number of clicks on the pricing card free button", async () => { + const mockedRecord = useTelemetry(); + const ComposedDashboard = composeStory(LandingRedesignUs, Meta); + render(); + + const user = userEvent.setup(); + const freeCard = screen.getByLabelText("Monitor"); + const freeButton = getByRole(freeCard, "button", { + name: "Get ⁨Monitor⁩ (Free)", + }); + + await user.click(freeButton); + expect(mockedRecord).toHaveBeenCalledWith( + "ctaButton", + "click", + expect.objectContaining({ + button_id: "clicked_free_pricing_card", + }), + ); + }); +}); + +describe("Scan limit reached", () => { + it("passes the axe accessibility test suite", async () => { + const ComposedLanding = composeStory(LandingRedesignUsScanLimit, Meta); + const { container } = render(); + expect(await axe(container)).toHaveNoViolations(); + }); + + it("shows the scan limit and waitlist cta when it hits the threshold", () => { + const ComposedDashboard = composeStory(LandingRedesignUsScanLimit, Meta); + render(); + + // In total there are fixe “free scan” CTAs on the landing page. + const limitDescriptions = screen.getAllByText( + "We’ve reached the maximum scans for the month. Enter your email to get on our waitlist.", + ); + expect(limitDescriptions).toHaveLength(5); + }); + + it("opens the waitlist page when the join waitlist cta is selected", async () => { + const user = userEvent.setup(); + const ComposedDashboard = composeStory(LandingRedesignUsScanLimit, Meta); + render(); + const waitlistCta = screen.getAllByRole("link", { + name: "Join waitlist", + }); + // jsdom will complain about not being able to navigate to a different page + // after clicking the link; suppress that error, as it's not relevant to the + // test: + jest.spyOn(console, "error").mockImplementationOnce(() => undefined); + + await user.click(waitlistCta[0]); + + expect(waitlistCta[0]).toHaveAttribute( + "href", + "https://www.mozilla.org/products/monitor/waitlist-scan/", + ); + }); + + it("shows the waitlist CTA when the scan limit is reached", async () => { + global.fetch = jest.fn().mockImplementation(() => + Promise.resolve({ + success: true, + json: jest.fn(() => ({ + flowData: null, + })), + }), + ); + const ComposedDashboard = composeStory(LandingRedesignUsScanLimit, Meta); + render(); + + await waitFor(() => { + const waitlistCta = screen.getAllByRole("link", { + name: "Join waitlist", + }); + expect(waitlistCta[0]).toBeInTheDocument(); + }); + }); +}); + +describe("Account deletion confirmation", () => { + it("does not show a confirmaton message if the user has just deleted their account", () => { + global.fetch = jest.fn().mockImplementation(() => + Promise.resolve({ + success: true, + json: jest.fn(() => ({ + flowData: null, + })), + }), + ); + document.cookie = "justDeletedAccount=justDeletedAccount; max-age=0"; + + const ComposedLanding = composeStory(LandingRedesignUs, Meta); + render(); + + const alert = screen.queryByRole("alert"); + + expect(alert).not.toBeInTheDocument(); + }); + + it("shows a confirmaton message if the user has just deleted their account", () => { + global.fetch = jest.fn().mockImplementation(() => + Promise.resolve({ + success: true, + json: jest.fn(() => ({ + flowData: null, + })), + }), + ); + document.cookie = "justDeletedAccount=justDeletedAccount"; + + const ComposedLanding = composeStory(LandingRedesignUs, Meta); + render(); + + const alert = screen.getByRole("alert"); + const confirmationMessage = within(alert).getByText( + "Your ⁨Monitor⁩ account is now deleted.", + ); + + expect(alert).toBeInTheDocument(); + expect(confirmationMessage).toBeInTheDocument(); + }); + + it("hides the 'account deletion' confirmation message when the user dismisses it", async () => { + global.fetch = jest.fn().mockImplementation(() => + Promise.resolve({ + success: true, + json: jest.fn(() => ({ + flowData: null, + })), + }), + ); + const user = userEvent.setup(); + document.cookie = "justDeletedAccount=justDeletedAccount"; + + const ComposedLanding = composeStory(LandingRedesignUs, Meta); + render(); + + const alert = screen.getByRole("alert"); + const dismissButton = within(alert).getByRole("button", { + name: "Dismiss", + }); + await user.click(dismissButton); + + expect(alert).not.toBeInTheDocument(); + }); +}); diff --git a/src/app/(proper_react)/(redesign)/(public)/LandingViewRedesign/components/CtaBanner.module.scss b/src/app/(proper_react)/(redesign)/(public)/LandingViewRedesign/components/CtaBanner.module.scss new file mode 100644 index 00000000000..adadf6165cf --- /dev/null +++ b/src/app/(proper_react)/(redesign)/(public)/LandingViewRedesign/components/CtaBanner.module.scss @@ -0,0 +1,25 @@ +@use "../../../../../tokens"; + +.banner { + align-items: center; + display: flex; + flex-direction: column; + gap: tokens.$layout-sm; + max-width: tokens.$content-lg; + padding: 0 tokens.$layout-sm; + text-align: center; + + &Header { + display: flex; + flex-direction: column; + gap: tokens.$spacing-sm; + + h2 { + font-weight: 600; + } + + strong { + color: tokens.$color-purple-70; + } + } +} diff --git a/src/app/(proper_react)/(redesign)/(public)/LandingViewRedesign/components/CtaBanner.tsx b/src/app/(proper_react)/(redesign)/(public)/LandingViewRedesign/components/CtaBanner.tsx new file mode 100644 index 00000000000..1d5af2d3aed --- /dev/null +++ b/src/app/(proper_react)/(redesign)/(public)/LandingViewRedesign/components/CtaBanner.tsx @@ -0,0 +1,46 @@ +/* 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 { LandingPageProps } from ".."; +import { ArrowIcon } from "../../../../../components/server/Icons"; +import { FreeScanCta } from "../../FreeScanCta"; +import { ScanLimit } from "../../ScanLimit"; +import styles from "./CtaBanner.module.scss"; + +export const CtaBanner = (props: LandingPageProps) => { + return ( +
+
+

+ + {props.l10n.getFragment("landing-redesign-banner-cta-header", { + elems: { b: }, + })} + +

+

{props.l10n.getString("landing-redesign-banner-cta-subheader")}

+
+ {props.eligibleForPremium && props.scanLimitReached ? ( + + ) : ( + + {props.l10n.getString("landing-redesign-banner-cta-button-label")} + + + } + showCtaOnly + /> + )} +
+ ); +}; diff --git a/src/app/(proper_react)/(redesign)/(public)/LandingViewRedesign/components/CtaInputBanner.module.scss b/src/app/(proper_react)/(redesign)/(public)/LandingViewRedesign/components/CtaInputBanner.module.scss new file mode 100644 index 00000000000..5a6c004e9b9 --- /dev/null +++ b/src/app/(proper_react)/(redesign)/(public)/LandingViewRedesign/components/CtaInputBanner.module.scss @@ -0,0 +1,49 @@ +@use "../../../../../tokens"; + +.banner { + background-color: tokens.$color-grey-05; + border-radius: tokens.$border-radius-xl; + display: flex; + justify-content: center; + padding: tokens.$layout-sm; + width: 100%; + + &Content { + display: flex; + flex-direction: column; + gap: tokens.$layout-sm; + max-width: tokens.$content-lg; + + form { + align-items: stretch; + display: flex; + flex-direction: column; + gap: tokens.$spacing-md; + max-width: tokens.$content-md; + } + + @media screen and (min-width: tokens.$screen-md) { + form { + flex-direction: row; + button { + flex: 0 0 auto; + } + } + } + } + + &Header { + align-items: flex-start; + flex-direction: column; + display: flex; + gap: tokens.$spacing-sm; + + h2 { + font-weight: 700; + } + + strong { + color: tokens.$color-purple-70; + } + } +} diff --git a/src/app/(proper_react)/(redesign)/(public)/LandingViewRedesign/components/CtaInputBanner.tsx b/src/app/(proper_react)/(redesign)/(public)/LandingViewRedesign/components/CtaInputBanner.tsx new file mode 100644 index 00000000000..9c621e918ce --- /dev/null +++ b/src/app/(proper_react)/(redesign)/(public)/LandingViewRedesign/components/CtaInputBanner.tsx @@ -0,0 +1,55 @@ +/* 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 { LandingPageProps } from ".."; +import { FreeScanCta } from "../../FreeScanCta"; +import { ScanLimit } from "../../ScanLimit"; +import styles from "./CtaInputBanner.module.scss"; + +export const CtaInputBanner = (props: LandingPageProps) => { + return ( +
+
+
+

+ + {props.l10n.getFragment( + "landing-redesign-cta-input-banner-header", + { + elems: { b: }, + }, + )} + +

+

+ {props.l10n.getString( + "landing-redesign-cta-input-banner-subheader", + )} +

+
+ {props.eligibleForPremium && props.scanLimitReached ? ( + + ) : ( + + )} +
+
+ ); +}; diff --git a/src/app/(proper_react)/(redesign)/(public)/LandingViewRedesign/components/Faq.module.scss b/src/app/(proper_react)/(redesign)/(public)/LandingViewRedesign/components/Faq.module.scss new file mode 100644 index 00000000000..de8bbcc4661 --- /dev/null +++ b/src/app/(proper_react)/(redesign)/(public)/LandingViewRedesign/components/Faq.module.scss @@ -0,0 +1,104 @@ +@use "../../../../../tokens"; + +.content { + align-items: center; + border-radius: tokens.$border-radius-lg; + box-shadow: inset 0 0 0 4px tokens.$color-grey-10; + display: flex; + flex-direction: column; + gap: tokens.$spacing-md; + max-width: tokens.$content-lg; + padding: tokens.$layout-xs; + width: 100%; + + @media screen and (min-width: tokens.$screen-sm) { + gap: tokens.$spacing-lg; + padding: tokens.$layout-sm; + } + + @media screen and (min-width: tokens.$screen-md) { + gap: tokens.$spacing-xl; + padding: tokens.$layout-md; + } +} + +.faqList { + display: flex; + flex-direction: column; + width: 100%; + + dd { + background: tokens.$color-white; + border-radius: 0 0 tokens.$border-radius-lg tokens.$border-radius-lg; + border: 2px solid tokens.$color-grey-10; + border-top: none; + } + + dt { + border-radius: tokens.$border-radius-lg; + border: 2px solid tokens.$color-grey-10; + margin-top: tokens.$spacing-md; + + &:focus-within { + border-color: tokens.$color-purple-70; + + & + dd { + border-color: tokens.$color-purple-70; + } + } + + &:has([aria-expanded="true"]) { + border-radius: tokens.$border-radius-lg tokens.$border-radius-lg 0 0; + border-bottom: none; + } + } + + .faqQuestion { + align-items: center; + background: none; + border: none; + color: inherit; + cursor: pointer; + display: flex; + flex-direction: row; + font: tokens.$text-body-lg; + font-weight: 600; + gap: tokens.$layout-xs; + justify-content: space-between; + padding: tokens.$spacing-md; + text-align: start; + width: 100%; + + @media screen and (min-width: tokens.$screen-md) { + gap: tokens.$layout-md; + padding: tokens.$spacing-lg; + } + + svg { + color: tokens.$color-black; + flex: 1 0 16px; + max-width: 16px; + } + + &:focus { + outline: none; + } + } + + .faqAnswer { + display: none; + height: 0; + padding: tokens.$spacing-sm tokens.$layout-xs tokens.$spacing-md + tokens.$spacing-md; + + @media screen and (min-width: tokens.$screen-md) { + padding: tokens.$spacing-sm tokens.$layout-lg tokens.$spacing-lg + tokens.$spacing-lg; + } + + &.expanded { + display: block; + height: auto; + } + } +} diff --git a/src/app/(proper_react)/(redesign)/(public)/LandingViewRedesign/components/Faq.tsx b/src/app/(proper_react)/(redesign)/(public)/LandingViewRedesign/components/Faq.tsx new file mode 100644 index 00000000000..ec44362bfb5 --- /dev/null +++ b/src/app/(proper_react)/(redesign)/(public)/LandingViewRedesign/components/Faq.tsx @@ -0,0 +1,183 @@ +/* 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/. */ + +"use client"; + +import { ReactNode, useRef, useState } from "react"; +import { useL10n } from "../../../../../hooks/l10n"; +import { useButton, useFocusRing } from "react-aria"; +import { useTelemetry } from "../../../../../hooks/useTelemetry"; +import { Button } from "../../../../../components/client/Button"; +import { TelemetryButton } from "../../../../../components/client/TelemetryButton"; +import { + CONST_ONEREP_DATA_BROKER_COUNT, + CONST_URL_SUMO_MONITOR_FAQ, + CONST_URL_SUMO_MONITOR_PLUS, +} from "../../../../../../constants"; +import { + ChevronDown, + CloseBigIcon, +} from "../../../../../components/server/Icons"; +import styles from "./Faq.module.scss"; + +export type FaqItemProps = { + question: string; + answer: string | ReactNode; + isExpanded: boolean; + id: string; + onExpandAnswer: () => void; +}; + +const FaqItem = (props: FaqItemProps) => { + const l10n = useL10n(); + const buttonRef = useRef(null); + + const { buttonProps } = useButton( + { + onPress: props.onExpandAnswer, + }, + buttonRef, + ); + const { focusProps } = useFocusRing(); + + return ( + <> +
+ +
+
+ {props.answer} +
+ + ); +}; + +export const Faq = () => { + const l10n = useL10n(); + const recordTelemetry = useTelemetry(); + const [expandedQuestion, setExpandedQuestion] = useState(null); + + const faqItems: FaqItemProps[] = [ + { + id: "premium-what-websites-sell-info", + question: l10n.getString("landing-premium-what-websites-sell-info-qn"), + answer: l10n.getString("landing-premium-what-websites-sell-info-ans"), + isExpanded: expandedQuestion === "premium-what-websites-sell-info", + onExpandAnswer: () => { + handleExpandAnswer("premium-what-websites-sell-info"); + }, + }, + { + id: "premium-continuous-data-removal", + question: l10n.getString("landing-premium-continuous-data-removal-qn"), + answer: l10n.getFragment("landing-premium-continuous-data-removal-ans", { + vars: { + data_broker_sites_total_num: CONST_ONEREP_DATA_BROKER_COUNT, + }, + elems: { + learn_more_link: ( + - {props.isHero ? ( - labelContent - ) : ( - {labelContent} - )} ); }; diff --git a/src/app/components/client/BillingPeriod.module.scss b/src/app/components/client/BillingPeriod.module.scss index a35ecc2c486..b4fd11449ad 100644 --- a/src/app/components/client/BillingPeriod.module.scss +++ b/src/app/components/client/BillingPeriod.module.scss @@ -1,32 +1,40 @@ @use "../../tokens"; .toggleContainer { - background-color: tokens.$color-grey-10; + background-color: tokens.$color-grey-05; border-radius: tokens.$border-radius-xl; - border: 1px solid tokens.$color-grey-30; - padding: tokens.$spacing-sm; + border: 2px solid tokens.$color-purple-70; + padding: tokens.$spacing-xs; display: flex; align-items: center; - gap: tokens.$spacing-sm; + align-self: center; + gap: tokens.$spacing-xs; + + &:focus-within { + outline: tokens.$border-focus-width solid tokens.$color-purple-10; + } .option { border-radius: tokens.$border-radius-xl; - padding: tokens.$spacing-xs tokens.$spacing-md; + // Add a bit more space: The next spacing step is too much. + padding: tokens.$spacing-sm calc(tokens.$spacing-md * 1.5); + line-height: 1.25em; font-weight: 500; color: tokens.$color-grey-50; cursor: pointer; &.isFocused { - outline: 4px solid tokens.$color-purple-70; background-color: tokens.$color-purple-10; } + &:hover { background-color: tokens.$color-purple-20; } &.isSelected { - background-color: tokens.$color-white; - color: tokens.$color-blue-50; + background-color: tokens.$color-purple-40; + color: tokens.$color-white; + font-weight: 600; } } } diff --git a/src/app/components/client/Button.module.scss b/src/app/components/client/Button.module.scss index a704a5810db..0cc29549f04 100644 --- a/src/app/components/client/Button.module.scss +++ b/src/app/components/client/Button.module.scss @@ -1,14 +1,17 @@ @use "../../tokens"; .button { + align-items: center; font: tokens.$text-body-md; - font-weight: 500; + font-weight: 600; border: 0; padding: tokens.$spacing-md tokens.$spacing-lg; border-radius: tokens.$border-radius-md; cursor: pointer; - display: inline-block; + display: inline-flex; + gap: tokens.$spacing-sm; line-height: 1; + justify-content: center; text-align: center; text-decoration: none; @@ -87,7 +90,6 @@ } &.secondary { - font-weight: 400; color: tokens.$color-purple-70; box-shadow: inset 0 0 0 2px tokens.$color-purple-70; background-color: transparent; diff --git a/src/app/components/client/InputField.module.scss b/src/app/components/client/InputField.module.scss index 105ad834f23..612e84f539f 100644 --- a/src/app/components/client/InputField.module.scss +++ b/src/app/components/client/InputField.module.scss @@ -2,9 +2,10 @@ .input, .comboBox { - background: tokens.$color-white; + border-radius: tokens.$border-radius-md; display: flex; flex-direction: column; + gap: tokens.$spacing-sm; position: relative; .inputFieldWrapper { @@ -14,8 +15,10 @@ .floatingLabel { background: tokens.$color-white; border-radius: tokens.$border-radius-sm; + backface-visibility: hidden; color: tokens.$color-black; display: inline-block; + font-weight: 400; // Add a bit more space: The next spacing step is too much. left: calc(tokens.$spacing-sm * 1.5); line-height: 1em; @@ -25,16 +28,19 @@ top: 50%; transform-origin: top left; transform: translate(-0.05em, -50%) scale(1); - transition: transform 0.2s ease-in-out; + transition: + transform 0.1s ease-in-out, + color 0.1s ease-in-out; user-select: none; } .inputField { + background: tokens.$color-white; border: 1px solid tokens.$color-grey-30; - border-radius: tokens.$border-radius-sm; + border-radius: tokens.$border-radius-md; color: tokens.$color-black; // Add a bit more space: The next spacing step is too much. - padding: calc(tokens.$spacing-sm * 1.5); + padding: tokens.$spacing-md calc(tokens.$spacing-sm * 1.5); width: 100%; &.noValue { @@ -51,9 +57,8 @@ } &:focus { - border: 1px solid tokens.$color-informational-active; - outline: tokens.$border-focus-width solid - tokens.$color-informational-focus; + border: 1px solid tokens.$color-purple-70; + outline: tokens.$border-focus-width solid tokens.$color-purple-40; } &:disabled { @@ -65,11 +70,12 @@ &:has(.floatingLabel) { ::placeholder { @include tokens.visually-hidden; + color: transparent; } .inputField { // Move the value string off-center. - padding: calc(tokens.$spacing-md * 1.25) calc(tokens.$spacing-sm * 1.5) - tokens.$spacing-xs; + padding: calc(tokens.$spacing-md * 1.35) calc(tokens.$spacing-sm * 1.5) + calc(tokens.$spacing-md * 0.65); } } @@ -78,13 +84,12 @@ .floatingLabel { color: tokens.$color-grey-40; // Make the floating label visually align with the input value. - transform: translate(-0.05em, -115%) scale(0.75); + transform: translate(-0.05em, -150%) scale(0.85); } } .inputLabel { font-weight: 600; - margin-bottom: tokens.$spacing-sm; } .buttonIcon { @@ -103,7 +108,7 @@ align-items: center; color: tokens.$color-grey-40; gap: tokens.$spacing-xs; - background: inherit; + background: tokens.$color-white; border-radius: tokens.$border-radius-sm; bottom: 0; font: tokens.$text-body-xs; diff --git a/src/app/components/server/Icons.tsx b/src/app/components/server/Icons.tsx index 1ba3a0954de..0073c753d37 100644 --- a/src/app/components/server/Icons.tsx +++ b/src/app/components/server/Icons.tsx @@ -598,6 +598,73 @@ export const ClockIcon = (props: SVGProps & { alt: string }) => { ); }; +// Keywords: time, estimated, watch, big +export const ClockIconBig = ( + props: SVGProps & { alt: string }, +) => { + return ( + + {props.alt} + + + + ); +}; + +// Keywords: shield, secure, protected +export const ShieldIconBig = ( + props: SVGProps & { alt: string }, +) => { + return ( + + {props.alt} + + + ); +}; + +// Keywords: heart, love, like +export const HeartIconBig = ( + props: SVGProps & { alt: string }, +) => { + return ( + + {props.alt} + + + ); +}; + // Keywords: completed, tick export const CheckIcon = (props: SVGProps & { alt: string }) => { return (