diff --git a/special-pages/package.json b/special-pages/package.json index 58f4b8398..5fa59308c 100644 --- a/special-pages/package.json +++ b/special-pages/package.json @@ -9,7 +9,7 @@ "prebuild": "node types.mjs && node translations.mjs", "build": "node index.mjs", "build.dev": "npm run build -- --env development", - "test-unit": "node --test unit-test/translations.mjs pages/duckplayer/unit-tests/embed-settings.mjs", + "test-unit": "node --test unit-test/translations.mjs pages/duckplayer/unit-tests/embed-settings.mjs pages/new-tab/app/freemium-pir-banner/unit-tests/utils.spec.mjs", "test-int": "npm run test-unit && npm run build.dev && playwright test --grep-invert '@screenshots'", "test-int-x": "npm run test-int", "test.screenshots": "npm run test-unit && npm run build.dev && 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 75b00bc59..67fd1d0d0 100644 --- a/special-pages/pages/new-tab/app/components/Examples.jsx +++ b/special-pages/pages/new-tab/app/components/Examples.jsx @@ -1,15 +1,15 @@ -import { h } from 'preact'; +import { customizerExamples } from '../customizer/components/Customizer.examples.js'; import { favoritesExamples } from '../favorites/components/Favorites.examples.js'; -import { otherPrivacyStatsExamples, privacyStatsExamples } from '../privacy-stats/components/PrivacyStats.examples.js'; +import { freemiumPIRBannerExamples } from '../freemium-pir-banner/components/FreemiumPIRBanner.examples.js'; import { nextStepsExamples, otherNextStepsExamples } from '../next-steps/components/NextSteps.examples.js'; +import { otherPrivacyStatsExamples, privacyStatsExamples } from '../privacy-stats/components/PrivacyStats.examples.js'; import { otherRMFExamples, RMFExamples } from '../remote-messaging-framework/components/RMF.examples.js'; -import { customizerExamples } from '../customizer/components/Customizer.examples.js'; -import { noop } from '../utils.js'; import { updateNotificationExamples } from '../update-notification/components/UpdateNotification.examples.js'; /** @type {Record import("preact").ComponentChild}>} */ export const mainExamples = { ...favoritesExamples, + ...freemiumPIRBannerExamples, ...nextStepsExamples, ...privacyStatsExamples, ...RMFExamples, diff --git a/special-pages/pages/new-tab/app/entry-points/freemiumPIRBanner.js b/special-pages/pages/new-tab/app/entry-points/freemiumPIRBanner.js new file mode 100644 index 000000000..dabe6f8d2 --- /dev/null +++ b/special-pages/pages/new-tab/app/entry-points/freemiumPIRBanner.js @@ -0,0 +1,14 @@ +import { h } from 'preact'; +import { Centered } from '../components/Layout.js'; +import { FreemiumPIRBannerConsumer } from '../freemium-pir-banner/components/FreemiumPIRBanner.js'; +import { FreemiumPIRBannerProvider } from '../freemium-pir-banner/FreemiumPIRBannerProvider.js'; + +export function factory() { + return ( + + + + + + ); +} diff --git a/special-pages/pages/new-tab/app/freemium-pir-banner/FreemiumPIRBannerProvider.js b/special-pages/pages/new-tab/app/freemium-pir-banner/FreemiumPIRBannerProvider.js new file mode 100644 index 000000000..f8392fab3 --- /dev/null +++ b/special-pages/pages/new-tab/app/freemium-pir-banner/FreemiumPIRBannerProvider.js @@ -0,0 +1,94 @@ +import { createContext, h } from 'preact'; +import { useCallback, useEffect, useReducer, useRef } from 'preact/hooks'; +import { useMessaging } from '../types.js'; +import { FreemiumPIRBannerService } from './freemiumPIRBanner.service.js'; +import { reducer, useDataSubscription, useInitialData } from '../service.hooks.js'; + +/** + * @typedef {import('../../types/new-tab.js').FreemiumPIRBannerData} FreemiumPIRBannerData + * @typedef {import('../service.hooks.js').State} State + * @typedef {import('../service.hooks.js').Events} Events + */ + +/** + * These are the values exposed to consumers. + */ +export const FreemiumPIRBannerContext = 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 FreemiumPIRBannerDispatchContext = createContext(/** @type {import("preact/hooks").Dispatch} */ ({})); + +/** + * A data provider that will use `FreemiumPIRBannerService` to fetch data, subscribe + * to updates and modify state. + * + * @param {Object} props + * @param {import("preact").ComponentChild} props.children + */ +export function FreemiumPIRBannerProvider(props) { + const initial = /** @type {State} */ ({ + status: 'idle', + data: null, + config: null, + }); + + // const [state, dispatch] = useReducer(withLog('FreemiumPIRBannerProvider', reducer), initial) + const [state, dispatch] = useReducer(reducer, initial); + + // create an instance of `FreemiumPIRBannerService` 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 {FreemiumPIRBannerService|null} */ (null)); + const ntp = useMessaging(); + useEffect(() => { + const stats = new FreemiumPIRBannerService(ntp); + service.current = stats; + return () => { + stats.destroy(); + }; + }, [ntp]); + return service; +} diff --git a/special-pages/pages/new-tab/app/freemium-pir-banner/components/FreemiumPIRBanner.examples.js b/special-pages/pages/new-tab/app/freemium-pir-banner/components/FreemiumPIRBanner.examples.js new file mode 100644 index 000000000..e83c60c8a --- /dev/null +++ b/special-pages/pages/new-tab/app/freemium-pir-banner/components/FreemiumPIRBanner.examples.js @@ -0,0 +1,27 @@ +import { h } from 'preact'; +import { noop } from '../../utils.js'; +import { FreemiumPIRBanner } from './FreemiumPIRBanner.js'; +import { freemiumPIRDataExamples } from '../mocks/freemiumPIRBanner.data.js'; + +/** @type {Record import("preact").ComponentChild}>} */ + +export const freemiumPIRBannerExamples = { + 'freemiumPIR.onboarding': { + factory: () => ( + + ), + }, + 'freemiumPIR.scan_results': { + factory: () => ( + + ), + }, +}; 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 new file mode 100644 index 000000000..768ecce65 --- /dev/null +++ b/special-pages/pages/new-tab/app/freemium-pir-banner/components/FreemiumPIRBanner.js @@ -0,0 +1,48 @@ +import cn from 'classnames'; +import { h } from 'preact'; +import { Button } from '../../../../../shared/components/Button/Button'; +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'; + +/** + * @typedef { import("../../../types/new-tab").FreemiumPIRBannerMessage} FreemiumPIRBannerMessage + * @param {object} props + * @param {FreemiumPIRBannerMessage} props.message + * @param {(id: string) => void} props.dismiss + * @param {(id: string) => void} props.action + */ + +export function FreemiumPIRBanner({ 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 FreemiumPIRBannerConsumer() { + const { state, action, dismiss } = useContext(FreemiumPIRBannerContext); + + if (state.status === 'ready' && state.data.content) { + return ; + } + return null; +} diff --git a/special-pages/pages/new-tab/app/freemium-pir-banner/components/FreemiumPIRBanner.module.css b/special-pages/pages/new-tab/app/freemium-pir-banner/components/FreemiumPIRBanner.module.css new file mode 100644 index 000000000..21ac61f7c --- /dev/null +++ b/special-pages/pages/new-tab/app/freemium-pir-banner/components/FreemiumPIRBanner.module.css @@ -0,0 +1,77 @@ +.root { + --ntp-freemiumPIR-surface-background-color: rgba(0, 0, 0, .06); + background: var(--ntp-freemiumPIR-surface-background-color); + padding: calc(14 * var(--px-in-rem)) var(--sp-8) calc(14 * var(--px-in-rem)) var(--sp-4); + border-radius: var(--border-radius-lg); + position: relative; + display: flex; + justify-content: flex-start; + align-items: flex-start; + font-family: system-ui, BlinkMacSystemFont, 'Segoe UI', Roboto; + color: var(--ntp-text-normal); + width: 100%; + animation: animate-fade .2s cubic-bezier(0.55, 0.055, 0.666, 0.19); + margin-bottom: var(--ntp-gap); + + &.icon { + padding-left: var(--sp-2); + } + + @media screen and (prefers-color-scheme: dark) { + background-color: var(--color-white-at-6); + } +} + +.iconBlock { + margin-right: var(--sp-2); + width: 3rem; + min-width: 3rem; +} + +.content { + flex-grow: 1; + height: 100%; + align-self: center; +} + +.title { + font-size: var(--body-font-size); + font-weight: var(--title-2-font-weight); + line-height: normal; + margin-bottom: var(--sp-1); +} + +.description { + font-size: var(--body-font-size); + line-height: var(--body-line-height); +} + +.btnBlock { + margin-left: var(--sp-3); + align-self: center; +} + +.btnRow { + margin-top: var(--sp-3); + display: flex; + flex-wrap: wrap; + gap: calc(10 * var(--px-in-rem)); +} + +.dismissBtn { + position: absolute; + top: 0.5rem; + right: 0.5rem; +} + + +@keyframes animate-fade { + 0% { + opacity: 0; + scale: 0.98; + } + 100% { + opacity: 1; + scale: 1; + } +} \ No newline at end of file diff --git a/special-pages/pages/new-tab/app/freemium-pir-banner/freemium-pir-banner.md b/special-pages/pages/new-tab/app/freemium-pir-banner/freemium-pir-banner.md new file mode 100644 index 000000000..cff2d7c21 --- /dev/null +++ b/special-pages/pages/new-tab/app/freemium-pir-banner/freemium-pir-banner.md @@ -0,0 +1,38 @@ +--- +title: Freemium PIR Banner +--- + +## Requests: +- {@link "NewTab Messages".FreemiumPIRBannerGetDataRequest `freemiumPIRBanner_getData`} + - Used to fetch the initial data (during the first render) + - returns {@link "NewTab Messages".FreemiumPIRBannerData} + +## Subscriptions: +- {@link "NewTab Messages".FreemiumPIRBannerOnDataUpdateSubscription `freemiumPIRBanner_onDataUpdate`}. + - The messages available for the platform + - returns {@link "NewTab Messages".FreemiumPIRBannerData} + +## Notifications: +- {@link "NewTab Messages".FreemiumPIRBannerActionNotification `freemiumPIRBanner_action`} + - Sent when the user clicks the action button + - sends {@link "NewTab Messages".FreemiumPIRBannerAction} + - example payload: + ```json + { + "id": "onboarding" + } + ``` +- {@link "NewTab Messages".FreemiumPIRBannerDismissNotification `freemiumPIRBanner_dismiss`} + - Sent when the user clicks the dismiss button + - sends {@link "NewTab Messages".FreemiumPIRBannerDismissAction} + - example payload: + ```json + { + "id": "scan_results" + } + ``` + +## Examples: + +The following examples show the data types in JSON format: +[messages/new-tab/examples/stats.js](../../messages/examples/freemiumPIRBanner.js) diff --git a/special-pages/pages/new-tab/app/freemium-pir-banner/freemiumPIRBanner.service.js b/special-pages/pages/new-tab/app/freemium-pir-banner/freemiumPIRBanner.service.js new file mode 100644 index 000000000..496f24ce4 --- /dev/null +++ b/special-pages/pages/new-tab/app/freemium-pir-banner/freemiumPIRBanner.service.js @@ -0,0 +1,61 @@ +/** + * @typedef {import("../../types/new-tab.js").FreemiumPIRBannerData} FreemiumPIRBannerData + */ +import { Service } from '../service.js'; + +export class FreemiumPIRBannerService { + /** + * @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('freemiumPIRBanner_getData'), + subscribe: (cb) => ntp.messaging.subscribe('freemiumPIRBanner_onDataUpdate', cb), + }); + } + + name() { + return 'FreemiumPIRBannerService'; + } + + /** + * @returns {Promise} + * @internal + */ + async getInitial() { + return await this.dataService.fetchInitial(); + } + + /** + * @internal + */ + destroy() { + this.dataService.destroy(); + } + + /** + * @param {(evt: {data: FreemiumPIRBannerData, source: 'manual' | 'subscription'}) => void} cb + * @internal + */ + onData(cb) { + return this.dataService.onData(cb); + } + + /** + * @param {string} id + * @internal + */ + dismiss(id) { + return this.ntp.messaging.notify('freemiumPIRBanner_dismiss', { id }); + } + + /** + * @param {string} id + */ + action(id) { + this.ntp.messaging.notify('freemiumPIRBanner_action', { id }); + } +} 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 new file mode 100644 index 000000000..3900c6428 --- /dev/null +++ b/special-pages/pages/new-tab/app/freemium-pir-banner/freemiumPIRBanner.utils.js @@ -0,0 +1,30 @@ +/** + * @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/integration-tests/freemium-pir-banner.spec.js b/special-pages/pages/new-tab/app/freemium-pir-banner/integration-tests/freemium-pir-banner.spec.js new file mode 100644 index 000000000..c9cb1d2c6 --- /dev/null +++ b/special-pages/pages/new-tab/app/freemium-pir-banner/integration-tests/freemium-pir-banner.spec.js @@ -0,0 +1,44 @@ +import { test, expect } from '@playwright/test'; +import { NewtabPage } from '../../../integration-tests/new-tab.page.js'; + +test.describe('newtab remote messaging framework freemiumPIRBanner', () => { + test('fetches config + data', async ({ page }, workerInfo) => { + const ntp = NewtabPage.create(page, workerInfo); + await ntp.reducedMotion(); + await ntp.openPage({ pir: 'onboarding' }); + + const calls1 = await ntp.mocks.waitForCallCount({ method: 'initialSetup', count: 1 }); + const calls2 = await ntp.mocks.waitForCallCount({ method: 'freemiumPIRBanner_getData', count: 1 }); + + expect(calls1.length).toBe(1); + expect(calls2.length).toBe(1); + }); + + test('onboarding variant renders a title, descriptionText with strong tag, an action button, and dismiss button', async ({ + page, + }, workerInfo) => { + const ntp = NewtabPage.create(page, workerInfo); + await ntp.reducedMotion(); + await ntp.openPage({ pir: 'onboarding' }); + await page.getByRole('heading', { name: 'Personal Information Removal' }).waitFor(); + await page.getByText('Find out which sites are').waitFor(); + await page.locator('strong').waitFor(); + + await page.getByRole('button', { name: 'Free Scan' }).click(); + await ntp.mocks.waitForCallCount({ method: 'freemiumPIRBanner_action', count: 1 }); + await page.getByTestId('dismissBtn').click(); + await ntp.mocks.waitForCallCount({ method: 'freemiumPIRBanner_dismiss', count: 1 }); + }); + + test('scan_results variant renders descriptionText, an action button, and dismiss button', async ({ page }, workerInfo) => { + const ntp = NewtabPage.create(page, workerInfo); + await ntp.reducedMotion(); + await ntp.openPage({ pir: 'scan_results' }); + + await page.getByText('Your free personal').waitFor(); + await page.getByRole('button', { name: 'View Results' }).click(); + await page.getByTestId('dismissBtn').click(); + await ntp.mocks.waitForCallCount({ method: 'freemiumPIRBanner_action', count: 1 }); + await ntp.mocks.waitForCallCount({ method: 'freemiumPIRBanner_dismiss', count: 1 }); + }); +}); diff --git a/special-pages/pages/new-tab/app/freemium-pir-banner/mocks/freemiumPIRBanner.data.js b/special-pages/pages/new-tab/app/freemium-pir-banner/mocks/freemiumPIRBanner.data.js new file mode 100644 index 000000000..5fd95ef86 --- /dev/null +++ b/special-pages/pages/new-tab/app/freemium-pir-banner/mocks/freemiumPIRBanner.data.js @@ -0,0 +1,24 @@ +/** + * @type {Record}>} + + */ +export const freemiumPIRDataExamples = { + onboarding: { + content: { + messageType: 'big_single_action', + id: 'onboarding', + titleText: 'Personal Information Removal', + descriptionText: 'Find out which sites are selling **your info**.', + actionText: 'Free Scan', + }, + }, + scan_results: { + content: { + messageType: 'big_single_action', + id: 'scan_results', + titleText: null, + descriptionText: 'Your free personal information scan found 19 records about you on 3 different sites', + actionText: 'View Results', + }, + }, +}; diff --git a/special-pages/pages/new-tab/app/freemium-pir-banner/unit-tests/utils.spec.mjs b/special-pages/pages/new-tab/app/freemium-pir-banner/unit-tests/utils.spec.mjs new file mode 100644 index 000000000..08caf8e9e --- /dev/null +++ b/special-pages/pages/new-tab/app/freemium-pir-banner/unit-tests/utils.spec.mjs @@ -0,0 +1,43 @@ +import { describe, it } from 'node:test'; +import { equal } from 'node:assert/strict'; +import { convertMarkdownToHTMLForStrongTags } from '../freemiumPIRBanner.utils.js'; + +describe('convertMarkdownToHTMLForStrongTags', () => { + it('with terms wrapped in "**" will return with tags wrapping that part of the string', () => { + const str = 'Find out which sites are selling **your info**.'; + const expected = 'Find out which sites are selling your info.'; + const actual = convertMarkdownToHTMLForStrongTags(str); + equal(actual, expected); + }); + + it("will not affect strings that don't have two sets of two astrisks", () => { + const strWithoutStars = 'Gingerbread oat cake dessert macaroon powder tiramisu topping.'; + const strWithOneStar = 'Gingerbread oat* cake dessert macaroon powder tiramisu topping.'; + const strWithOneStarSandwich = 'Gingerbread *oat* cake dessert macaroon powder tiramisu topping.'; + const strWithUnevenStars = 'Gingerbread **oat* cake dessert macaroon powder tiramisu topping.'; + + const actualWithoutStars = convertMarkdownToHTMLForStrongTags(strWithoutStars); + const actualWithOneStar = convertMarkdownToHTMLForStrongTags(strWithOneStar); + const actualWithOneStarSandwich = convertMarkdownToHTMLForStrongTags(strWithOneStarSandwich); + const actualWithUnevenStars = convertMarkdownToHTMLForStrongTags(strWithUnevenStars); + + equal(actualWithoutStars, strWithoutStars); + equal(actualWithOneStar, strWithOneStar); + equal(actualWithOneStarSandwich, strWithOneStarSandwich); + equal(actualWithUnevenStars, strWithUnevenStars); + }); + + it('will handle strings that have more than one set of bolded words', () => { + const str = 'Gingerbread **oat cake** dessert **macaroon powder tiramisu** topping.'; + const expected = 'Gingerbread oat cake dessert macaroon powder tiramisu topping.'; + const actual = convertMarkdownToHTMLForStrongTags(str); + equal(actual, expected); + }); + + it('ignores HTML', () => { + const str = 'abc **def** '; + const expected = 'abc def <script>alert(document.cookie)</script>'; + const actual = convertMarkdownToHTMLForStrongTags(str); + equal(actual, expected); + }); +}); diff --git a/special-pages/pages/new-tab/app/mock-transport.js b/special-pages/pages/new-tab/app/mock-transport.js index dfc53b031..9fde53e58 100644 --- a/special-pages/pages/new-tab/app/mock-transport.js +++ b/special-pages/pages/new-tab/app/mock-transport.js @@ -5,6 +5,7 @@ import { rmfDataExamples } from './remote-messaging-framework/mocks/rmf.data.js' import { favorites, gen } from './favorites/mocks/favorites.data.js'; import { updateNotificationExamples } from './update-notification/mocks/update-notification.data.js'; import { variants as nextSteps } from './next-steps/nextsteps.data.js'; +import { freemiumPIRDataExamples } from './freemium-pir-banner/mocks/freemiumPIRBanner.data.js'; /** * @typedef {import('../types/new-tab').Favorite} Favorite @@ -83,6 +84,7 @@ export function mockTransport() { /** @type {Map} */ const rmfSubscriptions = new Map(); + const freemiumPIRBannerSubscriptions = new Map(); function clearRmf() { const listeners = rmfSubscriptions.get('rmf_onDataUpdate') || []; @@ -125,7 +127,14 @@ export function mockTransport() { } case 'rmf_dismiss': { console.log('ignoring rmf_dismiss', msg.params); - clearRmf(); + return; + } + case 'freemiumPIRBanner_action': { + console.log('ignoring freemiumPIRBanner_action', msg.params); + return; + } + case 'freemiumPIRBanner_dismiss': { + console.log('ignoring freemiumPIRBanner_dismiss', msg.params); return; } case 'favorites_setConfig': { @@ -208,6 +217,21 @@ export function mockTransport() { ); return () => controller.abort(); } + case 'freemiumPIRBanner_onDataUpdate': { + // store the callback for later (eg: dismiss) + const prev = freemiumPIRBannerSubscriptions.get('freemiumPIRBanner_onDataUpdate') || []; + const next = [...prev]; + next.push(cb); + freemiumPIRBannerSubscriptions.set('freemiumPIRBanner_onDataUpdate', next); + + const freemiumPIRBannerParam = url.searchParams.get('pir'); + + if (freemiumPIRBannerParam !== null && freemiumPIRBannerParam in freemiumPIRDataExamples) { + const message = freemiumPIRDataExamples[freemiumPIRBannerParam]; + cb(message); + } + return () => {}; + } case 'rmf_onDataUpdate': { // store the callback for later (eg: dismiss) const prev = rmfSubscriptions.get('rmf_onDataUpdate') || []; @@ -256,21 +280,6 @@ export function mockTransport() { { signal: controller.signal }, ); - // setTimeout(() => { - // const next = favorites.many.favorites.map(item => { - // if (item.id === 'id-many-2') { - // return { - // ...item, - // favicon: { - // src: './company-icons/adform.svg', maxAvailableSize: 32 - // } - // } - // } - // return item - // }); - // cb({favorites: next}) - // }, 2000) - return () => controller.abort(); } case 'stats_onDataUpdate': { @@ -402,6 +411,17 @@ export function mockTransport() { return Promise.resolve(message); } + case 'freemiumPIRBanner_getData': { + /** @type {import('../types/new-tab.ts').FreemiumPIRBannerData} */ + let freemiumPIRBannerMessage = { content: null }; + const freemiumPIRBannerParam = url.searchParams.get('pir'); + + if (freemiumPIRBannerParam && freemiumPIRBannerParam in freemiumPIRDataExamples) { + freemiumPIRBannerMessage = freemiumPIRDataExamples[freemiumPIRBannerParam]; + } + + return Promise.resolve(freemiumPIRBannerMessage); + } case 'favorites_getData': { const param = url.searchParams.get('favorites'); let data; @@ -428,6 +448,7 @@ export function mockTransport() { const widgetsFromStorage = read('widgets') || [ { id: 'updateNotification' }, { id: 'rmf' }, + { id: 'freemiumPIRBanner' }, { id: 'nextSteps' }, { id: 'favorites' }, { id: 'privacyStats' }, diff --git a/special-pages/pages/new-tab/app/next-steps/components/NextStepsCard.js b/special-pages/pages/new-tab/app/next-steps/components/NextStepsCard.js index c844715a8..bd8bedaf8 100644 --- a/special-pages/pages/new-tab/app/next-steps/components/NextStepsCard.js +++ b/special-pages/pages/new-tab/app/next-steps/components/NextStepsCard.js @@ -9,8 +9,10 @@ import { variants, additionalCardStates } from '../nextsteps.data'; import styles from './NextSteps.module.css'; /** + * @typedef {import('../../../types/new-tab').NextStepsCardTypes} NextStepsCardTypes + * * @param {object} props - * @param {string} props.type + * @param {NextStepsCardTypes} props.type * @param {(id: string) => void} props.dismiss * @param {(id: string) => void} props.action */ @@ -19,7 +21,7 @@ export function NextStepsCard({ type, dismiss, action }) { const { t } = useTypedTranslationWith(/** @type {import("../strings.json")} */ ({})); const message = variants[type]?.(t); const [showConfirmation, setShowConfirmation] = useState(false); - const hasConfirmationState = additionalCardStates.hasConfirmationText(message.id); + const hasConfirmationState = additionalCardStates.hasConfirmationText(type); const handleClick = () => { if (!hasConfirmationState) { diff --git a/special-pages/pages/new-tab/app/next-steps/components/NextStepsGroup.js b/special-pages/pages/new-tab/app/next-steps/components/NextStepsGroup.js index 4846d11be..95d5b2a3d 100644 --- a/special-pages/pages/new-tab/app/next-steps/components/NextStepsGroup.js +++ b/special-pages/pages/new-tab/app/next-steps/components/NextStepsGroup.js @@ -13,12 +13,12 @@ import { NextStepsCard } from './NextStepsCard'; * @typedef {enStrings & ntpStrings} strings * @typedef {import('../../../types/new-tab').Expansion} Expansion * @typedef {import('../../../types/new-tab').Animation} Animation - * @typedef {import('../../../types/new-tab').NextStepsCards} NextStepsCards + * @typedef {import('../../../types/new-tab').NextStepsCardTypes} NextStepsCardTypes */ /** * @param {object} props - * @param {string[]} props.types + * @param {NextStepsCardTypes[]} props.types * @param {Expansion} props.expansion * @param {()=>void} props.toggle * @param {(id: string)=>void} props.action diff --git a/special-pages/pages/new-tab/app/next-steps/nextsteps.data.js b/special-pages/pages/new-tab/app/next-steps/nextsteps.data.js index 0455c98f8..2746ef12c 100644 --- a/special-pages/pages/new-tab/app/next-steps/nextsteps.data.js +++ b/special-pages/pages/new-tab/app/next-steps/nextsteps.data.js @@ -71,7 +71,10 @@ export const otherText = { nextSteps_sectionTitle: (t) => t('nextSteps_sectionTitle'), }; -/** @type {string[]} cardsWithConfirmationText */ +/** + * @typedef {import('../../types/new-tab').NextStepsCardTypes} NextStepsCardTypes + * @type {NextStepsCardTypes[]} cardsWithConfirmationText + */ const cardsWithConfirmationText = ['addAppToDockMac']; export const additionalCardStates = { 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 e23698cb6..ab04b8eee 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 @@ -61,9 +61,20 @@ export class NewtabPage { * @param {Record} [params.additional] - Optional map of key/values to add * @param {string} [params.rmf] - Optional flag to add certain rmf example * @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. */ - async openPage({ mode = 'debug', additional, platformName, willThrow = false, favorites, nextSteps, rmf, updateNotification } = {}) { + async openPage({ + mode = 'debug', + additional, + platformName, + willThrow = false, + favorites, + nextSteps, + rmf, + pir, + updateNotification, + } = {}) { await this.mocks.install(); const searchParams = new URLSearchParams({ mode, willThrow: String(willThrow) }); @@ -85,6 +96,10 @@ export class NewtabPage { } } + if (pir !== undefined) { + searchParams.set('pir', pir); + } + if (platformName !== undefined) { searchParams.set('platform', platformName); } diff --git a/special-pages/pages/new-tab/messages/examples/freemiumPIRBanner.js b/special-pages/pages/new-tab/messages/examples/freemiumPIRBanner.js new file mode 100644 index 000000000..fdb9775ea --- /dev/null +++ b/special-pages/pages/new-tab/messages/examples/freemiumPIRBanner.js @@ -0,0 +1,25 @@ +/** + * @type {import("../../types/new-tab.js").FreemiumPIRBannerData} + */ +const freemiumPIRBannerOnboarding = { + content: { + messageType: 'big_single_action', + id: 'onboarding', + titleText: '', + descriptionText: 'Lorem Ipsum is simply dummy text of the printing and typesetting industry.', + actionText: 'Free Scan', + }, +}; + +/** + * @type {import("../../types/new-tab.js").FreemiumPIRBannerData} + */ +const freemiumPIRBannerScanResults = { + content: { + messageType: 'big_single_action', + id: 'scan_results', + titleText: null, + descriptionText: 'Your free personal information scan found 4 records on 2 different sites', + actionText: 'View Results', + }, +}; diff --git a/special-pages/pages/new-tab/messages/freemiumPIRBanner_action.notify.json b/special-pages/pages/new-tab/messages/freemiumPIRBanner_action.notify.json new file mode 100644 index 000000000..6ff1decca --- /dev/null +++ b/special-pages/pages/new-tab/messages/freemiumPIRBanner_action.notify.json @@ -0,0 +1,11 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Freemium PIR Banner Action", + "type": "object", + "required": ["id"], + "properties": { + "id": { + "type": "string" + } + } +} diff --git a/special-pages/pages/new-tab/messages/freemiumPIRBanner_dismiss.notify.json b/special-pages/pages/new-tab/messages/freemiumPIRBanner_dismiss.notify.json new file mode 100644 index 000000000..a96664eba --- /dev/null +++ b/special-pages/pages/new-tab/messages/freemiumPIRBanner_dismiss.notify.json @@ -0,0 +1,13 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Freemium PIR Banner Dismiss Action", + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "string" + } + } +} \ No newline at end of file diff --git a/special-pages/pages/new-tab/messages/freemiumPIRBanner_getData.request.json b/special-pages/pages/new-tab/messages/freemiumPIRBanner_getData.request.json new file mode 100644 index 000000000..fbdeff52e --- /dev/null +++ b/special-pages/pages/new-tab/messages/freemiumPIRBanner_getData.request.json @@ -0,0 +1,3 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#" +} diff --git a/special-pages/pages/new-tab/messages/freemiumPIRBanner_getData.response.json b/special-pages/pages/new-tab/messages/freemiumPIRBanner_getData.response.json new file mode 100644 index 000000000..c4f04b9d7 --- /dev/null +++ b/special-pages/pages/new-tab/messages/freemiumPIRBanner_getData.response.json @@ -0,0 +1,8 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "allOf": [ + { + "$ref": "types/freemiumPIRBanner-message.json" + } + ] +} diff --git a/special-pages/pages/new-tab/messages/freemiumPIRBanner_onDataUpdate.subscribe.json b/special-pages/pages/new-tab/messages/freemiumPIRBanner_onDataUpdate.subscribe.json new file mode 100644 index 000000000..e36685815 --- /dev/null +++ b/special-pages/pages/new-tab/messages/freemiumPIRBanner_onDataUpdate.subscribe.json @@ -0,0 +1,8 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "allOf": [ + { + "$ref": "types/freemiumPIRBanner-message.json" + } + ] +} \ No newline at end of file diff --git a/special-pages/pages/new-tab/messages/types/freemiumPIRBanner-message.json b/special-pages/pages/new-tab/messages/types/freemiumPIRBanner-message.json new file mode 100644 index 000000000..35150883a --- /dev/null +++ b/special-pages/pages/new-tab/messages/types/freemiumPIRBanner-message.json @@ -0,0 +1,52 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Freemium PIR Banner Data", + "type": "object", + "required": [ + "content" + ], + "properties": { + "content": { + "oneOf": [ + { + "type": "null" + }, + { + "type": "object", + "title": "Freemium PIR Banner Message", + "required": [ + "messageType", + "id", + "descriptionText", + "titleText", + "actionText" + ], + "properties": { + "messageType": { + "const": "big_single_action" + }, + "id": { + "type": "string", + "enum": [ + "onboarding", + "scan_results" + ] + }, + "titleText": { + "type": [ + "string", + "null" + ] + }, + "descriptionText": { + "type": "string" + }, + "actionText": { + "type": "string" + } + } + } + ] + } + } +} \ No newline at end of file diff --git a/special-pages/pages/new-tab/messages/types/next-steps.json b/special-pages/pages/new-tab/messages/types/next-steps.json index b3ac5c104..36092e01e 100644 --- a/special-pages/pages/new-tab/messages/types/next-steps.json +++ b/special-pages/pages/new-tab/messages/types/next-steps.json @@ -21,6 +21,7 @@ ], "properties": { "id": { + "title": "Next Steps Card Types", "type": "string", "enum": [ "bringStuff", diff --git a/special-pages/pages/new-tab/public/icons/Information-Remover-96.svg b/special-pages/pages/new-tab/public/icons/Information-Remover-96.svg new file mode 100644 index 000000000..fa9c262a6 --- /dev/null +++ b/special-pages/pages/new-tab/public/icons/Information-Remover-96.svg @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/special-pages/pages/new-tab/types/new-tab.ts b/special-pages/pages/new-tab/types/new-tab.ts index 2697cc8bf..f4ff909a5 100644 --- a/special-pages/pages/new-tab/types/new-tab.ts +++ b/special-pages/pages/new-tab/types/new-tab.ts @@ -66,15 +66,16 @@ export type WidgetConfigs = WidgetConfigItem[]; * An ordered list of supported Widgets. Use this to communicate what's supported */ export type Widgets = WidgetListItem[]; +export type NextStepsCardTypes = + | "bringStuff" + | "defaultApp" + | "blockCookies" + | "emailProtection" + | "duckplayer" + | "addAppToDockMac" + | "pinAppToTaskbarWindows"; export type NextStepsCards = { - id: - | "bringStuff" - | "defaultApp" - | "blockCookies" - | "emailProtection" - | "duckplayer" - | "addAppToDockMac" - | "pinAppToTaskbarWindows"; + id: NextStepsCardTypes; }[]; export type RMFMessage = SmallMessage | MediumMessage | BigSingleActionMessage | BigTwoActionMessage; export type RMFIcon = "Announce" | "DDGAnnounce" | "CriticalUpdate" | "AppUpdate" | "PrivacyPro"; @@ -94,6 +95,8 @@ export interface NewTabMessages { | FavoritesOpenNotification | FavoritesOpenContextMenuNotification | FavoritesSetConfigNotification + | FreemiumPIRBannerActionNotification + | FreemiumPIRBannerDismissNotification | NextStepsActionNotification | NextStepsDismissNotification | NextStepsSetConfigNotification @@ -111,6 +114,7 @@ export interface NewTabMessages { requests: | FavoritesGetConfigRequest | FavoritesGetDataRequest + | FreemiumPIRBannerGetDataRequest | InitialSetupRequest | NextStepsGetConfigRequest | NextStepsGetDataRequest @@ -124,6 +128,7 @@ export interface NewTabMessages { | CustomizerOnThemeUpdateSubscription | FavoritesOnConfigUpdateSubscription | FavoritesOnDataUpdateSubscription + | FreemiumPIRBannerOnDataUpdateSubscription | NextStepsOnConfigUpdateSubscription | NextStepsOnDataUpdateSubscription | RmfOnDataUpdateSubscription @@ -294,6 +299,26 @@ export interface ViewTransitions { export interface Auto { kind: "auto-animate"; } +/** + * Generated from @see "../messages/freemiumPIRBanner_action.notify.json" + */ +export interface FreemiumPIRBannerActionNotification { + method: "freemiumPIRBanner_action"; + params: FreemiumPIRBannerAction; +} +export interface FreemiumPIRBannerAction { + id: string; +} +/** + * Generated from @see "../messages/freemiumPIRBanner_dismiss.notify.json" + */ +export interface FreemiumPIRBannerDismissNotification { + method: "freemiumPIRBanner_dismiss"; + params: FreemiumPIRBannerDismissAction; +} +export interface FreemiumPIRBannerDismissAction { + id: string; +} /** * Generated from @see "../messages/nextSteps_action.notify.json" */ @@ -462,6 +487,23 @@ export interface FavoriteFavicon { src: string; maxAvailableSize: number; } +/** + * Generated from @see "../messages/freemiumPIRBanner_getData.request.json" + */ +export interface FreemiumPIRBannerGetDataRequest { + method: "freemiumPIRBanner_getData"; + result: FreemiumPIRBannerData; +} +export interface FreemiumPIRBannerData { + content: null | FreemiumPIRBannerMessage; +} +export interface FreemiumPIRBannerMessage { + messageType: "big_single_action"; + id: "onboarding" | "scan_results"; + titleText: string | null; + descriptionText: string; + actionText: string; +} /** * Generated from @see "../messages/initialSetup.request.json" */ @@ -644,6 +686,13 @@ export interface FavoritesOnDataUpdateSubscription { subscriptionEvent: "favorites_onDataUpdate"; params: FavoritesData; } +/** + * Generated from @see "../messages/freemiumPIRBanner_onDataUpdate.subscribe.json" + */ +export interface FreemiumPIRBannerOnDataUpdateSubscription { + subscriptionEvent: "freemiumPIRBanner_onDataUpdate"; + params: FreemiumPIRBannerData; +} /** * Generated from @see "../messages/nextSteps_onConfigUpdate.subscribe.json" */ diff --git a/special-pages/playwright.config.js b/special-pages/playwright.config.js index e8e8772aa..75f20d18a 100644 --- a/special-pages/playwright.config.js +++ b/special-pages/playwright.config.js @@ -16,11 +16,12 @@ export default defineConfig({ name: 'integration', // prettier-ignore testMatch: [ + 'favorites.spec.js', + 'freemium-pir-banner.spec.js', + 'new-tab.spec.js', 'next-steps.spec.js', 'privacy-stats.spec.js', 'rmf.spec.js', - 'new-tab.spec.js', - 'favorites.spec.js', 'update-notification.spec.js' ], use: {