Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

NTP: FE - Freemium PIR Banner #1315

Merged
merged 20 commits into from
Dec 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
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
Loading