Skip to content

MPP-4171 Megabundle In App Promo #5628

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jun 9, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions frontend/pendingTranslations.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ plan-grid-card-phone-item-one = Phone mask to <b>protect your real phone number<
plan-grid-megabundle-title = Privacy Protection Plan
plan-grid-megabundle-label = Best value, save { $discountPercentage }%
plan-grid-megabundle-subtitle = 3 privacy tools, 1 price
plan-grid-megabundle-vpn-title = { -brand-name-mozilla-vpn}
plan-grid-megabundle-vpn-title = { -brand-name-mozilla-vpn }
plan-grid-megabundle-vpn-description = Online activity protection
-brand-name-monitor-plus = Monitor Plus
plan-grid-megabundle-monitor-title = { -brand-name-monitor-plus }
Expand All @@ -166,4 +166,13 @@ plan-grid-megabundle-relay-description = Unlimited email masks for spam protecti
plan-grid-billed-monthly = Billed monthly
plan-matrix-price-yearly-calculated = { $yearly_price } billed yearly
plan-grid-megabundle-monthly = { $price }/mo
plan-grid-megabundle-yearly = { $yearly_price } billed yearly
plan-grid-megabundle-yearly = { $yearly_price } billed yearly

whatsnew-megabundle-heading = Privacy and security, one supercharged plan
-brand-name-monitor = Monitor
whatsnew-megabundle-snippet = For { $monthly_price }/month, save on { -brand-name-vpn }, { -brand-name-monitor }‘s data broker protection, and { -brand-name-relay }‘s unlimited email…
whatsnew-megabundle-description = For { $monthly_price }/month, save on { -brand-name-vpn }, { -brand-name-monitor }‘s data broker protection, and { -brand-name-relay }‘s unlimited email masks.
whatsnew-megabundle-cta = Get year-round protection
whatsnew-megabundle-premium-snippet = For { $monthly_price }/month, combine { -brand-name-relay } with { -brand-name-vpn }‘s online activity protection and { -brand-name-monitor }‘s data…
whatsnew-megabundle-premium-description = For { $monthly_price }/month, combine { -brand-name-relay } with { -brand-name-vpn }‘s online activity protection and { -brand-name-monitor }‘s protection from data brokers who sell your info online.
whatsnew-megabundle-premium-cta = Upgrade my protection
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { render, screen, fireEvent } from "@testing-library/react";
import { WhatsNewMenu } from "./WhatsNewMenu";
import { useL10n } from "../../../../hooks/l10n";
import { useGaEvent } from "../../../../hooks/gaEvent";
import { useAddonData } from "../../../../hooks/addon";
import { useLocalDismissal } from "../../../../hooks/localDismissal";
import { isUsingFirefox } from "../../../../functions/userAgent";
import { isFlagActive } from "../../../../functions/waffle";
import {
mockedRuntimeData,
mockedProfiles,
} from "../../../../apiMocks/mockData";

jest.mock("../../../../hooks/l10n");
jest.mock("../../../../hooks/gaEvent");
jest.mock("../../../../hooks/addon");
jest.mock("../../../../hooks/localDismissal");
jest.mock("../../../../functions/userAgent");
jest.mock("../../../../functions/waffle");
jest.mock("../../../../functions/getLocale", () => ({ getLocale: () => "en" }));

const l10nMock = {
getString: jest.fn((key, vars) =>
vars ? `${key}:${JSON.stringify(vars)}` : `${key}`,
),
};

beforeAll(() => {
class MockIntersectionObserver implements IntersectionObserver {
readonly root: Element | null = null;
readonly rootMargin: string = "0px";
readonly thresholds: ReadonlyArray<number> = [0];
disconnect() {}
observe() {}
takeRecords(): IntersectionObserverEntry[] {
return [];
}
unobserve() {}
constructor() {}
}
global.IntersectionObserver =
MockIntersectionObserver as typeof IntersectionObserver;
});

