diff --git a/.stylelintrc.json b/.stylelintrc.json index 8b092df8e4..a825e898d0 100644 --- a/.stylelintrc.json +++ b/.stylelintrc.json @@ -4,8 +4,14 @@ "ignoreFiles": ["build/**/*.css", "Sources/**/*.css", "docs/**/*.css", "special-pages/pages/**/*/dist/*.css"], "rules": { "csstree/validator": { - "ignoreProperties": ["text-wrap", "view-transition-name"] + "ignoreProperties": ["text-wrap", "view-transition-name", "composes"] }, + "property-no-unknown": [ + true, + { + "ignoreProperties": ["composes"] + } + ], "alpha-value-notation": null, "at-rule-empty-line-before": null, "color-function-notation": null, diff --git a/injected/package.json b/injected/package.json index 5f01338935..9ce0ca5d7b 100644 --- a/injected/package.json +++ b/injected/package.json @@ -15,7 +15,7 @@ "test-int": "playwright test --grep-invert '@screenshots'", "test-int-x": "xvfb-run --server-args='-screen 0 1024x768x24' npm run test-int", "test-int-snapshots": "playwright test --grep '@screenshots'", - "test-int-snapshots-update": "playwright test --grep '@screenshots' --update-snapshots --last-failed", + "test-int-snapshots-update": "playwright test --grep '@screenshots' --update-snapshots --last-failed --pass-with-no-tests", "test": "npm run test-unit && npm run test-int && npm run playwright", "serve": "http-server -c-1 --port 3220 integration-test/test-pages", "playwright": "playwright test --grep-invert '@screenshots'", diff --git a/special-pages/package.json b/special-pages/package.json index e7860339f0..9efd08598f 100644 --- a/special-pages/package.json +++ b/special-pages/package.json @@ -10,7 +10,7 @@ "build": "node index.mjs", "build.dev": "npm run build -- --env development", "lint-fix": "cd ../ && npm run lint-fix", - "test-unit": "node --test \"unit-test/*\" \"pages/history/unit-tests/*\" \"pages/duckplayer/unit-tests/*\" \"pages/new-tab/app/freemium-pir-banner/unit-tests/*\" \"pages/new-tab/app/omnibar/unit-tests/*\"", + "test-unit": "node --test \"unit-test/*\" \"pages/history/unit-tests/*\" \"pages/duckplayer/unit-tests/*\" \"pages/new-tab/app/omnibar/unit-tests/*\"", "test-int": "playwright test --grep-invert '@screenshots'", "test-int-x": "npm run test-int", "test-int-snapshots": "playwright test --grep '@screenshots'", diff --git a/special-pages/pages/new-tab/app/components/Examples.jsx b/special-pages/pages/new-tab/app/components/Examples.jsx index c23339718d..724a125cb5 100644 --- a/special-pages/pages/new-tab/app/components/Examples.jsx +++ b/special-pages/pages/new-tab/app/components/Examples.jsx @@ -7,6 +7,7 @@ import { otherRMFExamples, RMFExamples } from '../remote-messaging-framework/com import { updateNotificationExamples } from '../update-notification/components/UpdateNotification.examples.js'; import { activityExamples } from '../activity/components/Activity.examples.js'; import { protectionsHeadingExamples } from '../protections/components/ProtectionsHeading.examples.js'; +import { subscriptionWinBackBannerExamples } from '../subscription-winback-banner/components/SubscriptionWinBackBanner.examples.js'; /** @type {Record import("preact").ComponentChild}>} */ export const mainExamples = { @@ -15,6 +16,7 @@ export const mainExamples = { ...nextStepsExamples, ...privacyStatsExamples, ...RMFExamples, + ...subscriptionWinBackBannerExamples, }; export const otherExamples = { diff --git a/special-pages/pages/new-tab/app/entry-points/subscriptionWinBackBanner.js b/special-pages/pages/new-tab/app/entry-points/subscriptionWinBackBanner.js new file mode 100644 index 0000000000..d26c6b7713 --- /dev/null +++ b/special-pages/pages/new-tab/app/entry-points/subscriptionWinBackBanner.js @@ -0,0 +1,14 @@ +import { h } from 'preact'; +import { Centered } from '../components/Layout.js'; +import { SubscriptionWinBackBannerConsumer } from '../subscription-winback-banner/components/SubscriptionWinBackBanner.js'; +import { SubscriptionWinBackBannerProvider } from '../subscription-winback-banner/SubscriptionWinBackBannerProvider.js'; + +export function factory() { + return ( + + + + + + ); +} diff --git a/special-pages/pages/new-tab/app/freemium-pir-banner/components/FreemiumPIRBanner.js b/special-pages/pages/new-tab/app/freemium-pir-banner/components/FreemiumPIRBanner.js index 768ecce651..c57836816f 100644 --- a/special-pages/pages/new-tab/app/freemium-pir-banner/components/FreemiumPIRBanner.js +++ b/special-pages/pages/new-tab/app/freemium-pir-banner/components/FreemiumPIRBanner.js @@ -5,7 +5,7 @@ import { DismissButton } from '../../components/DismissButton'; import styles from './FreemiumPIRBanner.module.css'; import { FreemiumPIRBannerContext } from '../FreemiumPIRBannerProvider'; import { useContext } from 'preact/hooks'; -import { convertMarkdownToHTMLForStrongTags } from '../freemiumPIRBanner.utils'; +import { convertMarkdownToHTMLForStrongTags } from '../../../../../shared/utils'; /** * @typedef { import("../../../types/new-tab").FreemiumPIRBannerMessage} FreemiumPIRBannerMessage diff --git a/special-pages/pages/new-tab/app/freemium-pir-banner/freemiumPIRBanner.utils.js b/special-pages/pages/new-tab/app/freemium-pir-banner/freemiumPIRBanner.utils.js deleted file mode 100644 index 3900c6428e..0000000000 --- a/special-pages/pages/new-tab/app/freemium-pir-banner/freemiumPIRBanner.utils.js +++ /dev/null @@ -1,30 +0,0 @@ -/** - * @type {(markdown: string) => string} convertMarkdownToHTMLForStrongTags - */ - -export function convertMarkdownToHTMLForStrongTags(markdown) { - // first, remove any HTML tags - markdown = escapeXML(markdown); - - // Use a regular expression to find all the words wrapped in ** - const regex = /\*\*(.*?)\*\*/g; - - // Replace the matched text with the HTML tags - const result = markdown.replace(regex, '$1'); - return result; -} - -/** - * Escapes any occurrences of &, ", <, > or / with XML entities. - */ -function escapeXML(str) { - const replacements = { - '&': '&', - '"': '"', - "'": ''', - '<': '<', - '>': '>', - '/': '/', - }; - return String(str).replace(/[&"'<>/]/g, (m) => replacements[m]); -} diff --git a/special-pages/pages/new-tab/app/mock-transport.js b/special-pages/pages/new-tab/app/mock-transport.js index 9c04d5fc36..3c4f88a8d5 100644 --- a/special-pages/pages/new-tab/app/mock-transport.js +++ b/special-pages/pages/new-tab/app/mock-transport.js @@ -7,6 +7,7 @@ import { updateNotificationExamples } from './update-notification/mocks/update-n import { variants as nextSteps } from './next-steps/nextsteps.data.js'; import { customizerData, customizerMockTransport } from './customizer/mocks.js'; import { freemiumPIRDataExamples } from './freemium-pir-banner/mocks/freemiumPIRBanner.data.js'; +import { subscriptionWinBackBannerDataExamples } from './subscription-winback-banner/mocks/subscriptionWinBackBanner.data.js'; import { activityMockTransport } from './activity/mocks/activity.mock-transport.js'; import { protectionsMockTransport } from './protections/mocks/protections.mock-transport.js'; import { omnibarMockTransport } from './omnibar/mocks/omnibar.mock-transport.js'; @@ -95,6 +96,7 @@ export function mockTransport() { const rmfSubscriptions = new Map(); const freemiumPIRBannerSubscriptions = new Map(); const nextStepsSubscriptions = new Map(); + const subscriptionWinBackBannerSubscriptions = new Map(); function clearRmf() { const listeners = rmfSubscriptions.get('rmf_onDataUpdate') || []; @@ -163,6 +165,14 @@ export function mockTransport() { console.log('ignoring freemiumPIRBanner_dismiss', msg.params); return; } + case 'winBackOffer_action': { + console.log('ignoring winBackOffer_action', msg.params); + return; + } + case 'winBackOffer_dismiss': { + console.log('ignoring winBackOffer_dismiss', msg.params); + return; + } case 'favorites_setConfig': { if (!msg.params) throw new Error('unreachable'); @@ -256,6 +266,24 @@ export function mockTransport() { } return () => {}; } + case 'winBackOffer_onDataUpdate': { + // store the callback for later (eg: dismiss) + const prev = subscriptionWinBackBannerSubscriptions.get('winBackOffer_onDataUpdate') || []; + const next = [...prev]; + next.push(cb); + subscriptionWinBackBannerSubscriptions.set('winBackOffer_onDataUpdate', next); + + const subscriptionWinBackBannerParam = url.searchParams.get('winback'); + + if ( + subscriptionWinBackBannerParam !== null && + subscriptionWinBackBannerParam in subscriptionWinBackBannerDataExamples + ) { + const message = subscriptionWinBackBannerDataExamples[subscriptionWinBackBannerParam]; + cb(message); + } + return () => {}; + } case 'nextSteps_onDataUpdate': { const prev = nextStepsSubscriptions.get('nextSteps_onDataUpdate') || []; const next = [...prev]; @@ -471,6 +499,18 @@ export function mockTransport() { return Promise.resolve(freemiumPIRBannerMessage); } + case 'winBackOffer_getData': { + /** @type {import('../types/new-tab.ts').SubscriptionWinBackBannerData} */ + let subscriptionWinBackBannerMessage = { content: null }; + + const subscriptionWinBackBannerParam = url.searchParams.get('winback'); + + if (subscriptionWinBackBannerParam && subscriptionWinBackBannerParam in subscriptionWinBackBannerDataExamples) { + subscriptionWinBackBannerMessage = subscriptionWinBackBannerDataExamples[subscriptionWinBackBannerParam]; + } + + return Promise.resolve(subscriptionWinBackBannerMessage); + } case 'favorites_getData': { const param = url.searchParams.get('favorites'); let data; @@ -514,6 +554,7 @@ export function initialSetup(url) { { id: 'updateNotification' }, { id: 'rmf' }, { id: 'freemiumPIRBanner' }, + { id: 'subscriptionWinBackBanner' }, { id: 'nextSteps' }, { id: 'favorites' }, ]; diff --git a/special-pages/pages/new-tab/app/subscription-winback-banner/SubscriptionWinBackBannerProvider.js b/special-pages/pages/new-tab/app/subscription-winback-banner/SubscriptionWinBackBannerProvider.js new file mode 100644 index 0000000000..78dde8167f --- /dev/null +++ b/special-pages/pages/new-tab/app/subscription-winback-banner/SubscriptionWinBackBannerProvider.js @@ -0,0 +1,96 @@ +import { createContext, h } from 'preact'; +import { useCallback, useEffect, useReducer, useRef } from 'preact/hooks'; +import { useMessaging } from '../types.js'; +import { SubscriptionWinBackBannerService } from './subscriptionWinBackBanner.service.js'; +import { reducer, useDataSubscription, useInitialData } from '../service.hooks.js'; + +/** + * @typedef {import('../../types/new-tab.js').SubscriptionWinBackBannerData} SubscriptionWinBackBannerData + * @typedef {import('../service.hooks.js').State} State + * @typedef {import('../service.hooks.js').Events} Events + */ + +/** + * These are the values exposed to consumers. + */ +export const SubscriptionWinBackBannerContext = createContext({ + /** @type {State} */ + state: { status: 'idle', data: null, config: null }, + /** @type {(id: string) => void} */ + dismiss: (id) => { + throw new Error('must implement dismiss' + id); + }, + /** @type {(id: string) => void} */ + action: (id) => { + throw new Error('must implement action' + id); + }, +}); + +export const SubscriptionWinBackBannerDispatchContext = createContext(/** @type {import("preact/hooks").Dispatch} */ ({})); + +/** + * A data provider that will use `SubscriptionWinBackBannerService` to fetch data, subscribe + * to updates and modify state. + * + * @param {Object} props + * @param {import("preact").ComponentChild} props.children + */ +export function SubscriptionWinBackBannerProvider(props) { + const initial = /** @type {State} */ ({ + status: 'idle', + data: null, + config: null, + }); + + // const [state, dispatch] = useReducer(withLog('SubscriptionWinBackBannerProvider', reducer), initial) + const [state, dispatch] = useReducer(reducer, initial); + + // create an instance of `SubscriptionWinBackBannerService` for the lifespan of this component. + const service = useService(); + + // get initial data + useInitialData({ dispatch, service }); + + // subscribe to data updates + useDataSubscription({ dispatch, service }); + + // todo(valerie): implement onDismiss in the service + const dismiss = useCallback( + (id) => { + console.log('onDismiss'); + service.current?.dismiss(id); + }, + [service], + ); + + const action = useCallback( + (id) => { + service.current?.action(id); + }, + [service], + ); + + return ( + + + {props.children} + + + ); +} + +/** + * @return {import("preact").RefObject} + */ +export function useService() { + const service = useRef(/** @type {SubscriptionWinBackBannerService|null} */ (null)); + const ntp = useMessaging(); + useEffect(() => { + const stats = new SubscriptionWinBackBannerService(ntp); + service.current = stats; + return () => { + stats.destroy(); + }; + }, [ntp]); + return service; +} diff --git a/special-pages/pages/new-tab/app/subscription-winback-banner/components/SubscriptionWinBackBanner.examples.js b/special-pages/pages/new-tab/app/subscription-winback-banner/components/SubscriptionWinBackBanner.examples.js new file mode 100644 index 0000000000..9deb1b57c7 --- /dev/null +++ b/special-pages/pages/new-tab/app/subscription-winback-banner/components/SubscriptionWinBackBanner.examples.js @@ -0,0 +1,18 @@ +import { h } from 'preact'; +import { noop } from '../../utils.js'; +import { SubscriptionWinBackBanner } from './SubscriptionWinBackBanner.js'; +import { subscriptionWinBackBannerDataExamples } from '../mocks/subscriptionWinBackBanner.data.js'; + +/** @type {Record import("preact").ComponentChild}>} */ + +export const subscriptionWinBackBannerExamples = { + 'subscriptionWinBackBanner.winback_last_day': { + factory: () => ( + + ), + }, +}; diff --git a/special-pages/pages/new-tab/app/subscription-winback-banner/components/SubscriptionWinBackBanner.js b/special-pages/pages/new-tab/app/subscription-winback-banner/components/SubscriptionWinBackBanner.js new file mode 100644 index 0000000000..4c635e5c5c --- /dev/null +++ b/special-pages/pages/new-tab/app/subscription-winback-banner/components/SubscriptionWinBackBanner.js @@ -0,0 +1,47 @@ +import cn from 'classnames'; +import { h } from 'preact'; +import { Button } from '../../../../../shared/components/Button/Button'; +import { DismissButton } from '../../components/DismissButton'; +import styles from './SubscriptionWinBackBanner.module.css'; +import { SubscriptionWinBackBannerContext } from '../SubscriptionWinBackBannerProvider'; +import { useContext } from 'preact/hooks'; +import { convertMarkdownToHTMLForStrongTags } from '../../../../../shared/utils'; + +/** + * @typedef { import("../../../types/new-tab").SubscriptionWinBackBannerMessage} SubscriptionWinBackBannerMessage + * @param {object} props + * @param {SubscriptionWinBackBannerMessage} props.message + * @param {(id: string) => void} props.dismiss + * @param {(id: string) => void} props.action + */ +export function SubscriptionWinBackBanner({ message, action, dismiss }) { + const processedMessageDescription = convertMarkdownToHTMLForStrongTags(message.descriptionText); + return ( +
+ + + +
+ {message.titleText &&

{message.titleText}

} +

+

+ {message.messageType === 'big_single_action' && message?.actionText && action && ( +
+ +
+ )} + {message.id && dismiss && dismiss(message.id)} />} +
+ ); +} + +export function SubscriptionWinBackBannerConsumer() { + const { state, action, dismiss } = useContext(SubscriptionWinBackBannerContext); + + if (state.status === 'ready' && state.data.content) { + return ; + } + return null; +} diff --git a/special-pages/pages/new-tab/app/subscription-winback-banner/components/SubscriptionWinBackBanner.module.css b/special-pages/pages/new-tab/app/subscription-winback-banner/components/SubscriptionWinBackBanner.module.css new file mode 100644 index 0000000000..75dd3ead8e --- /dev/null +++ b/special-pages/pages/new-tab/app/subscription-winback-banner/components/SubscriptionWinBackBanner.module.css @@ -0,0 +1,41 @@ +/** + * Using CSS Modules 'composes' to import styles from FreemiumPIRBanner. + * This avoids code duplication and keeps both components in sync. + * See: https://github.com/css-modules/css-modules/blob/master/docs/composition.md + */ + +.root { + composes: root from '../../freemium-pir-banner/components/FreemiumPIRBanner.module.css'; +} + +.iconBlock { + composes: iconBlock from '../../freemium-pir-banner/components/FreemiumPIRBanner.module.css'; +} + +.content { + composes: content from '../../freemium-pir-banner/components/FreemiumPIRBanner.module.css'; +} + +.title { + composes: title from '../../freemium-pir-banner/components/FreemiumPIRBanner.module.css'; +} + +.description { + composes: description from '../../freemium-pir-banner/components/FreemiumPIRBanner.module.css'; +} + +.btnBlock { + composes: btnBlock from '../../freemium-pir-banner/components/FreemiumPIRBanner.module.css'; +} + +.btnRow { + composes: btnRow from '../../freemium-pir-banner/components/FreemiumPIRBanner.module.css'; +} + +.dismissBtn { + composes: dismissBtn from '../../freemium-pir-banner/components/FreemiumPIRBanner.module.css'; +} + +.icon { + composes: icon from '../../freemium-pir-banner/components/FreemiumPIRBanner.module.css'; +} diff --git a/special-pages/pages/new-tab/app/subscription-winback-banner/integration-tests/subscription-winback-banner.spec.js b/special-pages/pages/new-tab/app/subscription-winback-banner/integration-tests/subscription-winback-banner.spec.js new file mode 100644 index 0000000000..03b72716f1 --- /dev/null +++ b/special-pages/pages/new-tab/app/subscription-winback-banner/integration-tests/subscription-winback-banner.spec.js @@ -0,0 +1,30 @@ +import { test, expect } from '@playwright/test'; +import { NewtabPage } from '../../../integration-tests/new-tab.page.js'; + +test.describe('newtab remote messaging framework subscriptionWinBackBanner', () => { + test('fetches config + data', async ({ page }, workerInfo) => { + const ntp = NewtabPage.create(page, workerInfo); + await ntp.reducedMotion(); + await ntp.openPage({ winback: 'winback_last_day' }); + + const calls1 = await ntp.mocks.waitForCallCount({ method: 'initialSetup', count: 1 }); + const calls2 = await ntp.mocks.waitForCallCount({ method: 'winBackOffer_getData', count: 1 }); + + expect(calls1.length).toBe(1); + expect(calls2.length).toBe(1); + }); + + test('renders a title, descriptionText an action button, and dismiss button', async ({ page }, workerInfo) => { + const ntp = NewtabPage.create(page, workerInfo); + await ntp.reducedMotion(); + await ntp.openPage({ winback: 'winback_last_day' }); + + await page.getByRole('heading', { name: 'Last day to save 25%!' }).waitFor(); + await page.getByText('Stay protected with our').waitFor(); + + await page.getByRole('button', { name: 'See Offer' }).click(); + await ntp.mocks.waitForCallCount({ method: 'winBackOffer_action', count: 1 }); + await page.getByTestId('dismissBtn').click(); + await ntp.mocks.waitForCallCount({ method: 'winBackOffer_dismiss', count: 1 }); + }); +}); diff --git a/special-pages/pages/new-tab/app/subscription-winback-banner/mocks/subscriptionWinBackBanner.data.js b/special-pages/pages/new-tab/app/subscription-winback-banner/mocks/subscriptionWinBackBanner.data.js new file mode 100644 index 0000000000..201596b1e5 --- /dev/null +++ b/special-pages/pages/new-tab/app/subscription-winback-banner/mocks/subscriptionWinBackBanner.data.js @@ -0,0 +1,15 @@ +/** + * @type {Record}>} + + */ +export const subscriptionWinBackBannerDataExamples = { + winback_last_day: { + content: { + messageType: 'big_single_action', + id: 'winback_last_day', + titleText: 'Last day to save 25%!', + descriptionText: 'Stay protected with our VPN, private AI, and more. Resubscribe today and save 25%. Limited time offer.', + actionText: 'See Offer', + }, + }, +}; diff --git a/special-pages/pages/new-tab/app/subscription-winback-banner/subscription-winback-banner.md b/special-pages/pages/new-tab/app/subscription-winback-banner/subscription-winback-banner.md new file mode 100644 index 0000000000..2067d2f1c3 --- /dev/null +++ b/special-pages/pages/new-tab/app/subscription-winback-banner/subscription-winback-banner.md @@ -0,0 +1,29 @@ +--- +title: Subscription Win-back Banner +--- + +## Requests: +- {@link "NewTab Messages".SubscriptionWinBackBannerGetDataRequest `winBackOffer_getData`} + - Used to fetch the initial data (during the first render) + - returns {@link "NewTab Messages".SubscriptionWinBackBannerData} + +## Subscriptions: +- {@link "NewTab Messages".SubscriptionWinBackBannerOnDataUpdateSubscription `winBackOffer_onDataUpdate`}. + - The messages available for the platform + - returns {@link "NewTab Messages".SubscriptionWinBackBannerData} + +## Notifications: +- {@link "NewTab Messages".SubscriptionWinBackBannerActionNotification `winBackOffer_action`} + - Sent when the user clicks the action button + - sends {@link "NewTab Messages".SubscriptionWinBackBannerAction} + - example payload: + ```json + { + "id": "winback_last_day" + } + ``` + +## Examples: + +The following examples show the data types in JSON format: +[messages/new-tab/examples/stats.js](../../messages/examples/subscriptionWinBackBanner.js) diff --git a/special-pages/pages/new-tab/app/subscription-winback-banner/subscriptionWinBackBanner.service.js b/special-pages/pages/new-tab/app/subscription-winback-banner/subscriptionWinBackBanner.service.js new file mode 100644 index 0000000000..5895c4e8f0 --- /dev/null +++ b/special-pages/pages/new-tab/app/subscription-winback-banner/subscriptionWinBackBanner.service.js @@ -0,0 +1,61 @@ +/** + * @typedef {import("../../types/new-tab.js").SubscriptionWinBackBannerData} SubscriptionWinBackBannerData + */ +import { Service } from '../service.js'; + +export class SubscriptionWinBackBannerService { + /** + * @param {import("../../src/index.js").NewTabPage} ntp - The internal data feed, expected to have a `subscribe` method. + * @internal + */ + constructor(ntp) { + this.ntp = ntp; + /** @type {Service} */ + this.dataService = new Service({ + initial: () => ntp.messaging.request('winBackOffer_getData'), + subscribe: (cb) => ntp.messaging.subscribe('winBackOffer_onDataUpdate', cb), + }); + } + + name() { + return 'SubscriptionWinBackBannerService'; + } + + /** + * @returns {Promise} + * @internal + */ + async getInitial() { + return await this.dataService.fetchInitial(); + } + + /** + * @internal + */ + destroy() { + this.dataService.destroy(); + } + + /** + * @param {(evt: {data: SubscriptionWinBackBannerData, source: 'manual' | 'subscription'}) => void} cb + * @internal + */ + onData(cb) { + return this.dataService.onData(cb); + } + + /** + * @param {string} id + * @internal + */ + dismiss(id) { + return this.ntp.messaging.notify('winBackOffer_dismiss', { id }); + } + + /** + * @param {string} id + */ + action(id) { + this.ntp.messaging.notify('winBackOffer_action', { id }); + } +} diff --git a/special-pages/pages/new-tab/integration-tests/new-tab.page.js b/special-pages/pages/new-tab/integration-tests/new-tab.page.js index 7cb99dd2bb..6cd6bdb389 100644 --- a/special-pages/pages/new-tab/integration-tests/new-tab.page.js +++ b/special-pages/pages/new-tab/integration-tests/new-tab.page.js @@ -71,6 +71,7 @@ export class NewtabPage { * @param {string} [params.updateNotification] - Optional flag to point to display=components view with certain rmf example visible * @param {string} [params.pir] - Optional flag to add certain Freemium PIR Banner example * @param {string} [params.platformName] - Optional parameters for opening the page. + * @param {string} [params.winback] - Optional parameters for Subscription Win-back Banner. */ async openPage({ mode = 'debug', @@ -82,6 +83,7 @@ export class NewtabPage { rmf, pir, updateNotification, + winback, } = {}) { await this.mocks.install(); const searchParams = new URLSearchParams({ mode, willThrow: String(willThrow) }); @@ -108,6 +110,10 @@ export class NewtabPage { searchParams.set('pir', pir); } + if (winback !== undefined) { + searchParams.set('winback', winback); + } + if (platformName !== undefined) { searchParams.set('platform', platformName); } diff --git a/special-pages/pages/new-tab/messages/examples/subscriptionWinBackBanner.js b/special-pages/pages/new-tab/messages/examples/subscriptionWinBackBanner.js new file mode 100644 index 0000000000..2dfc725400 --- /dev/null +++ b/special-pages/pages/new-tab/messages/examples/subscriptionWinBackBanner.js @@ -0,0 +1,12 @@ +/** + * @type {import("../../types/new-tab.js").SubscriptionWinBackBannerData} + */ +const subscriptionWinBackBannerLastDay = { + content: { + messageType: 'big_single_action', + id: 'winback_last_day', + titleText: 'Last day to save 25%!', + descriptionText: 'Stay protected with our VPN, private AI, and more. Resubscribe today and save 25%. Limited time offer.', + actionText: 'See Offer', + }, +}; diff --git a/special-pages/pages/new-tab/messages/types/subscriptionWinBackBanner-message.json b/special-pages/pages/new-tab/messages/types/subscriptionWinBackBanner-message.json new file mode 100644 index 0000000000..5286bf056a --- /dev/null +++ b/special-pages/pages/new-tab/messages/types/subscriptionWinBackBanner-message.json @@ -0,0 +1,51 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Subscription Win-back Banner Data", + "type": "object", + "required": [ + "content" + ], + "properties": { + "content": { + "oneOf": [ + { + "type": "null" + }, + { + "type": "object", + "title": "Subscription Win-back Banner Message", + "required": [ + "messageType", + "id", + "descriptionText", + "titleText", + "actionText" + ], + "properties": { + "messageType": { + "const": "big_single_action" + }, + "id": { + "type": "string", + "enum": [ + "winback_last_day" + ] + }, + "titleText": { + "type": [ + "string", + "null" + ] + }, + "descriptionText": { + "type": "string" + }, + "actionText": { + "type": "string" + } + } + } + ] + } + } +} diff --git a/special-pages/pages/new-tab/messages/winBackOffer_action.notify.json b/special-pages/pages/new-tab/messages/winBackOffer_action.notify.json new file mode 100644 index 0000000000..bb63871568 --- /dev/null +++ b/special-pages/pages/new-tab/messages/winBackOffer_action.notify.json @@ -0,0 +1,13 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Subscription Win-back Banner Action", + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "string" + } + } +} diff --git a/special-pages/pages/new-tab/messages/winBackOffer_dismiss.notify.json b/special-pages/pages/new-tab/messages/winBackOffer_dismiss.notify.json new file mode 100644 index 0000000000..ad7c68b471 --- /dev/null +++ b/special-pages/pages/new-tab/messages/winBackOffer_dismiss.notify.json @@ -0,0 +1,13 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Subscription Win-back Banner Dismiss Action", + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "string" + } + } +} diff --git a/special-pages/pages/new-tab/messages/winBackOffer_getData.request.json b/special-pages/pages/new-tab/messages/winBackOffer_getData.request.json new file mode 100644 index 0000000000..fbdeff52ec --- /dev/null +++ b/special-pages/pages/new-tab/messages/winBackOffer_getData.request.json @@ -0,0 +1,3 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#" +} diff --git a/special-pages/pages/new-tab/messages/winBackOffer_getData.response.json b/special-pages/pages/new-tab/messages/winBackOffer_getData.response.json new file mode 100644 index 0000000000..6f65c9e468 --- /dev/null +++ b/special-pages/pages/new-tab/messages/winBackOffer_getData.response.json @@ -0,0 +1,8 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "allOf": [ + { + "$ref": "types/subscriptionWinBackBanner-message.json" + } + ] +} diff --git a/special-pages/pages/new-tab/messages/winBackOffer_onDataUpdate.subscribe.json b/special-pages/pages/new-tab/messages/winBackOffer_onDataUpdate.subscribe.json new file mode 100644 index 0000000000..6f65c9e468 --- /dev/null +++ b/special-pages/pages/new-tab/messages/winBackOffer_onDataUpdate.subscribe.json @@ -0,0 +1,8 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "allOf": [ + { + "$ref": "types/subscriptionWinBackBanner-message.json" + } + ] +} diff --git a/special-pages/pages/new-tab/public/icons/Subscription-Clock-96.svg b/special-pages/pages/new-tab/public/icons/Subscription-Clock-96.svg new file mode 100644 index 0000000000..6e1895dd25 --- /dev/null +++ b/special-pages/pages/new-tab/public/icons/Subscription-Clock-96.svg @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/special-pages/pages/new-tab/readme.md b/special-pages/pages/new-tab/readme.md index e4a0f90619..35d116338f 100644 --- a/special-pages/pages/new-tab/readme.md +++ b/special-pages/pages/new-tab/readme.md @@ -180,6 +180,12 @@ - `onboarding` - Shows onboarding PIR banner - `scan_results` - Shows scan results PIR banner +### Subscription Win-back Banner + - **Purpose**: Tests different win-back banner states + - **Parameter**: `winback` + - **Example**: `?winback=true` + - **Options**: + ## Combining Parameters diff --git a/special-pages/pages/new-tab/types/new-tab.ts b/special-pages/pages/new-tab/types/new-tab.ts index a84be137d9..41205f9521 100644 --- a/special-pages/pages/new-tab/types/new-tab.ts +++ b/special-pages/pages/new-tab/types/new-tab.ts @@ -140,7 +140,9 @@ export interface NewTabMessages { | StatsShowMoreNotification | TelemetryEventNotification | UpdateNotificationDismissNotification - | WidgetsSetConfigNotification; + | WidgetsSetConfigNotification + | WinBackOfferActionNotification + | WinBackOfferDismissNotification; requests: | ActivityConfirmBurnRequest | ActivityGetDataRequest @@ -157,7 +159,8 @@ export interface NewTabMessages { | ProtectionsGetConfigRequest | ProtectionsGetDataRequest | RmfGetDataRequest - | StatsGetDataRequest; + | StatsGetDataRequest + | WinBackOfferGetDataRequest; subscriptions: | ActivityOnBurnCompleteSubscription | ActivityOnDataPatchSubscription @@ -180,7 +183,8 @@ export interface NewTabMessages { | StatsOnDataUpdateSubscription | TabsOnDataUpdateSubscription | UpdateNotificationOnDataUpdateSubscription - | WidgetsOnConfigUpdatedSubscription; + | WidgetsOnConfigUpdatedSubscription + | WinBackOfferOnDataUpdateSubscription; } /** * Generated from @see "../messages/activity_addFavorite.notify.json" @@ -672,6 +676,26 @@ export interface WidgetConfigItem { id: string; visibility: WidgetVisibility; } +/** + * Generated from @see "../messages/winBackOffer_action.notify.json" + */ +export interface WinBackOfferActionNotification { + method: "winBackOffer_action"; + params: SubscriptionWinBackBannerAction; +} +export interface SubscriptionWinBackBannerAction { + id: string; +} +/** + * Generated from @see "../messages/winBackOffer_dismiss.notify.json" + */ +export interface WinBackOfferDismissNotification { + method: "winBackOffer_dismiss"; + params: SubscriptionWinBackBannerDismissAction; +} +export interface SubscriptionWinBackBannerDismissAction { + id: string; +} /** * Generated from @see "../messages/activity_confirmBurn.request.json" */ @@ -1001,6 +1025,23 @@ export interface TrackerCompany { displayName: string; count: number; } +/** + * Generated from @see "../messages/winBackOffer_getData.request.json" + */ +export interface WinBackOfferGetDataRequest { + method: "winBackOffer_getData"; + result: SubscriptionWinBackBannerData; +} +export interface SubscriptionWinBackBannerData { + content: null | SubscriptionWinBackBannerMessage; +} +export interface SubscriptionWinBackBannerMessage { + messageType: "big_single_action"; + id: "winback_last_day"; + titleText: string | null; + descriptionText: string; + actionText: string; +} /** * Generated from @see "../messages/activity_onBurnComplete.subscribe.json" */ @@ -1174,6 +1215,13 @@ export interface WidgetsOnConfigUpdatedSubscription { subscriptionEvent: "widgets_onConfigUpdated"; params: WidgetConfigs; } +/** + * Generated from @see "../messages/winBackOffer_onDataUpdate.subscribe.json" + */ +export interface WinBackOfferOnDataUpdateSubscription { + subscriptionEvent: "winBackOffer_onDataUpdate"; + params: SubscriptionWinBackBannerData; +} declare module "../src/index.js" { export interface NewTabPage { diff --git a/special-pages/playwright.config.js b/special-pages/playwright.config.js index 71d42de25a..5da7537260 100644 --- a/special-pages/playwright.config.js +++ b/special-pages/playwright.config.js @@ -24,6 +24,7 @@ export default defineConfig({ testMatch: [ 'favorites.spec.js', 'freemium-pir-banner.spec.js', + 'subscription-winback-banner.spec.js', 'new-tab.spec.js', 'new-tab.screenshots.spec.js', 'next-steps.spec.js', diff --git a/special-pages/shared/components/Button/Button.module.css b/special-pages/shared/components/Button/Button.module.css index 2e48704942..cd43eccca4 100644 --- a/special-pages/shared/components/Button/Button.module.css +++ b/special-pages/shared/components/Button/Button.module.css @@ -295,6 +295,7 @@ body:not([data-platform-name]) { } } + &.accent, &.accentBrand { background-color: var(--ddg-color-primary); color: var(--color-white); @@ -333,6 +334,7 @@ body:not([data-platform-name]) { } } + &.accent, &.accentBrand { color: var(--color-black-at-84); background-color: var(--color-blue-20); diff --git a/special-pages/shared/utils.js b/special-pages/shared/utils.js index e8627f0235..06f9537409 100644 --- a/special-pages/shared/utils.js +++ b/special-pages/shared/utils.js @@ -21,3 +21,33 @@ export const getLocalizedNumberFormatter = (locale) => { return new Intl.NumberFormat(localeToUse); }; + +/** + * @type {(markdown: string) => string} convertMarkdownToHTMLForStrongTags + */ +export function convertMarkdownToHTMLForStrongTags(markdown) { + // first, remove any HTML tags + markdown = escapeXML(markdown); + + // Use a regular expression to find all the words wrapped in ** + const regex = /\*\*(.*?)\*\*/g; + + // Replace the matched text with the HTML tags + const result = markdown.replace(regex, '$1'); + return result; +} + +/** + * Escapes any occurrences of &, ", <, > or / with XML entities. + */ +function escapeXML(str) { + const replacements = { + '&': '&', + '"': '"', + "'": ''', + '<': '<', + '>': '>', + '/': '/', + }; + return String(str).replace(/[&"'<>/]/g, (m) => replacements[m]); +} diff --git a/special-pages/pages/new-tab/app/freemium-pir-banner/unit-tests/utils.spec.mjs b/special-pages/unit-test/utils.spec.mjs similarity index 96% rename from special-pages/pages/new-tab/app/freemium-pir-banner/unit-tests/utils.spec.mjs rename to special-pages/unit-test/utils.spec.mjs index 08caf8e9e8..089e6b1139 100644 --- a/special-pages/pages/new-tab/app/freemium-pir-banner/unit-tests/utils.spec.mjs +++ b/special-pages/unit-test/utils.spec.mjs @@ -1,6 +1,6 @@ import { describe, it } from 'node:test'; import { equal } from 'node:assert/strict'; -import { convertMarkdownToHTMLForStrongTags } from '../freemiumPIRBanner.utils.js'; +import { convertMarkdownToHTMLForStrongTags } from '../shared/utils.js'; describe('convertMarkdownToHTMLForStrongTags', () => { it('with terms wrapped in "**" will return with tags wrapping that part of the string', () => {