Skip to content

Commit

Permalink
NTP: FE - Freemium PIR Banner (#1315)
Browse files Browse the repository at this point in the history
* feat: Create MessageBar component

* feat: Use MessageBar in RMF

* wip

* chore: fix typing maybe?

* chore: Add to page

* chore: Undo the message bar creation

* fix: compnents view

* fix: Typing issue in NextStepsCards

* fix: naming is hard

* chore: Change PIRBanner message shape

* chore: Move convertMarkdownToHTMLForStrongTags to util file

* fix: naming

* chore: Add tests for the markdown convert util

* chore: Add Playwright tests

* docs: Add PIR Banner docs

* fix: test pause

* fix: mocktransport

* added xss test (#1330)

Co-authored-by: Shane Osbourne <shane.osbourne8@gmail.com>

* chore: Readd icon after rebase

* path update

---------

Co-authored-by: Shane Osbourne <shane.osbourne8@gmail.com>
  • Loading branch information
vkraucunas and shakyShane authored Dec 16, 2024
1 parent 2bab275 commit 7ef4778
Show file tree
Hide file tree
Showing 29 changed files with 780 additions and 37 deletions.
2 changes: 1 addition & 1 deletion special-pages/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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'",
Expand Down
8 changes: 4 additions & 4 deletions special-pages/pages/new-tab/app/components/Examples.jsx
Original file line number Diff line number Diff line change
@@ -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<string, {factory: () => import("preact").ComponentChild}>} */
export const mainExamples = {
...favoritesExamples,
...freemiumPIRBannerExamples,
...nextStepsExamples,
...privacyStatsExamples,
...RMFExamples,
Expand Down
14 changes: 14 additions & 0 deletions special-pages/pages/new-tab/app/entry-points/freemiumPIRBanner.js
Original file line number Diff line number Diff line change
@@ -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 (
<Centered data-entry-point="freemiumPIRBanner">
<FreemiumPIRBannerProvider>
<FreemiumPIRBannerConsumer />
</FreemiumPIRBannerProvider>
</Centered>
);
}
Original file line number Diff line number Diff line change
@@ -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<FreemiumPIRBannerData, undefined>} State
* @typedef {import('../service.hooks.js').Events<FreemiumPIRBannerData, undefined>} 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<Events>} */ ({}));

/**
* 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 (
<FreemiumPIRBannerContext.Provider value={{ state, dismiss, action }}>
<FreemiumPIRBannerDispatchContext.Provider value={dispatch}>{props.children}</FreemiumPIRBannerDispatchContext.Provider>
</FreemiumPIRBannerContext.Provider>
);
}

/**
* @return {import("preact").RefObject<FreemiumPIRBannerService>}
*/
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;
}
Original file line number Diff line number Diff line change
@@ -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<string, {factory: () => import("preact").ComponentChild}>} */

export const freemiumPIRBannerExamples = {
'freemiumPIR.onboarding': {
factory: () => (
<FreemiumPIRBanner
message={freemiumPIRDataExamples.onboarding.content}
dismiss={noop('freemiumPIRBanner_dismiss')}
action={noop('freemiumPIRBanner_action')}
/>
),
},
'freemiumPIR.scan_results': {
factory: () => (
<FreemiumPIRBanner
message={freemiumPIRDataExamples.scan_results.content}
dismiss={noop('freemiumPIRBanner_dismiss')}
action={noop('freemiumPIRBanner_action')}
/>
),
},
};
Original file line number Diff line number Diff line change
@@ -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 (
<div id={message.id} class={cn(styles.root, styles.icon)}>
<span class={styles.iconBlock}>
<img src={`./icons/Information-Remover-96.svg`} alt="" />
</span>
<div class={styles.content}>
{message.titleText && <h2 class={styles.title}>{message.titleText}</h2>}
<p class={styles.description} dangerouslySetInnerHTML={{ __html: processedMessageDescription }} />
</div>
{message.messageType === 'big_single_action' && message?.actionText && action && (
<div class={styles.btnBlock}>
<Button variant="standard" onClick={() => action(message.id)}>
{message.actionText}
</Button>
</div>
)}
{message.id && dismiss && <DismissButton className={styles.dismissBtn} onClick={() => dismiss(message.id)} />}
</div>
);
}

export function FreemiumPIRBannerConsumer() {
const { state, action, dismiss } = useContext(FreemiumPIRBannerContext);

if (state.status === 'ready' && state.data.content) {
return <FreemiumPIRBanner message={state.data.content} action={action} dismiss={dismiss} />;
}
return null;
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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)
Original file line number Diff line number Diff line change
@@ -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<FreemiumPIRBannerData>} */
this.dataService = new Service({
initial: () => ntp.messaging.request('freemiumPIRBanner_getData'),
subscribe: (cb) => ntp.messaging.subscribe('freemiumPIRBanner_onDataUpdate', cb),
});
}

name() {
return 'FreemiumPIRBannerService';
}

/**
* @returns {Promise<FreemiumPIRBannerData>}
* @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 });
}
}
Loading

0 comments on commit 7ef4778

Please sign in to comment.