diff --git a/config/nimbus.yaml b/config/nimbus.yaml index 60cb8573ac1..807c3e37d63 100644 --- a/config/nimbus.yaml +++ b/config/nimbus.yaml @@ -101,6 +101,33 @@ features: value: { "enabled": true } - channel: production value: { "enabled": true } + landing-page-redesign-plus-eligible-experiment: + description: Landing page redesign + variables: + enabled: + description: If the feature is enabled + type: Boolean + default: false + variant: + description: The landing page variant to show + type: LandingPageVariant + default: default + defaults: + - channel: local + value: { + "enabled": true, + "variant": redesign, + } + - channel: staging + value: { + "enabled": false, + "variant": default, + } + - channel: production + value: { + "enabled": false, + "variant": default, + } enums: OptionalBrokerScanInfoFields: description: An enum of optional broker scan info fields @@ -122,3 +149,10 @@ enums: description: Only show a CTA button with the label “Get free scan” ctaOnlyAlternativeLabel: description: Only show a CTA button with the label “Sign up to get free scan” + LandingPageVariant: + description: An enum of landing page variants + variants: + default: + description: Show the default landing page + redesign: + description: Show the redesigned landing page diff --git a/src/app/(proper_react)/(redesign)/(public)/LandingView.module.scss b/src/app/(proper_react)/(redesign)/(public)/LandingView.module.scss index ed07350d152..0b9b13d4bac 100644 --- a/src/app/(proper_react)/(redesign)/(public)/LandingView.module.scss +++ b/src/app/(proper_react)/(redesign)/(public)/LandingView.module.scss @@ -5,6 +5,28 @@ flex-direction: column; height: 100%; } + +.waitlistSection { + display: flex; + flex-direction: column; + align-items: center; + gap: tokens.$spacing-lg; + padding: tokens.$layout-md tokens.$spacing-md; + + .waitlistTitle { + text-align: center; + font: tokens.$text-title-2xs; + font-weight: 600; + line-height: 1.4; + font-family: var(--font-inter); + color: tokens.$color-purple-70; + } + + a { + align-self: center; + } +} + .navbar { font: tokens.$text-body-xl; padding: tokens.$layout-xs; @@ -12,28 +34,28 @@ flex-direction: row; justify-content: flex-start; background-color: tokens.$color-grey-05; - .navbarLinksContainer { + ul { display: flex; flex-direction: column; - .navbarLinks { + a { text-decoration: none; color: tokens.$color-grey-50; } } @media screen and (min-width: tokens.$screen-sm) { justify-content: flex-end; - .navbarLinksContainer { + ul { flex-direction: row; justify-content: flex-end; - .navbarLinks { + a { margin-left: tokens.$spacing-lg; } } } @media screen and (min-width: tokens.$screen-md) { padding: tokens.$layout-xs tokens.$layout-xl; - .navbarLinksContainer { - .navbarLinks { + ul { + a { margin-left: tokens.$spacing-xl; } } @@ -396,24 +418,3 @@ align-self: center; } } - -.waitlistSection { - display: flex; - flex-direction: column; - align-items: center; - gap: tokens.$spacing-lg; - padding: tokens.$layout-md tokens.$spacing-md; - - .waitlistTitle { - text-align: center; - font: tokens.$text-title-2xs; - font-weight: 600; - line-height: 1.4; - font-family: var(--font-inter); - color: tokens.$color-purple-70; - } - - a { - align-self: center; - } -} diff --git a/src/app/(proper_react)/(redesign)/(public)/LandingView.stories.tsx b/src/app/(proper_react)/(redesign)/(public)/LandingView.stories.tsx index 4ebb5c93966..5d297bf87e2 100644 --- a/src/app/(proper_react)/(redesign)/(public)/LandingView.stories.tsx +++ b/src/app/(proper_react)/(redesign)/(public)/LandingView.stories.tsx @@ -11,7 +11,7 @@ import { AccountsMetricsFlowProvider } from "../../../../contextProviders/accoun import { CONST_URL_MONITOR_LANDING_PAGE_ID } from "../../../../constants"; const meta: Meta = { - title: "Pages/Public/Landing page", + title: "Pages/Public/Landing page/Default", component: (props: ViewProps) => { const experimentData = props.experimentData ?? defaultExperimentData["Features"]; @@ -31,7 +31,18 @@ const meta: Meta = { service: process.env.OAUTH_CLIENT_ID as string, }} > - + diff --git a/src/app/(proper_react)/(redesign)/(public)/LandingView.tsx b/src/app/(proper_react)/(redesign)/(public)/LandingView.tsx index f27b479acdc..315719a3216 100644 --- a/src/app/(proper_react)/(redesign)/(public)/LandingView.tsx +++ b/src/app/(proper_react)/(redesign)/(public)/LandingView.tsx @@ -26,9 +26,10 @@ import { TelemetryLink } from "../../../components/client/TelemetryLink"; import { HeresHowWeHelp } from "./HeresHowWeHelp"; import { ScanLimit } from "./ScanLimit"; import { FaqSection } from "./Faq"; -import { AccountDeletionNotification } from "./AccountDeletionNotification"; import { ExperimentData } from "../../../../telemetry/generated/nimbus/experiments"; import { FreeScanCta } from "./FreeScanCta"; +import { TopNavBar } from "./TopNavBar"; +import { AccountDeletionNotification } from "./AccountDeletionNotification"; export type Props = { eligibleForPremium: boolean; @@ -43,7 +44,11 @@ export const View = (props: Props) => { <>
- {props.eligibleForPremium && } + {props.eligibleForPremium && ( +
+ +
+ )}

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

@@ -258,52 +263,6 @@ export const View = (props: Props) => { ); }; -export const TopNavBar = ({ l10n }: { l10n: ExtendedReactLocalization }) => { - return ( -
-
- - {l10n.getString("landing-premium-hero-navbar-link-how-it-works")} - - - {l10n.getString("landing-premium-hero-navbar-link-pricing")} - - - {l10n.getString("landing-premium-hero-navbar-link-faqs")} - - - {l10n.getString("landing-premium-hero-navbar-link-recent-breaches")} - -
-
- ); -}; - const HeroImage = (props: Props) => { if (!props.eligibleForPremium) { return ( diff --git a/src/app/(proper_react)/(redesign)/(public)/LandingViewRedesign.module.scss b/src/app/(proper_react)/(redesign)/(public)/LandingViewRedesign.module.scss new file mode 100644 index 00000000000..a56803ec8bf --- /dev/null +++ b/src/app/(proper_react)/(redesign)/(public)/LandingViewRedesign.module.scss @@ -0,0 +1,7 @@ +@import "../../../tokens"; + +.wrapper { + display: flex; + flex-direction: column; + height: 100%; +} diff --git a/src/app/(proper_react)/(redesign)/(public)/LandingViewRedesign.stories.tsx b/src/app/(proper_react)/(redesign)/(public)/LandingViewRedesign.stories.tsx new file mode 100644 index 00000000000..24f6e7a47a4 --- /dev/null +++ b/src/app/(proper_react)/(redesign)/(public)/LandingViewRedesign.stories.tsx @@ -0,0 +1,102 @@ +/* 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 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"; + +const meta: Meta = { + title: "Pages/Public/Landing page/Redesign", + component: (props: ViewProps) => { + const experimentData = + props.experimentData ?? defaultExperimentData["Features"]; + return ( + + + + + + ); + }, + args: { + l10n: getL10n(), + }, +}; + +export default meta; +type Story = StoryObj; + +export const LandingRedesignUs: Story = { + name: "US visitors", + args: { + eligibleForPremium: true, + countryCode: "us", + scanLimitReached: false, + }, +}; + +export const LandingRedesignUsScanLimit: Story = { + name: "US visitors - Scan limit reached", + args: { + eligibleForPremium: true, + countryCode: "us", + 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.test.tsx b/src/app/(proper_react)/(redesign)/(public)/LandingViewRedesign.test.tsx new file mode 100644 index 00000000000..781f1d8ae96 --- /dev/null +++ b/src/app/(proper_react)/(redesign)/(public)/LandingViewRedesign.test.tsx @@ -0,0 +1,235 @@ +/* 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 new file mode 100644 index 00000000000..0ed9bed03de --- /dev/null +++ b/src/app/(proper_react)/(redesign)/(public)/LandingViewRedesign.tsx @@ -0,0 +1,27 @@ +/* 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)/PublicShell.module.scss b/src/app/(proper_react)/(redesign)/(public)/PublicShell.module.scss index 6061313f401..8ec033604b9 100644 --- a/src/app/(proper_react)/(redesign)/(public)/PublicShell.module.scss +++ b/src/app/(proper_react)/(redesign)/(public)/PublicShell.module.scss @@ -26,7 +26,7 @@ display: flex; align-items: center; justify-content: space-between; - padding: tokens.$spacing-md; + padding: tokens.$spacing-lg tokens.$layout-xl; background-color: tokens.$color-white; gap: tokens.$spacing-lg; @@ -44,8 +44,26 @@ flex-shrink: 0; } - @media screen and (min-width: tokens.$screen-md) { - padding: tokens.$spacing-lg tokens.$layout-xl; + &.navDesktop { + ul { + display: flex; + + a { + color: tokens.$color-black; + text-decoration: none; + font-weight: 600; + padding: tokens.$spacing-sm tokens.$spacing-md; + + &:hover { + background-color: tokens.$color-purple-70; + text-decoration: underline; + } + } + } + + @media screen and (max-width: tokens.$screen-xl) { + display: none; + } } } diff --git a/src/app/(proper_react)/(redesign)/(public)/PublicShell.stories.ts b/src/app/(proper_react)/(redesign)/(public)/PublicShell.stories.ts index 87dd3f7ae6f..76cb9698157 100644 --- a/src/app/(proper_react)/(redesign)/(public)/PublicShell.stories.ts +++ b/src/app/(proper_react)/(redesign)/(public)/PublicShell.stories.ts @@ -18,5 +18,6 @@ export const PublicShellStory: Story = { args: { countryCode: "us", l10n: getL10n("en"), + enabledFeatureFlags: [], }, }; diff --git a/src/app/(proper_react)/(redesign)/(public)/PublicShell.tsx b/src/app/(proper_react)/(redesign)/(public)/PublicShell.tsx index b697d76c792..50c7f7d205f 100644 --- a/src/app/(proper_react)/(redesign)/(public)/PublicShell.tsx +++ b/src/app/(proper_react)/(redesign)/(public)/PublicShell.tsx @@ -12,24 +12,69 @@ import { SignInButton } from "../../../components/client/SignInButton"; import { Footer } from "../Footer"; import { ToastContainer } from "react-toastify"; import "react-toastify/dist/ReactToastify.css"; +import { MobileShell } from "../MobileShell"; +import { + getPremiumSubscriptionUrl, + getSubscriptionBillingAmount, +} from "../../../functions/server/getPremiumSubscriptionInfo"; +import { FeatureFlagName } from "../../../../db/tables/featureFlags"; +import { TopNavBar } from "./TopNavBar"; +import { ExperimentData } from "../../../../telemetry/generated/nimbus/experiments"; export type Props = { children: ReactNode; l10n: ExtendedReactLocalization; countryCode: string; + enabledFeatureFlags: FeatureFlagName[]; + experimentData: ExperimentData["Features"]; +}; + +const PublicMobileShell = ( + props: Props & { + hasLandingPageRedesign: boolean; + }, +) => { + if (props.hasLandingPageRedesign) { + return ( + + {props.children} + + ); + } + + return props.children; }; export const PublicShell = (props: Props) => { + const hasLandingPageRedesign = + props.enabledFeatureFlags.includes("LandingPageRedesign") && + props.experimentData["landing-page-redesign-plus-eligible-experiment"] + .enabled && + props.experimentData["landing-page-redesign-plus-eligible-experiment"] + .variant === "redesign"; return ( -
- -
-
-
{props.children}
-
-
+
{props.children}
+
+
+ ); }; diff --git a/src/app/(proper_react)/(redesign)/(public)/TopNavBar.tsx b/src/app/(proper_react)/(redesign)/(public)/TopNavBar.tsx new file mode 100644 index 00000000000..bce2997a65b --- /dev/null +++ b/src/app/(proper_react)/(redesign)/(public)/TopNavBar.tsx @@ -0,0 +1,57 @@ +/* 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 { TelemetryLink } from "../../../components/client/TelemetryLink"; +import { useL10n } from "../../../hooks/l10n"; + +export const TopNavBar = () => { + const l10n = useL10n(); + return ( +
    +
  • + + {l10n.getString("landing-premium-hero-navbar-link-how-it-works")} + +
  • +
  • + + {l10n.getString("landing-premium-hero-navbar-link-pricing")} + +
  • +
  • + + {l10n.getString("landing-premium-hero-navbar-link-faqs")} + +
  • +
  • + + {l10n.getString("landing-premium-hero-navbar-link-recent-breaches")} + +
  • +
+ ); +}; diff --git a/src/app/(proper_react)/(redesign)/(public)/breach-details/[breachName]/BreachDetailView.stories.tsx b/src/app/(proper_react)/(redesign)/(public)/breach-details/[breachName]/BreachDetailView.stories.tsx index 2adf3435325..aae660f0ab8 100644 --- a/src/app/(proper_react)/(redesign)/(public)/breach-details/[breachName]/BreachDetailView.stories.tsx +++ b/src/app/(proper_react)/(redesign)/(public)/breach-details/[breachName]/BreachDetailView.stories.tsx @@ -7,11 +7,23 @@ import { BreachDetailsView, Props as ViewProps } from "./BreachDetailView"; import { getL10n } from "../../../../../functions/l10n/storybookAndJest"; import { PublicShell } from "../../PublicShell"; import { createRandomHibpListing } from "../../../../../../apiMocks/mockData"; +import { defaultExperimentData } from "../../../../../../telemetry/generated/nimbus/experiments"; const meta: Meta = { title: "Pages/Public/Breach listing", component: (props: ViewProps) => ( - + ), diff --git a/src/app/(proper_react)/(redesign)/(public)/breaches/BreachIndexView.stories.tsx b/src/app/(proper_react)/(redesign)/(public)/breaches/BreachIndexView.stories.tsx index de5b7cc67b2..42375a74f58 100644 --- a/src/app/(proper_react)/(redesign)/(public)/breaches/BreachIndexView.stories.tsx +++ b/src/app/(proper_react)/(redesign)/(public)/breaches/BreachIndexView.stories.tsx @@ -8,11 +8,23 @@ import { BreachIndexView, Props as ViewProps } from "./BreachIndexView"; import { getL10n } from "../../../../functions/l10n/storybookAndJest"; import { PublicShell } from "../PublicShell"; import { createRandomHibpListing } from "../../../../../apiMocks/mockData"; +import { defaultExperimentData } from "../../../../../telemetry/generated/nimbus/experiments"; const meta: Meta = { title: "Pages/Public/Breach index", component: (props: ViewProps) => ( - + ), diff --git a/src/app/(proper_react)/(redesign)/(public)/how-it-works/HowItWorksView.stories.tsx b/src/app/(proper_react)/(redesign)/(public)/how-it-works/HowItWorksView.stories.tsx index 955d62293e6..6c8a979747a 100644 --- a/src/app/(proper_react)/(redesign)/(public)/how-it-works/HowItWorksView.stories.tsx +++ b/src/app/(proper_react)/(redesign)/(public)/how-it-works/HowItWorksView.stories.tsx @@ -6,11 +6,23 @@ import type { Meta, StoryObj } from "@storybook/react"; import { HowItWorksView } from "./HowItWorksView"; import { getL10n } from "../../../../functions/l10n/storybookAndJest"; import { PublicShell } from "../PublicShell"; +import { defaultExperimentData } from "../../../../../telemetry/generated/nimbus/experiments"; const meta: Meta = { title: "Pages/Public/HowItWorks page", component: () => ( - + + {props.children} ); diff --git a/src/app/(proper_react)/(redesign)/(public)/page.tsx b/src/app/(proper_react)/(redesign)/(public)/page.tsx index ed109dd3c01..92e0960be44 100644 --- a/src/app/(proper_react)/(redesign)/(public)/page.tsx +++ b/src/app/(proper_react)/(redesign)/(public)/page.tsx @@ -12,7 +12,8 @@ import { } from "../../../functions/server/onerep"; import { isEligibleForPremium } from "../../../functions/universal/premium"; import { getL10n } from "../../../functions/l10n/serverComponents"; -import { View } from "./LandingView"; +import { View as LandingView } from "./LandingView"; +import { View as LandingViewRedesign } from "./LandingViewRedesign"; import { CONST_DAY_MILLISECONDS, CONST_URL_MONITOR_LANDING_PAGE_ID, @@ -21,6 +22,7 @@ import { getExperimentationId } from "../../../functions/server/getExperimentati import { getExperiments } from "../../../functions/server/getExperiments"; import { getLocale } from "../../../functions/universal/getLocale"; import { AccountsMetricsFlowProvider } from "../../../../contextProviders/accounts-metrics-flow"; +import { getEnabledFeatureFlags } from "../../../../db/tables/featureFlags"; type Props = { searchParams: { @@ -36,6 +38,9 @@ export default async function Page({ searchParams }: Props) { const countryCode = getCountryCode(headers()); const eligibleForPremium = isEligibleForPremium(countryCode); + const enabledFeatureFlags = await getEnabledFeatureFlags({ + isSignedOut: true, + }); const experimentationId = getExperimentationId(session?.user ?? null); const experimentData = await getExperiments({ experimentationId, @@ -68,13 +73,29 @@ export default async function Page({ searchParams }: Props) { service: process.env.OAUTH_CLIENT_ID as string, }} > - + {enabledFeatureFlags.includes("LandingPageRedesign") && + experimentData["Features"][ + "landing-page-redesign-plus-eligible-experiment" + ].enabled && + experimentData["Features"][ + "landing-page-redesign-plus-eligible-experiment" + ].variant === "redesign" ? ( + + ) : ( + + )} ); } diff --git a/src/app/(proper_react)/(redesign)/MobileShell.module.scss b/src/app/(proper_react)/(redesign)/MobileShell.module.scss index 84955521f5f..afe04bab146 100644 --- a/src/app/(proper_react)/(redesign)/MobileShell.module.scss +++ b/src/app/(proper_react)/(redesign)/MobileShell.module.scss @@ -12,10 +12,10 @@ @media screen and (max-width: tokens.$screen-xl) { position: sticky; background-color: tokens.$color-white; - box-shadow: tokens.$box-shadow-sm; width: 100%; display: flex; align-items: center; + justify-content: space-between; gap: tokens.$spacing-sm; padding: tokens.$spacing-xs tokens.$spacing-sm; // Overlay `.nonHeader` so that the box-shadow of this element @@ -23,43 +23,37 @@ z-index: 1; .headerStart { - flex: 0 0 20%; display: flex; align-items: center; justify-content: flex-start; - - .menuToggleButton { - display: flex; - align-items: center; - justify-content: center; - background-color: transparent; - border-style: none; - padding: tokens.$spacing-md; - cursor: pointer; - - svg { - width: auto; - height: 16px; - } - - &:hover svg { - color: tokens.$color-blue-50; - } - } + padding-left: tokens.$spacing-md; } - .headerMiddle { - flex: 1 0 auto; + .headerEnd { + align-self: flex-end; display: flex; align-items: center; - justify-content: center; + gap: tokens.$spacing-sm; + justify-content: flex-end; } - .headerEnd { - flex: 0 0 20%; + .menuToggleButton { display: flex; align-items: center; - justify-content: flex-end; + justify-content: center; + background-color: transparent; + border-style: none; + padding: tokens.$spacing-md; + cursor: pointer; + + svg { + width: auto; + height: 16px; + } + + &:hover svg { + color: tokens.$color-purple-70; + } } } } @@ -92,7 +86,7 @@ top: 0; height: 0; // Overlap .content - z-index: 1; + z-index: 2; .mainMenu { align-items: center; @@ -102,62 +96,52 @@ flex-direction: column; ul { + border-bottom: 1px solid tokens.$color-grey-10; list-style-type: none; padding: 0; width: 100%; - } - a, - a:visited { - display: block; - padding: tokens.$spacing-sm tokens.$spacing-lg; - color: tokens.$color-grey-40; - font-weight: 500; - border-top: 1px solid tokens.$color-grey-10; - text-decoration: none; - - &.isActive { - color: tokens.$color-purple-70; - } - - &:hover { - background-color: tokens.$color-purple-50; - color: tokens.$color-white; - text-decoration: underline; - } - - // The `a` and `a:visited` violate this rule, but are safe: - // stylelint-disable-next-line no-descending-specificity - &:focus { - background-color: tokens.$color-blue-50; - color: tokens.$color-white; - outline: none; - } - } - - .subMenu { - padding-left: tokens.$layout-xs; - - // The `a` and `a:visited` violate this rule, but are safe: - // stylelint-disable-next-line no-descending-specificity - a, - a:visited { - border-top: none; + a { + align-items: center; + display: flex; + padding: calc(tokens.$spacing-sm * 1.5) tokens.$spacing-lg; + gap: tokens.$spacing-sm; + color: tokens.$color-black; + border-top: 1px solid tokens.$color-grey-10; + text-decoration: none; &.isActive { - color: tokens.$color-purple-40; + color: tokens.$color-purple-70; text-decoration: underline; - - &:hover { - color: tokens.$color-purple-70; - } } &:hover { - color: tokens.$color-grey-40; - background: none; + background-color: tokens.$color-purple-50; + color: tokens.$color-white; text-decoration: underline; } + + // The `a` and `a:visited` violate this rule, but are safe: + // stylelint-disable-next-line no-descending-specificity + &:focus { + background-color: tokens.$color-grey-10; + outline: none; + text-decoration: underline; + } + + & > svg { + fill: tokens.$color-purple-70; + } + } + + &.subMenu { + border-bottom: none; + + a { + font-weight: 400; + padding: calc(tokens.$spacing-sm * 1.5) tokens.$spacing-2xl; + border-top: none; + } } } } diff --git a/src/app/(proper_react)/(redesign)/(public)/MobileShell.stories.ts b/src/app/(proper_react)/(redesign)/MobileShell.stories.ts similarity index 77% rename from src/app/(proper_react)/(redesign)/(public)/MobileShell.stories.ts rename to src/app/(proper_react)/(redesign)/MobileShell.stories.ts index cc0dda1dafe..fba5a9b74c6 100644 --- a/src/app/(proper_react)/(redesign)/(public)/MobileShell.stories.ts +++ b/src/app/(proper_react)/(redesign)/MobileShell.stories.ts @@ -3,10 +3,10 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { Session } from "next-auth"; -import { SerializedSubscriber } from "../../../../next-auth"; +import { SerializedSubscriber } from "../../../next-auth"; import type { Meta, StoryObj } from "@storybook/react"; -import { MobileShell } from "../MobileShell"; +import { MobileShell } from "./MobileShell"; function createUser(): Session["user"] { return { @@ -37,9 +37,17 @@ const meta: Meta = { export default meta; type Story = StoryObj; -export const MobileShellStory: Story = { +export const MobileShellPublic: Story = { + args: { + countryCode: "us", + session: null, + }, +}; + +export const MobileShellAuthenticated: Story = { args: { countryCode: "us", session: mockedSession, + enabledFeatureFlags: [], }, }; diff --git a/src/app/(proper_react)/(redesign)/MobileShell.test.tsx b/src/app/(proper_react)/(redesign)/MobileShell.test.tsx new file mode 100644 index 00000000000..21a5bc7b8e1 --- /dev/null +++ b/src/app/(proper_react)/(redesign)/MobileShell.test.tsx @@ -0,0 +1,87 @@ +/* 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 { render, screen } from "@testing-library/react"; +import { composeStory } from "@storybook/react"; +import { axe } from "jest-axe"; +import Meta, { + MobileShellAuthenticated, + MobileShellPublic, +} from "./MobileShell.stories"; +import { userEvent } from "@testing-library/user-event"; + +describe("MobileShell public", () => { + it("passes the axe accessibility test suite", async () => { + const ComposedMobileShell = composeStory(MobileShellPublic, Meta); + const { container } = render(); + expect(await axe(container)).toHaveNoViolations(); + }); + + it("shows the sign-in button", () => { + const ComposedMobileShell = composeStory(MobileShellPublic, Meta); + render(); + + const signInButton = screen.getByRole("button", { + name: "Sign In", + }); + expect(signInButton).toBeInTheDocument(); + }); + + it("opens and closes the menu", async () => { + const user = userEvent.setup(); + const ComposedMobileShell = composeStory(MobileShellPublic, Meta); + render(); + + const menuButton = screen.getByRole("button", { + name: "Expand menu", + }); + + await user.click(menuButton); + expect( + screen.getByRole("button", { name: "Collapse menu" }), + ).toBeInTheDocument(); + await user.click(menuButton); + expect( + screen.getByRole("button", { name: "Expand menu" }), + ).toBeInTheDocument(); + }); +}); + +describe("MobileShell authenticated", () => { + it("passes the axe accessibility test suite", async () => { + const ComposedMobileShell = composeStory(MobileShellAuthenticated, Meta); + const { container } = render(); + expect(await axe(container)).toHaveNoViolations(); + }); + + it("does not show the sign-in button", () => { + const ComposedMobileShell = composeStory(MobileShellAuthenticated, Meta); + render(); + + const signInButton = screen.queryByRole("button", { + name: "Sign In", + }); + expect(signInButton).not.toBeInTheDocument(); + }); + + it("opens and closes the menu", async () => { + const user = userEvent.setup(); + const ComposedMobileShell = composeStory(MobileShellAuthenticated, Meta); + render(); + + const menuButton = screen.getByRole("button", { + name: "Expand menu", + }); + + await user.click(menuButton); + expect( + screen.getByRole("button", { name: "Collapse menu" }), + ).toBeInTheDocument(); + await user.click(menuButton); + expect( + screen.getByRole("button", { name: "Expand menu" }), + ).toBeInTheDocument(); + }); +}); diff --git a/src/app/(proper_react)/(redesign)/MobileShell.tsx b/src/app/(proper_react)/(redesign)/MobileShell.tsx index a09c5d773a8..f98e0bc51b3 100644 --- a/src/app/(proper_react)/(redesign)/MobileShell.tsx +++ b/src/app/(proper_react)/(redesign)/MobileShell.tsx @@ -19,10 +19,12 @@ import { useTelemetry } from "../../hooks/useTelemetry"; import { usePathname } from "next/navigation"; import { CONST_SETTINGS_TAB_SLUGS } from "../../../constants"; import { FeatureFlagName } from "../../../db/tables/featureFlags"; +import { SignInButton } from "../../components/client/SignInButton"; +import { TopNavBar } from "./(public)/TopNavBar"; export type Props = { countryCode: string; - session: Session; + session: Session | null; monthlySubscriptionUrl: string; yearlySubscriptionUrl: string; subscriptionBillingAmount: { @@ -62,42 +64,11 @@ export const MobileShell = (props: Props) => { /* c8 ignore next */ tabIndex={isOnDashboard ? -1 : undefined} className={`${styles.wrapper} ${ - // TODO: Add unit test when changing this code: - /* c8 ignore next */ isExpanded ? styles.hasOpenMenu : styles.hasClosedMenu }`} >
- -
-
{
- + {props.session ? ( + + ) : ( + + )} +
@@ -127,76 +119,86 @@ export const MobileShell = (props: Props) => { aria-label={l10n.getString("mobile-menu-label")} >
-
    -
  • - - {l10n.getString("main-nav-link-dashboard-label")} - -
  • - {props.countryCode === "us" && ( -
  • - - {l10n.getString("main-nav-link-how-it-works-label")} - -
  • - )} -
  • - - {l10n.getString("main-nav-link-faq-label")} - -
  • -
  • - - {l10n.getString("main-nav-link-settings-label")} - - {props.enabledFeatureFlags.includes("SettingsPageRedesign") && ( -
      - {CONST_SETTINGS_TAB_SLUGS.map((submenuKey) => { - return ( -
    • - - {l10n.getString(`settings-tab-label-${submenuKey}`)} - -
    • - ); - })} -
    - )} -
  • -
-
- -
+ {props.session ? ( + <> +
    +
  • + + {l10n.getString("main-nav-link-dashboard-label")} + +
  • + {props.countryCode === "us" && ( +
  • + + {l10n.getString("main-nav-link-how-it-works-label")} + +
  • + )} +
  • + + {l10n.getString("main-nav-link-faq-label")} + +
  • +
  • + + {l10n.getString("main-nav-link-settings-label")} + + {props.enabledFeatureFlags.includes( + "SettingsPageRedesign", + ) && ( +
      + {CONST_SETTINGS_TAB_SLUGS.map((submenuKey) => { + return ( +
    • + + {l10n.getString( + `settings-tab-label-${submenuKey}`, + )} + +
    • + ); + })} +
    + )} +
  • +
+
+ +
+ + ) : ( + + )}
{props.children}
diff --git a/src/app/components/client/SignInButton.tsx b/src/app/components/client/SignInButton.tsx index b73d888eb9a..cdfb2f199e8 100644 --- a/src/app/components/client/SignInButton.tsx +++ b/src/app/components/client/SignInButton.tsx @@ -6,10 +6,10 @@ import { signIn, useSession } from "next-auth/react"; import { useL10n } from "../../hooks/l10n"; -import { Button } from "./Button"; +import { Button, ButtonProps } from "./Button"; import { useTelemetry } from "../../hooks/useTelemetry"; -export const SignInButton = () => { +export const SignInButton = (props: ButtonProps) => { const l10n = useL10n(); const session = useSession(); const recordTelemetry = useTelemetry(); @@ -20,6 +20,7 @@ export const SignInButton = () => { return (