diff --git a/locales-pending/announcements.ftl b/locales-pending/announcements.ftl index 27df8e0ac2f..a979c95da18 100644 --- a/locales-pending/announcements.ftl +++ b/locales-pending/announcements.ftl @@ -43,3 +43,19 @@ announcement-add-up-to-20-emails-free-cta-label = Upgrade announcement-switch-to-yearly-sub-title = { -brand-monitor-plus }, now with 35% yearly savings announcement-switch-to-yearly-sub-description = Switch to year-round protection with one easy payment. announcement-switch-to-yearly-sub-cta-label = Upgrade + +# id: bundle-offering-free-user + +announcement-bundle-offering-free-user-title = Privacy and security, one supercharged plan +# Variables: +# $bundleMonthlyPrice (string) - monthly bundle plan's price per month, including currency, e.g. "$13.37" +announcement-bundle-offering-free-user-description = For { $bundleMonthlyPrice }/month, save on { -brand-vpn }, { -brand-monitor }’s data broker protection, and { -brand-relay-new }’s unlimited email masks. +announcement-bundle-offering-free-user-cta-label = Get year-round protection + +# id: bundle-offering-plus-user + +announcement-bundle-offering-plus-user-title = Privacy and security, one supercharged plan +# Variables: +# $bundleMonthlyPrice (string) - monthly bundle plan's price per month, including currency, e.g. "$13.37" +announcement-bundle-offering-plus-user-description = For { $bundleMonthlyPrice }/month, get { -brand-vpn }’s online activity protection and { -brand-relay-new }’s unlimited email masks along with { -brand-monitor }. +announcement-bundle-offering-plus-user-cta-label = Upgrade my protection diff --git a/public/images/announcements/bundle-offering-free-user/big.svg b/public/images/announcements/bundle-offering-free-user/big.svg new file mode 100644 index 00000000000..89ec3ced70a --- /dev/null +++ b/public/images/announcements/bundle-offering-free-user/big.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/announcements/bundle-offering-free-user/small.svg b/public/images/announcements/bundle-offering-free-user/small.svg new file mode 100644 index 00000000000..c1e56a5b4b6 --- /dev/null +++ b/public/images/announcements/bundle-offering-free-user/small.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/announcements/bundle-offering-plus-user/big.svg b/public/images/announcements/bundle-offering-plus-user/big.svg new file mode 100644 index 00000000000..89ec3ced70a --- /dev/null +++ b/public/images/announcements/bundle-offering-plus-user/big.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/announcements/bundle-offering-plus-user/small.svg b/public/images/announcements/bundle-offering-plus-user/small.svg new file mode 100644 index 00000000000..c1e56a5b4b6 --- /dev/null +++ b/public/images/announcements/bundle-offering-plus-user/small.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/TestComponentWrapper.tsx b/src/TestComponentWrapper.tsx index 1c3a8785001..6e84e252cad 100644 --- a/src/TestComponentWrapper.tsx +++ b/src/TestComponentWrapper.tsx @@ -8,6 +8,7 @@ import { SessionProvider } from "next-auth/react"; import { ReactAriaI18nProvider } from "./contextProviders/react-aria"; import { getL10nBundles } from "./app/functions/l10n/storybookAndJest"; import { CookiesProvider } from "./contextProviders/cookies"; +import { SubscriptionBillingProvider } from "./contextProviders/subscription-billing-context"; const l10nBundles = getL10nBundles(); @@ -16,7 +17,20 @@ export const TestComponentWrapper = (props: { children: ReactNode }) => { - {props.children} + + + {props.children} + + diff --git a/src/app/(proper_react)/(redesign)/(authenticated)/admin/announcements/page.tsx b/src/app/(proper_react)/(redesign)/(authenticated)/admin/announcements/page.tsx index 699c39e8521..c42c0e5b02e 100644 --- a/src/app/(proper_react)/(redesign)/(authenticated)/admin/announcements/page.tsx +++ b/src/app/(proper_react)/(redesign)/(authenticated)/admin/announcements/page.tsx @@ -7,9 +7,12 @@ import { notFound, redirect } from "next/navigation"; import { isAdmin } from "../../../../../api/utils/auth"; import { getAllAnnouncements } from "../../../../../../db/tables/announcements"; import { AnnouncementsAdmin } from "./AnnouncementsAdmin"; +import { SubscriptionBillingProvider } from "../../../../../../contextProviders/subscription-billing-context"; +import { getSubscriptionBillingAmount } from "../../../../../functions/server/getPremiumSubscriptionInfo"; export default async function DevPage() { const session = await getServerSession(); + const billing = getSubscriptionBillingAmount(); if (!session?.user?.email || !session.user.subscriber?.id) { return redirect("/"); @@ -21,5 +24,9 @@ export default async function DevPage() { const announcements = await getAllAnnouncements(); - return ; + return ( + + + + ); } diff --git a/src/app/(proper_react)/layout.tsx b/src/app/(proper_react)/layout.tsx index c5dfe242e7e..b34d36dd28d 100644 --- a/src/app/(proper_react)/layout.tsx +++ b/src/app/(proper_react)/layout.tsx @@ -20,6 +20,8 @@ import { PromptNoneAuth } from "../components/client/PromptNoneAuth"; import { addClientIdForSubscriber } from "../../db/tables/google_analytics_clients"; import { logger } from "../functions/server/logging"; import { CookiesProvider } from "../../contextProviders/cookies"; +import { SubscriptionBillingProvider } from "../../contextProviders/subscription-billing-context"; +import { getSubscriptionBillingAmount } from "../functions/server/getPremiumSubscriptionInfo"; export default async function Layout({ children }: { children: ReactNode }) { const l10nBundles = getL10nBundles( @@ -35,6 +37,7 @@ export default async function Layout({ children }: { children: ReactNode }) { email: session.user.email, }, ); + const billing = getSubscriptionBillingAmount(); const cookieStore = await cookies(); // This expects the default Google Analytics cookie documented here: https://support.google.com/analytics/answer/11397207?hl=en @@ -68,10 +71,12 @@ export default async function Layout({ children }: { children: ReactNode }) { - {enabledFlags.includes("PromptNoneAuthFlow") && !session && ( - - )} - {children} + + {enabledFlags.includes("PromptNoneAuthFlow") && !session && ( + + )} + {children} + diff --git a/src/app/components/client/toolbar/AnnouncementDialog.tsx b/src/app/components/client/toolbar/AnnouncementDialog.tsx index faea3a1eba9..e87ae60a270 100644 --- a/src/app/components/client/toolbar/AnnouncementDialog.tsx +++ b/src/app/components/client/toolbar/AnnouncementDialog.tsx @@ -5,7 +5,7 @@ "use client"; import Image from "next/image"; -import { FocusScope, useButton, useOverlayTrigger } from "react-aria"; +import { useButton, useOverlayTrigger } from "react-aria"; import { useL10n } from "../../../hooks/l10n"; import { useRef, useState } from "react"; import { useOverlayTriggerState } from "react-stately"; @@ -17,6 +17,7 @@ import { TelemetryLink } from "../TelemetryLink"; import { useTelemetry } from "../../../hooks/useTelemetry"; import { AnnouncementRow } from "knex/types/tables"; import { truncateDescription } from "../../../../utils/truncate"; +import { useSubscriptionBilling } from "../../../../contextProviders/subscription-billing-context"; type AnnouncementDialogProps = { announcements: UserAnnouncementWithDetails[]; @@ -193,227 +194,225 @@ export const AnnouncementDialog = ({ state={triggerState} {...overlayProps} > - -
-
- - -
-
-
- {announcementDetailsView && relevantAnnouncement ? ( -
- {l10n.getString("announcement-big-img-alt")} - setBigImageUnavailableMap((prev) => ({ - ...prev, - [relevantAnnouncement.announcement_id]: true, - })) - } - /* c8 ignore end */ - /> -
-
+
+
+ + +
+
+
+ {announcementDetailsView && relevantAnnouncement ? ( +
+ {l10n.getString("announcement-big-img-alt")} + setBigImageUnavailableMap((prev) => ({ + ...prev, + [relevantAnnouncement.announcement_id]: true, + })) + } + /* c8 ignore end */ + /> +
+
+
+ +
+
+ +
+
+ {relevantAnnouncement.cta_link && ( + + + + )} +
+ +
+ ) : ( +
+ {filteredAnnouncements.length === 0 ? ( + // Empty state +
+ +
- + {l10n.getString( + "announcement-dialog-empty-state-title", + )}
- + {l10n.getString( + "announcement-dialog-empty-state-description", + )}
- {relevantAnnouncement.cta_link && ( - - - - )}
- -
- ) : ( -
- {filteredAnnouncements.length === 0 ? ( - // Empty state -
+ ) : ( + // List of announcements + + filteredAnnouncements.map((announcement) => ( +
- ) : ( - // List of announcements - - filteredAnnouncements.map((announcement) => ( - - )) + + )) + )} + {activeTab === "new" && + filteredAnnouncements.length !== 0 && ( + )} - {activeTab === "new" && - filteredAnnouncements.length !== 0 && ( - - )} -
- )} -
- +
+ )}
- + +
)} @@ -430,32 +429,45 @@ export const LocalizedAnnouncementString = ( props: LocalizedAnnouncementStringProps, ) => { const l10n = useL10n(); + const billingInfo = useSubscriptionBilling(); // Build the key based on the type (fluent IDs are named in this format) const key = `announcement-${props.announcement.announcement_id}-${props.type}`; - const localizedString = l10n.getString(key); - // If the key is not translated, use the fallback values from the announcements table - if (localizedString === key) { - console.warn(`${props.announcement.announcement_id} is not localized`); - - if (props.type === "title") { - return props.announcement.title; - } - if (props.type === "description") { - return props.announcement.description; - } - if (props.type === "cta-label") { - return props.announcement.cta_label; + const getFallback = () => { + switch (props.type) { + case "title": + return props.announcement.title; + case "description": + return props.announcement.description; + case "cta-label": + return props.announcement.cta_label; } + }; + + // If the fluent string doesn't exist, then fallback to the values set in the db + const raw = l10n.getString(key); + if (raw === key) { + return <>{getFallback()}; } + // Interpolate vars + // Passing vars like { bundleMonthlyPrice: "$9.99" } + // to every l10n.getString(key, { vars }) call — + // even when the Fluent string doesn't use that variable + // is not performance-intensive and is generally fine. + // React rendering cost is unchanged: Passing unused variables doesn’t + // trigger any additional render complexity. + const parsedString = l10n.getString(key, { + bundleMonthlyPrice: `$${billingInfo.monthly}`, + }); + return ( <> {props.truncatedDescription - ? truncateDescription(localizedString) - : localizedString} + ? truncateDescription(parsedString) + : parsedString} ); }; diff --git a/src/contextProviders/subscription-billing-context.tsx b/src/contextProviders/subscription-billing-context.tsx new file mode 100644 index 00000000000..012eecba7be --- /dev/null +++ b/src/contextProviders/subscription-billing-context.tsx @@ -0,0 +1,36 @@ +/* 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 React, { createContext, useContext } from "react"; +import { SubscriptionBillingAmount } from "../app/(proper_react)/(redesign)/(public)/LandingViewRedesign/components/PricingPlanListWithBundle"; + +const SubscriptionBillingContext = + createContext(null); + +export function SubscriptionBillingProvider({ + children, + value, +}: { + children: React.ReactNode; + value: SubscriptionBillingAmount; +}) { + return ( + + {children} + + ); +} + +export function useSubscriptionBilling(): SubscriptionBillingAmount { + const ctx = useContext(SubscriptionBillingContext); + /* c8 ignore next 5 */ + if (!ctx) { + throw new Error( + "useSubscriptionBilling must be used inside SubscriptionBillingProvider", + ); + } + return ctx; +}