describe("WhatsNewMenu", () => {
beforeEach(() => {
jest.clearAllMocks();
(useL10n as jest.Mock).mockReturnValue(l10nMock);
(useGaEvent as jest.Mock).mockReturnValue(jest.fn());
(useAddonData as jest.Mock).mockReturnValue({ present: false });
(isUsingFirefox as jest.Mock).mockReturnValue(false);
(isFlagActive as unknown as jest.Mock).mockReturnValue(true);
});

it("renders trigger when there are visible announcements", () => {
(useLocalDismissal as jest.Mock).mockImplementation(() => ({
isDismissed: false,
dismiss: jest.fn(),
}));

render(
<WhatsNewMenu
profile={mockedProfiles.full}
runtimeData={mockedRuntimeData}
style="test-style"
/>,
);

expect(
screen.getByRole("button", { name: /whatsnew-trigger-label/i }),
).toBeInTheDocument();
});

it("shows pill when undismissed entries exist", () => {
(useLocalDismissal as jest.Mock).mockImplementation(() => ({
isDismissed: false,
dismiss: jest.fn(),
}));

render(
<WhatsNewMenu
profile={mockedProfiles.full}
runtimeData={mockedRuntimeData}
style="test-style"
/>,
);

expect(screen.getByTestId("whatsnew-pill")).toHaveTextContent("1");
});

it("opens the overlay and displays the dashboard", () => {
(useLocalDismissal as jest.Mock).mockImplementation(() => ({
isDismissed: false,
dismiss: jest.fn(),
}));

render(
<WhatsNewMenu
profile={mockedProfiles.full}
runtimeData={mockedRuntimeData}
style="test-style"
/>,
);

const trigger = screen.getByRole("button");
fireEvent.click(trigger);

expect(screen.getByRole("dialog")).toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ import FirefoxIntegrationHero from "./images/firefox-integration-hero.svg";
import FirefoxIntegrationIcon from "./images/firefox-integration-icon.svg";
import MailingListHero from "./images/mailing-list-hero.svg";
import MailingListIcon from "./images/mailing-list-icon.svg";
import ShieldHero from "./images/shield-hero.svg";
import ShieldIcon from "./images/shield-icon.svg";
import { WhatsNewContent } from "./WhatsNewContent";
import {
DismissalData,
Expand All @@ -60,8 +62,11 @@ import { RuntimeData } from "../../../../hooks/api/runtimeData";
import { isFlagActive } from "../../../../functions/waffle";
import {
getBundlePrice,
getMegabundlePrice,
getMegabundleSubscribeLink,
getPeriodicalPremiumSubscribeLink,
isBundleAvailableInCountry,
isMegabundleAvailableInCountry,
isPeriodicalPremiumAvailableInCountry,
isPhonesAvailableInCountry,
} from "../../../../functions/getPlan";
Expand Down Expand Up @@ -639,6 +644,62 @@ export const WhatsNewMenu = (props: Props) => {
entries.push(mailingListAnnouncement);
}

const megabundleDismissal = useLocalDismissal(
`whatsnew-megabundle_${props.profile.id}`,
);

if (isMegabundleAvailableInCountry(props.runtimeData)) {
const isPremium = isPeriodicalPremiumAvailableInCountry(props.runtimeData);

const snippet = l10n.getString(
isPremium
? "whatsnew-megabundle-premium-snippet"
: "whatsnew-megabundle-snippet",
{ monthly_price: getMegabundlePrice(props.runtimeData, l10n) },
);

const description = l10n.getString(
isPremium
? "whatsnew-megabundle-premium-description"
: "whatsnew-megabundle-description",
{ monthly_price: getMegabundlePrice(props.runtimeData, l10n) },
);

const ctaText = l10n.getString(
isPremium ? "whatsnew-megabundle-premium-cta" : "whatsnew-megabundle-cta",
);

const megabundleEntry: WhatsNewEntry = {
title: l10n.getString("whatsnew-megabundle-heading"),
snippet,
content: (
<WhatsNewContent
heading={l10n.getString("whatsnew-megabundle-heading")}
description={description}
image={ShieldHero}
cta={
<a
className={styles.cta}
href={getMegabundleSubscribeLink(props.runtimeData)}
target="_blank"
>
{ctaText}
</a>
}
/>
),
icon: ShieldIcon,
dismissal: megabundleDismissal,
announcementDate: {
year: 2025,
month: 6,
day: 3,
},
};

entries.push(megabundleEntry);
}

const entriesNotInFuture = entries.filter((entry) => {
const entryDate = new Date(
Date.UTC(
Expand Down Expand Up @@ -694,6 +755,7 @@ export const WhatsNewMenu = (props: Props) => {
count: newEntries.length,
})}
className={styles.pill}
data-testid="whatsnew-pill"
>
{newEntries.length}
</i>
Expand All @@ -705,6 +767,7 @@ export const WhatsNewMenu = (props: Props) => {
<button
{...buttonProps}
ref={triggerRef}
data-testid="whatsnew-trigger"
className={`${styles.trigger} ${
triggerState.isOpen ? styles["is-open"] : ""
} ${props.style}`}
Expand Down
Loading