Skip to content

Commit 7ef4778

Browse files
NTP: FE - Freemium PIR Banner (#1315)
* 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 <[email protected]> * chore: Readd icon after rebase * path update --------- Co-authored-by: Shane Osbourne <[email protected]>
1 parent 2bab275 commit 7ef4778

29 files changed

+780
-37
lines changed

special-pages/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
"prebuild": "node types.mjs && node translations.mjs",
1010
"build": "node index.mjs",
1111
"build.dev": "npm run build -- --env development",
12-
"test-unit": "node --test unit-test/translations.mjs pages/duckplayer/unit-tests/embed-settings.mjs",
12+
"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",
1313
"test-int": "npm run test-unit && npm run build.dev && playwright test --grep-invert '@screenshots'",
1414
"test-int-x": "npm run test-int",
1515
"test.screenshots": "npm run test-unit && npm run build.dev && playwright test --grep '@screenshots'",

special-pages/pages/new-tab/app/components/Examples.jsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
1-
import { h } from 'preact';
1+
import { customizerExamples } from '../customizer/components/Customizer.examples.js';
22
import { favoritesExamples } from '../favorites/components/Favorites.examples.js';
3-
import { otherPrivacyStatsExamples, privacyStatsExamples } from '../privacy-stats/components/PrivacyStats.examples.js';
3+
import { freemiumPIRBannerExamples } from '../freemium-pir-banner/components/FreemiumPIRBanner.examples.js';
44
import { nextStepsExamples, otherNextStepsExamples } from '../next-steps/components/NextSteps.examples.js';
5+
import { otherPrivacyStatsExamples, privacyStatsExamples } from '../privacy-stats/components/PrivacyStats.examples.js';
56
import { otherRMFExamples, RMFExamples } from '../remote-messaging-framework/components/RMF.examples.js';
6-
import { customizerExamples } from '../customizer/components/Customizer.examples.js';
7-
import { noop } from '../utils.js';
87
import { updateNotificationExamples } from '../update-notification/components/UpdateNotification.examples.js';
98

109
/** @type {Record<string, {factory: () => import("preact").ComponentChild}>} */
1110
export const mainExamples = {
1211
...favoritesExamples,
12+
...freemiumPIRBannerExamples,
1313
...nextStepsExamples,
1414
...privacyStatsExamples,
1515
...RMFExamples,
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { h } from 'preact';
2+
import { Centered } from '../components/Layout.js';
3+
import { FreemiumPIRBannerConsumer } from '../freemium-pir-banner/components/FreemiumPIRBanner.js';
4+
import { FreemiumPIRBannerProvider } from '../freemium-pir-banner/FreemiumPIRBannerProvider.js';
5+
6+
export function factory() {
7+
return (
8+
<Centered data-entry-point="freemiumPIRBanner">
9+
<FreemiumPIRBannerProvider>
10+
<FreemiumPIRBannerConsumer />
11+
</FreemiumPIRBannerProvider>
12+
</Centered>
13+
);
14+
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { createContext, h } from 'preact';
2+
import { useCallback, useEffect, useReducer, useRef } from 'preact/hooks';
3+
import { useMessaging } from '../types.js';
4+
import { FreemiumPIRBannerService } from './freemiumPIRBanner.service.js';
5+
import { reducer, useDataSubscription, useInitialData } from '../service.hooks.js';
6+
7+
/**
8+
* @typedef {import('../../types/new-tab.js').FreemiumPIRBannerData} FreemiumPIRBannerData
9+
* @typedef {import('../service.hooks.js').State<FreemiumPIRBannerData, undefined>} State
10+
* @typedef {import('../service.hooks.js').Events<FreemiumPIRBannerData, undefined>} Events
11+
*/
12+
13+
/**
14+
* These are the values exposed to consumers.
15+
*/
16+
export const FreemiumPIRBannerContext = createContext({
17+
/** @type {State} */
18+
state: { status: 'idle', data: null, config: null },
19+
/** @type {(id: string) => void} */
20+
dismiss: (id) => {
21+
throw new Error('must implement dismiss' + id);
22+
},
23+
/** @type {(id: string) => void} */
24+
action: (id) => {
25+
throw new Error('must implement action' + id);
26+
},
27+
});
28+
29+
export const FreemiumPIRBannerDispatchContext = createContext(/** @type {import("preact/hooks").Dispatch<Events>} */ ({}));
30+
31+
/**
32+
* A data provider that will use `FreemiumPIRBannerService` to fetch data, subscribe
33+
* to updates and modify state.
34+
*
35+
* @param {Object} props
36+
* @param {import("preact").ComponentChild} props.children
37+
*/
38+
export function FreemiumPIRBannerProvider(props) {
39+
const initial = /** @type {State} */ ({
40+
status: 'idle',
41+
data: null,
42+
config: null,
43+
});
44+
45+
// const [state, dispatch] = useReducer(withLog('FreemiumPIRBannerProvider', reducer), initial)
46+
const [state, dispatch] = useReducer(reducer, initial);
47+
48+
// create an instance of `FreemiumPIRBannerService` for the lifespan of this component.
49+
const service = useService();
50+
51+
// get initial data
52+
useInitialData({ dispatch, service });
53+
54+
// subscribe to data updates
55+
useDataSubscription({ dispatch, service });
56+
57+
// todo(valerie): implement onDismiss in the service
58+
const dismiss = useCallback(
59+
(id) => {
60+
console.log('onDismiss');
61+
service.current?.dismiss(id);
62+
},
63+
[service],
64+
);
65+
66+
const action = useCallback(
67+
(id) => {
68+
service.current?.action(id);
69+
},
70+
[service],
71+
);
72+
73+
return (
74+
<FreemiumPIRBannerContext.Provider value={{ state, dismiss, action }}>
75+
<FreemiumPIRBannerDispatchContext.Provider value={dispatch}>{props.children}</FreemiumPIRBannerDispatchContext.Provider>
76+
</FreemiumPIRBannerContext.Provider>
77+
);
78+
}
79+
80+
/**
81+
* @return {import("preact").RefObject<FreemiumPIRBannerService>}
82+
*/
83+
export function useService() {
84+
const service = useRef(/** @type {FreemiumPIRBannerService|null} */ (null));
85+
const ntp = useMessaging();
86+
useEffect(() => {
87+
const stats = new FreemiumPIRBannerService(ntp);
88+
service.current = stats;
89+
return () => {
90+
stats.destroy();
91+
};
92+
}, [ntp]);
93+
return service;
94+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { h } from 'preact';
2+
import { noop } from '../../utils.js';
3+
import { FreemiumPIRBanner } from './FreemiumPIRBanner.js';
4+
import { freemiumPIRDataExamples } from '../mocks/freemiumPIRBanner.data.js';
5+
6+
/** @type {Record<string, {factory: () => import("preact").ComponentChild}>} */
7+
8+
export const freemiumPIRBannerExamples = {
9+
'freemiumPIR.onboarding': {
10+
factory: () => (
11+
<FreemiumPIRBanner
12+
message={freemiumPIRDataExamples.onboarding.content}
13+
dismiss={noop('freemiumPIRBanner_dismiss')}
14+
action={noop('freemiumPIRBanner_action')}
15+
/>
16+
),
17+
},
18+
'freemiumPIR.scan_results': {
19+
factory: () => (
20+
<FreemiumPIRBanner
21+
message={freemiumPIRDataExamples.scan_results.content}
22+
dismiss={noop('freemiumPIRBanner_dismiss')}
23+
action={noop('freemiumPIRBanner_action')}
24+
/>
25+
),
26+
},
27+
};
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import cn from 'classnames';
2+
import { h } from 'preact';
3+
import { Button } from '../../../../../shared/components/Button/Button';
4+
import { DismissButton } from '../../components/DismissButton';
5+
import styles from './FreemiumPIRBanner.module.css';
6+
import { FreemiumPIRBannerContext } from '../FreemiumPIRBannerProvider';
7+
import { useContext } from 'preact/hooks';
8+
import { convertMarkdownToHTMLForStrongTags } from '../freemiumPIRBanner.utils';
9+
10+
/**
11+
* @typedef { import("../../../types/new-tab").FreemiumPIRBannerMessage} FreemiumPIRBannerMessage
12+
* @param {object} props
13+
* @param {FreemiumPIRBannerMessage} props.message
14+
* @param {(id: string) => void} props.dismiss
15+
* @param {(id: string) => void} props.action
16+
*/
17+
18+
export function FreemiumPIRBanner({ message, action, dismiss }) {
19+
const processedMessageDescription = convertMarkdownToHTMLForStrongTags(message.descriptionText);
20+
return (
21+
<div id={message.id} class={cn(styles.root, styles.icon)}>
22+
<span class={styles.iconBlock}>
23+
<img src={`./icons/Information-Remover-96.svg`} alt="" />
24+
</span>
25+
<div class={styles.content}>
26+
{message.titleText && <h2 class={styles.title}>{message.titleText}</h2>}
27+
<p class={styles.description} dangerouslySetInnerHTML={{ __html: processedMessageDescription }} />
28+
</div>
29+
{message.messageType === 'big_single_action' && message?.actionText && action && (
30+
<div class={styles.btnBlock}>
31+
<Button variant="standard" onClick={() => action(message.id)}>
32+
{message.actionText}
33+
</Button>
34+
</div>
35+
)}
36+
{message.id && dismiss && <DismissButton className={styles.dismissBtn} onClick={() => dismiss(message.id)} />}
37+
</div>
38+
);
39+
}
40+
41+
export function FreemiumPIRBannerConsumer() {
42+
const { state, action, dismiss } = useContext(FreemiumPIRBannerContext);
43+
44+
if (state.status === 'ready' && state.data.content) {
45+
return <FreemiumPIRBanner message={state.data.content} action={action} dismiss={dismiss} />;
46+
}
47+
return null;
48+
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
.root {
2+
--ntp-freemiumPIR-surface-background-color: rgba(0, 0, 0, .06);
3+
background: var(--ntp-freemiumPIR-surface-background-color);
4+
padding: calc(14 * var(--px-in-rem)) var(--sp-8) calc(14 * var(--px-in-rem)) var(--sp-4);
5+
border-radius: var(--border-radius-lg);
6+
position: relative;
7+
display: flex;
8+
justify-content: flex-start;
9+
align-items: flex-start;
10+
font-family: system-ui, BlinkMacSystemFont, 'Segoe UI', Roboto;
11+
color: var(--ntp-text-normal);
12+
width: 100%;
13+
animation: animate-fade .2s cubic-bezier(0.55, 0.055, 0.666, 0.19);
14+
margin-bottom: var(--ntp-gap);
15+
16+
&.icon {
17+
padding-left: var(--sp-2);
18+
}
19+
20+
@media screen and (prefers-color-scheme: dark) {
21+
background-color: var(--color-white-at-6);
22+
}
23+
}
24+
25+
.iconBlock {
26+
margin-right: var(--sp-2);
27+
width: 3rem;
28+
min-width: 3rem;
29+
}
30+
31+
.content {
32+
flex-grow: 1;
33+
height: 100%;
34+
align-self: center;
35+
}
36+
37+
.title {
38+
font-size: var(--body-font-size);
39+
font-weight: var(--title-2-font-weight);
40+
line-height: normal;
41+
margin-bottom: var(--sp-1);
42+
}
43+
44+
.description {
45+
font-size: var(--body-font-size);
46+
line-height: var(--body-line-height);
47+
}
48+
49+
.btnBlock {
50+
margin-left: var(--sp-3);
51+
align-self: center;
52+
}
53+
54+
.btnRow {
55+
margin-top: var(--sp-3);
56+
display: flex;
57+
flex-wrap: wrap;
58+
gap: calc(10 * var(--px-in-rem));
59+
}
60+
61+
.dismissBtn {
62+
position: absolute;
63+
top: 0.5rem;
64+
right: 0.5rem;
65+
}
66+
67+
68+
@keyframes animate-fade {
69+
0% {
70+
opacity: 0;
71+
scale: 0.98;
72+
}
73+
100% {
74+
opacity: 1;
75+
scale: 1;
76+
}
77+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
---
2+
title: Freemium PIR Banner
3+
---
4+
5+
## Requests:
6+
- {@link "NewTab Messages".FreemiumPIRBannerGetDataRequest `freemiumPIRBanner_getData`}
7+
- Used to fetch the initial data (during the first render)
8+
- returns {@link "NewTab Messages".FreemiumPIRBannerData}
9+
10+
## Subscriptions:
11+
- {@link "NewTab Messages".FreemiumPIRBannerOnDataUpdateSubscription `freemiumPIRBanner_onDataUpdate`}.
12+
- The messages available for the platform
13+
- returns {@link "NewTab Messages".FreemiumPIRBannerData}
14+
15+
## Notifications:
16+
- {@link "NewTab Messages".FreemiumPIRBannerActionNotification `freemiumPIRBanner_action`}
17+
- Sent when the user clicks the action button
18+
- sends {@link "NewTab Messages".FreemiumPIRBannerAction}
19+
- example payload:
20+
```json
21+
{
22+
"id": "onboarding"
23+
}
24+
```
25+
- {@link "NewTab Messages".FreemiumPIRBannerDismissNotification `freemiumPIRBanner_dismiss`}
26+
- Sent when the user clicks the dismiss button
27+
- sends {@link "NewTab Messages".FreemiumPIRBannerDismissAction}
28+
- example payload:
29+
```json
30+
{
31+
"id": "scan_results"
32+
}
33+
```
34+
35+
## Examples:
36+
37+
The following examples show the data types in JSON format:
38+
[messages/new-tab/examples/stats.js](../../messages/examples/freemiumPIRBanner.js)
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
/**
2+
* @typedef {import("../../types/new-tab.js").FreemiumPIRBannerData} FreemiumPIRBannerData
3+
*/
4+
import { Service } from '../service.js';
5+
6+
export class FreemiumPIRBannerService {
7+
/**
8+
* @param {import("../../src/index.js").NewTabPage} ntp - The internal data feed, expected to have a `subscribe` method.
9+
* @internal
10+
*/
11+
constructor(ntp) {
12+
this.ntp = ntp;
13+
/** @type {Service<FreemiumPIRBannerData>} */
14+
this.dataService = new Service({
15+
initial: () => ntp.messaging.request('freemiumPIRBanner_getData'),
16+
subscribe: (cb) => ntp.messaging.subscribe('freemiumPIRBanner_onDataUpdate', cb),
17+
});
18+
}
19+
20+
name() {
21+
return 'FreemiumPIRBannerService';
22+
}
23+
24+
/**
25+
* @returns {Promise<FreemiumPIRBannerData>}
26+
* @internal
27+
*/
28+
async getInitial() {
29+
return await this.dataService.fetchInitial();
30+
}
31+
32+
/**
33+
* @internal
34+
*/
35+
destroy() {
36+
this.dataService.destroy();
37+
}
38+
39+
/**
40+
* @param {(evt: {data: FreemiumPIRBannerData, source: 'manual' | 'subscription'}) => void} cb
41+
* @internal
42+
*/
43+
onData(cb) {
44+
return this.dataService.onData(cb);
45+
}
46+
47+
/**
48+
* @param {string} id
49+
* @internal
50+
*/
51+
dismiss(id) {
52+
return this.ntp.messaging.notify('freemiumPIRBanner_dismiss', { id });
53+
}
54+
55+
/**
56+
* @param {string} id
57+
*/
58+
action(id) {
59+
this.ntp.messaging.notify('freemiumPIRBanner_action', { id });
60+
}
61+
}

0 commit comments

Comments
 (0)