summaryrefslogtreecommitdiffstats
path: root/browser/components/newtab/content-src/asrouter
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-28 14:29:10 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-28 14:29:10 +0000
commit2aa4a82499d4becd2284cdb482213d541b8804dd (patch)
treeb80bf8bf13c3766139fbacc530efd0dd9d54394c /browser/components/newtab/content-src/asrouter
parentInitial commit. (diff)
downloadfirefox-upstream.tar.xz
firefox-upstream.zip
Adding upstream version 86.0.1.upstream/86.0.1upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'browser/components/newtab/content-src/asrouter')
-rw-r--r--browser/components/newtab/content-src/asrouter/README.md34
-rw-r--r--browser/components/newtab/content-src/asrouter/asrouter-content.jsx326
-rw-r--r--browser/components/newtab/content-src/asrouter/asrouter-utils.js108
-rw-r--r--browser/components/newtab/content-src/asrouter/components/Button/Button.jsx32
-rw-r--r--browser/components/newtab/content-src/asrouter/components/Button/_Button.scss94
-rw-r--r--browser/components/newtab/content-src/asrouter/components/ConditionalWrapper/ConditionalWrapper.jsx9
-rw-r--r--browser/components/newtab/content-src/asrouter/components/ImpressionsWrapper/ImpressionsWrapper.jsx76
-rw-r--r--browser/components/newtab/content-src/asrouter/components/ModalOverlay/ModalOverlay.jsx56
-rw-r--r--browser/components/newtab/content-src/asrouter/components/ModalOverlay/_ModalOverlay.scss104
-rw-r--r--browser/components/newtab/content-src/asrouter/components/RichText/RichText.jsx83
-rw-r--r--browser/components/newtab/content-src/asrouter/components/SnippetBase/SnippetBase.jsx121
-rw-r--r--browser/components/newtab/content-src/asrouter/components/SnippetBase/_SnippetBase.scss117
-rw-r--r--browser/components/newtab/content-src/asrouter/docs/cfr_doorhanger_screenshot.pngbin0 -> 257709 bytes
-rw-r--r--browser/components/newtab/content-src/asrouter/docs/debugging-docs.md62
-rw-r--r--browser/components/newtab/content-src/asrouter/docs/debugging-guide.pngbin0 -> 247644 bytes
-rw-r--r--browser/components/newtab/content-src/asrouter/docs/experiment-guide.md52
-rw-r--r--browser/components/newtab/content-src/asrouter/docs/first-run.md9
-rw-r--r--browser/components/newtab/content-src/asrouter/docs/index.rst104
-rw-r--r--browser/components/newtab/content-src/asrouter/docs/message-routing-overview.pngbin0 -> 50250 bytes
-rw-r--r--browser/components/newtab/content-src/asrouter/docs/simple-cfr-template.rst37
-rw-r--r--browser/components/newtab/content-src/asrouter/docs/targeting-attributes.md828
-rw-r--r--browser/components/newtab/content-src/asrouter/docs/targeting-guide.md37
-rw-r--r--browser/components/newtab/content-src/asrouter/docs/telemetry-screenshot.pngbin0 -> 104954 bytes
-rw-r--r--browser/components/newtab/content-src/asrouter/rich-text-strings.js44
-rw-r--r--browser/components/newtab/content-src/asrouter/schemas/message-format.md101
-rw-r--r--browser/components/newtab/content-src/asrouter/schemas/message-group.schema.json63
-rw-r--r--browser/components/newtab/content-src/asrouter/schemas/panel/cfr-fxa-bookmark.schema.json163
-rw-r--r--browser/components/newtab/content-src/asrouter/schemas/provider-response.schema.json75
-rw-r--r--browser/components/newtab/content-src/asrouter/template-utils.js21
-rw-r--r--browser/components/newtab/content-src/asrouter/templates/CFR/templates/CFRUrlbarChiclet.schema.json75
-rw-r--r--browser/components/newtab/content-src/asrouter/templates/CFR/templates/ExtensionDoorhanger.schema.json365
-rw-r--r--browser/components/newtab/content-src/asrouter/templates/CFR/templates/InfoBar.schema.json96
-rw-r--r--browser/components/newtab/content-src/asrouter/templates/EOYSnippet/EOYSnippet.jsx153
-rw-r--r--browser/components/newtab/content-src/asrouter/templates/EOYSnippet/EOYSnippet.schema.json159
-rw-r--r--browser/components/newtab/content-src/asrouter/templates/EOYSnippet/_EOYSnippet.scss54
-rw-r--r--browser/components/newtab/content-src/asrouter/templates/FXASignupSnippet/FXASignupSnippet.jsx38
-rw-r--r--browser/components/newtab/content-src/asrouter/templates/FXASignupSnippet/FXASignupSnippet.schema.json187
-rw-r--r--browser/components/newtab/content-src/asrouter/templates/FirstRun/addUtmParams.js30
-rw-r--r--browser/components/newtab/content-src/asrouter/templates/NewsletterSnippet/NewsletterSnippet.jsx34
-rw-r--r--browser/components/newtab/content-src/asrouter/templates/NewsletterSnippet/NewsletterSnippet.schema.json177
-rw-r--r--browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/OnboardingMessage.jsx52
-rw-r--r--browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/OnboardingMessage.schema.json142
-rw-r--r--browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/ToolbarBadgeMessage.schema.json39
-rw-r--r--browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/UpdateAction.schema.json36
-rw-r--r--browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/WhatsNewMessage.schema.json97
-rw-r--r--browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/_OnboardingMessage.scss131
-rw-r--r--browser/components/newtab/content-src/asrouter/templates/SendToDeviceSnippet/SendToDeviceSnippet.jsx76
-rw-r--r--browser/components/newtab/content-src/asrouter/templates/SendToDeviceSnippet/SendToDeviceSnippet.schema.json234
-rw-r--r--browser/components/newtab/content-src/asrouter/templates/SendToDeviceSnippet/isEmailOrPhoneNumber.js38
-rw-r--r--browser/components/newtab/content-src/asrouter/templates/SimpleBelowSearchSnippet/SimpleBelowSearchSnippet.jsx133
-rw-r--r--browser/components/newtab/content-src/asrouter/templates/SimpleBelowSearchSnippet/SimpleBelowSearchSnippet.schema.json110
-rw-r--r--browser/components/newtab/content-src/asrouter/templates/SimpleBelowSearchSnippet/_SimpleBelowSearchSnippet.scss198
-rw-r--r--browser/components/newtab/content-src/asrouter/templates/SimpleSnippet/SimpleSnippet.jsx225
-rw-r--r--browser/components/newtab/content-src/asrouter/templates/SimpleSnippet/SimpleSnippet.schema.json155
-rw-r--r--browser/components/newtab/content-src/asrouter/templates/SimpleSnippet/_SimpleSnippet.scss135
-rw-r--r--browser/components/newtab/content-src/asrouter/templates/SubmitFormSnippet/SubmitFormScene2Snippet.schema.json163
-rw-r--r--browser/components/newtab/content-src/asrouter/templates/SubmitFormSnippet/SubmitFormSnippet.jsx409
-rw-r--r--browser/components/newtab/content-src/asrouter/templates/SubmitFormSnippet/SubmitFormSnippet.schema.json225
-rw-r--r--browser/components/newtab/content-src/asrouter/templates/SubmitFormSnippet/_SubmitFormSnippet.scss176
-rw-r--r--browser/components/newtab/content-src/asrouter/templates/template-manifest.jsx24
60 files changed, 6922 insertions, 0 deletions
diff --git a/browser/components/newtab/content-src/asrouter/README.md b/browser/components/newtab/content-src/asrouter/README.md
new file mode 100644
index 0000000000..0ee3345630
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/README.md
@@ -0,0 +1,34 @@
+# Activity Stream Router
+
+## Preferences `browser.newtab.activity-stream.asrouter.*`
+
+Name | Used for | Type | Example value
+--- | --- | --- | ---
+`allowHosts` | Allow a host in order to fetch messages from its endpoint | `[String]` | `["gist.github.com", "gist.githubusercontent.com", "localhost:8000"]`
+`providers.snippets` | Message provider options for snippets | `Object` | [see below](#message-providers)
+`providers.cfr` | Message provider options for cfr | `Object` | [see below](#message-providers)
+`providers.onboarding` | Message provider options for onboarding | `Object` | [see below](#message-providers)
+`useRemoteL10n` | Controls whether to use the remote Fluent files for l10n, default as `true` | `Boolean` | `[true|false]`
+
+### Message providers examples
+
+```json
+{
+ "id" : "snippets",
+ "type" : "remote",
+ "enabled": true,
+ "url" : "https://snippets.cdn.mozilla.net/us-west/bundles/bundle_d6d90fb9098ce8b45e60acf601bcb91b68322309.json",
+ "updateCycleInMs" : 14400000
+}
+```
+
+```json
+{
+ "id" : "onboarding",
+ "enabled": true,
+ "type" : "local",
+ "localProvider" : "OnboardingMessageProvider"
+}
+```
+
+### [Snippet message format documentation](https://github.com/mozilla/activity-stream/blob/master/content-src/asrouter/schemas/message-format.md)
diff --git a/browser/components/newtab/content-src/asrouter/asrouter-content.jsx b/browser/components/newtab/content-src/asrouter/asrouter-content.jsx
new file mode 100644
index 0000000000..0ad8999ebc
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/asrouter-content.jsx
@@ -0,0 +1,326 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { MESSAGE_TYPE_HASH as msg } from "common/ActorConstants.jsm";
+import { actionTypes as at } from "common/Actions.jsm";
+import { ASRouterUtils } from "./asrouter-utils";
+import { generateBundles } from "./rich-text-strings";
+import { ImpressionsWrapper } from "./components/ImpressionsWrapper/ImpressionsWrapper";
+import { LocalizationProvider } from "fluent-react";
+import { NEWTAB_DARK_THEME } from "content-src/lib/constants";
+import React from "react";
+import ReactDOM from "react-dom";
+import { SnippetsTemplates } from "./templates/template-manifest";
+
+const TEMPLATES_BELOW_SEARCH = ["simple_below_search_snippet"];
+
+// Note: nextProps/prevProps refer to props passed to <ImpressionsWrapper />, not <ASRouterUISurface />
+function shouldSendImpressionOnUpdate(nextProps, prevProps) {
+ return (
+ nextProps.message.id &&
+ (!prevProps.message || prevProps.message.id !== nextProps.message.id)
+ );
+}
+
+export class ASRouterUISurface extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.sendClick = this.sendClick.bind(this);
+ this.sendImpression = this.sendImpression.bind(this);
+ this.sendUserActionTelemetry = this.sendUserActionTelemetry.bind(this);
+ this.onUserAction = this.onUserAction.bind(this);
+ this.fetchFlowParams = this.fetchFlowParams.bind(this);
+ this.onBlockSelected = this.onBlockSelected.bind(this);
+ this.onBlockById = this.onBlockById.bind(this);
+ this.onDismiss = this.onDismiss.bind(this);
+ this.onMessageFromParent = this.onMessageFromParent.bind(this);
+
+ this.state = { message: {} };
+ if (props.document) {
+ this.footerPortal = props.document.getElementById(
+ "footer-asrouter-container"
+ );
+ }
+ }
+
+ async fetchFlowParams(params = {}) {
+ let result = {};
+ const { fxaEndpoint } = this.props;
+ if (!fxaEndpoint) {
+ const err =
+ "Tried to fetch flow params before fxaEndpoint pref was ready";
+ console.error(err); // eslint-disable-line no-console
+ }
+
+ try {
+ const urlObj = new URL(fxaEndpoint);
+ urlObj.pathname = "metrics-flow";
+ Object.keys(params).forEach(key => {
+ urlObj.searchParams.append(key, params[key]);
+ });
+ const response = await fetch(urlObj.toString(), { credentials: "omit" });
+ if (response.status === 200) {
+ const { deviceId, flowId, flowBeginTime } = await response.json();
+ result = { deviceId, flowId, flowBeginTime };
+ } else {
+ console.error("Non-200 response", response); // eslint-disable-line no-console
+ }
+ } catch (error) {
+ console.error(error); // eslint-disable-line no-console
+ }
+ return result;
+ }
+
+ sendUserActionTelemetry(extraProps = {}) {
+ const { message } = this.state;
+ const eventType = `${message.provider}_user_event`;
+ const source = extraProps.id;
+ delete extraProps.id;
+ ASRouterUtils.sendTelemetry({
+ source,
+ message_id: message.id,
+ action: eventType,
+ ...extraProps,
+ });
+ }
+
+ sendImpression(extraProps) {
+ if (this.state.message.provider === "preview") {
+ return Promise.resolve();
+ }
+
+ this.sendUserActionTelemetry({ event: "IMPRESSION", ...extraProps });
+ return ASRouterUtils.sendMessage({
+ type: msg.IMPRESSION,
+ data: this.state.message,
+ });
+ }
+
+ // If link has a `metric` data attribute send it as part of the `event_context`
+ // telemetry field which can have arbitrary values.
+ // Used for router messages with links as part of the content.
+ sendClick(event) {
+ const { dataset } = event.target;
+ const metric = {
+ event_context: dataset.metric,
+ // Used for the `source` of the event. Needed to differentiate
+ // from other snippet or onboarding events that may occur.
+ id: "NEWTAB_FOOTER_BAR_CONTENT",
+ };
+ const { entrypoint_name, entrypoint_value } = dataset;
+ // Assign the snippet referral for the action
+ const entrypoint = entrypoint_name
+ ? new URLSearchParams([[entrypoint_name, entrypoint_value]]).toString()
+ : entrypoint_value;
+ const action = {
+ type: dataset.action,
+ data: {
+ args: dataset.args,
+ ...(entrypoint && { entrypoint }),
+ },
+ };
+ if (action.type) {
+ ASRouterUtils.executeAction(action);
+ }
+ if (
+ !this.state.message.content.do_not_autoblock &&
+ !dataset.do_not_autoblock
+ ) {
+ this.onBlockById(this.state.message.id);
+ }
+ if (this.state.message.provider !== "preview") {
+ this.sendUserActionTelemetry({ event: "CLICK_BUTTON", ...metric });
+ }
+ }
+
+ onBlockSelected(options) {
+ return this.onBlockById(this.state.message.id, options);
+ }
+
+ onBlockById(id, options) {
+ return ASRouterUtils.blockById(id, options).then(clearAll => {
+ if (clearAll) {
+ this.setState({ message: {} });
+ }
+ });
+ }
+
+ onDismiss() {
+ this.clearMessage(this.state.message.id);
+ }
+
+ clearMessage(id) {
+ if (id === this.state.message.id) {
+ this.setState({ message: {} });
+ }
+ }
+
+ clearProvider(id) {
+ if (this.state.message.provider === id) {
+ this.setState({ message: {} });
+ }
+ }
+
+ onMessageFromParent({ type, data }) {
+ // These only exists due to onPrefChange events in ASRouter
+ switch (type) {
+ case "ClearMessages": {
+ data.forEach(id => this.clearMessage(id));
+ break;
+ }
+ case "ClearProviders": {
+ data.forEach(id => this.clearProvider(id));
+ break;
+ }
+ case "EnterSnippetsPreviewMode": {
+ this.props.dispatch({ type: at.SNIPPETS_PREVIEW_MODE });
+ break;
+ }
+ }
+ }
+
+ requestMessage(endpoint) {
+ ASRouterUtils.sendMessage({
+ type: "NEWTAB_MESSAGE_REQUEST",
+ data: { endpoint },
+ }).then(state => this.setState(state));
+ }
+
+ componentWillMount() {
+ const endpoint = ASRouterUtils.getPreviewEndpoint();
+ if (endpoint && endpoint.theme === "dark") {
+ global.window.dispatchEvent(
+ new CustomEvent("LightweightTheme:Set", {
+ detail: { data: NEWTAB_DARK_THEME },
+ })
+ );
+ }
+ if (endpoint && endpoint.dir === "rtl") {
+ //Set `dir = rtl` on the HTML
+ this.props.document.dir = "rtl";
+ }
+ ASRouterUtils.addListener(this.onMessageFromParent);
+ this.requestMessage(endpoint);
+ }
+
+ componentWillUnmount() {
+ ASRouterUtils.removeListener(this.onMessageFromParent);
+ }
+
+ componentDidUpdate(prevProps) {
+ if (
+ prevProps.adminContent &&
+ JSON.stringify(prevProps.adminContent) !==
+ JSON.stringify(this.props.adminContent)
+ ) {
+ this.updateContent();
+ }
+ }
+
+ updateContent() {
+ this.setState({
+ ...this.props.adminContent,
+ });
+ }
+
+ async getMonitorUrl({ url, flowRequestParams = {} }) {
+ const flowValues = await this.fetchFlowParams(flowRequestParams);
+
+ // Note that flowParams are actually added dynamically on the page
+ const urlObj = new URL(url);
+ ["deviceId", "flowId", "flowBeginTime"].forEach(key => {
+ if (key in flowValues) {
+ urlObj.searchParams.append(key, flowValues[key]);
+ }
+ });
+
+ return urlObj.toString();
+ }
+
+ async onUserAction(action) {
+ switch (action.type) {
+ // This needs to be handled locally because its
+ case "ENABLE_FIREFOX_MONITOR":
+ const url = await this.getMonitorUrl(action.data.args);
+ ASRouterUtils.executeAction({ type: "OPEN_URL", data: { args: url } });
+ break;
+ default:
+ ASRouterUtils.executeAction(action);
+ }
+ }
+
+ renderSnippets() {
+ const { message } = this.state;
+ if (!SnippetsTemplates[message.template]) {
+ return null;
+ }
+ const SnippetComponent = SnippetsTemplates[message.template];
+ const { content } = this.state.message;
+
+ return (
+ <ImpressionsWrapper
+ id="NEWTAB_FOOTER_BAR"
+ message={this.state.message}
+ sendImpression={this.sendImpression}
+ shouldSendImpressionOnUpdate={shouldSendImpressionOnUpdate}
+ // This helps with testing
+ document={this.props.document}
+ >
+ <LocalizationProvider bundles={generateBundles(content)}>
+ <SnippetComponent
+ {...this.state.message}
+ UISurface="NEWTAB_FOOTER_BAR"
+ onBlock={this.onBlockSelected}
+ onDismiss={this.onDismiss}
+ onAction={this.onUserAction}
+ sendClick={this.sendClick}
+ sendUserActionTelemetry={this.sendUserActionTelemetry}
+ />
+ </LocalizationProvider>
+ </ImpressionsWrapper>
+ );
+ }
+
+ renderPreviewBanner() {
+ if (this.state.message.provider !== "preview") {
+ return null;
+ }
+
+ return (
+ <div className="snippets-preview-banner">
+ <span className="icon icon-small-spacer icon-info" />
+ <span>Preview Purposes Only</span>
+ </div>
+ );
+ }
+
+ render() {
+ const { message } = this.state;
+ if (!message.id) {
+ return null;
+ }
+ const shouldRenderBelowSearch = TEMPLATES_BELOW_SEARCH.includes(
+ message.template
+ );
+
+ return shouldRenderBelowSearch ? (
+ // Render special below search snippets in place;
+ <div className="below-search-snippet-wrapper">
+ {this.renderSnippets()}
+ </div>
+ ) : (
+ // For regular snippets etc. we should render everything in our footer
+ // container.
+ ReactDOM.createPortal(
+ <>
+ {this.renderPreviewBanner()}
+ {this.renderSnippets()}
+ </>,
+ this.footerPortal
+ )
+ );
+ }
+}
+
+ASRouterUISurface.defaultProps = { document: global.document };
diff --git a/browser/components/newtab/content-src/asrouter/asrouter-utils.js b/browser/components/newtab/content-src/asrouter/asrouter-utils.js
new file mode 100644
index 0000000000..fe7f0110f2
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/asrouter-utils.js
@@ -0,0 +1,108 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { MESSAGE_TYPE_HASH as msg } from "common/ActorConstants.jsm";
+import { actionCreators as ac } from "common/Actions.jsm";
+
+export const ASRouterUtils = {
+ addListener(listener) {
+ if (global.ASRouterAddParentListener) {
+ global.ASRouterAddParentListener(listener);
+ }
+ },
+ removeListener(listener) {
+ if (global.ASRouterRemoveParentListener) {
+ global.ASRouterRemoveParentListener(listener);
+ }
+ },
+ sendMessage(action) {
+ if (global.ASRouterMessage) {
+ return global.ASRouterMessage(action);
+ }
+ throw new Error(`Unexpected call:\n${JSON.stringify(action, null, 3)}`);
+ },
+ blockById(id, options) {
+ return ASRouterUtils.sendMessage({
+ type: msg.BLOCK_MESSAGE_BY_ID,
+ data: { id, ...options },
+ });
+ },
+ modifyMessageJson(content) {
+ return ASRouterUtils.sendMessage({
+ type: msg.MODIFY_MESSAGE_JSON,
+ data: { content },
+ });
+ },
+ executeAction(button_action) {
+ return ASRouterUtils.sendMessage({
+ type: msg.USER_ACTION,
+ data: button_action,
+ });
+ },
+ unblockById(id) {
+ return ASRouterUtils.sendMessage({
+ type: msg.UNBLOCK_MESSAGE_BY_ID,
+ data: { id },
+ });
+ },
+ blockBundle(bundle) {
+ return ASRouterUtils.sendMessage({
+ type: msg.BLOCK_BUNDLE,
+ data: { bundle },
+ });
+ },
+ unblockBundle(bundle) {
+ return ASRouterUtils.sendMessage({
+ type: msg.UNBLOCK_BUNDLE,
+ data: { bundle },
+ });
+ },
+ overrideMessage(id) {
+ return ASRouterUtils.sendMessage({
+ type: msg.OVERRIDE_MESSAGE,
+ data: { id },
+ });
+ },
+ sendTelemetry(ping) {
+ return ASRouterUtils.sendMessage(ac.ASRouterUserEvent(ping));
+ },
+ getPreviewEndpoint() {
+ if (
+ global.document &&
+ global.document.location &&
+ global.document.location.href.includes("endpoint")
+ ) {
+ const params = new URLSearchParams(
+ global.document.location.href.slice(
+ global.document.location.href.indexOf("endpoint")
+ )
+ );
+ try {
+ const endpoint = new URL(params.get("endpoint"));
+ return {
+ url: endpoint.href,
+ snippetId: params.get("snippetId"),
+ theme: this.getPreviewTheme(),
+ dir: this.getPreviewDir(),
+ };
+ } catch (e) {}
+ }
+
+ return null;
+ },
+ getPreviewTheme() {
+ return new URLSearchParams(
+ global.document.location.href.slice(
+ global.document.location.href.indexOf("theme")
+ )
+ ).get("theme");
+ },
+ getPreviewDir() {
+ return new URLSearchParams(
+ global.document.location.href.slice(
+ global.document.location.href.indexOf("dir")
+ )
+ ).get("dir");
+ },
+};
diff --git a/browser/components/newtab/content-src/asrouter/components/Button/Button.jsx b/browser/components/newtab/content-src/asrouter/components/Button/Button.jsx
new file mode 100644
index 0000000000..b3ece86f16
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/components/Button/Button.jsx
@@ -0,0 +1,32 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import React from "react";
+
+const ALLOWED_STYLE_TAGS = ["color", "backgroundColor"];
+
+export const Button = props => {
+ const style = {};
+
+ // Add allowed style tags from props, e.g. props.color becomes style={color: props.color}
+ for (const tag of ALLOWED_STYLE_TAGS) {
+ if (typeof props[tag] !== "undefined") {
+ style[tag] = props[tag];
+ }
+ }
+ // remove border if bg is set to something custom
+ if (style.backgroundColor) {
+ style.border = "0";
+ }
+
+ return (
+ <button
+ onClick={props.onClick}
+ className={props.className || "ASRouterButton secondary"}
+ style={style}
+ >
+ {props.children}
+ </button>
+ );
+};
diff --git a/browser/components/newtab/content-src/asrouter/components/Button/_Button.scss b/browser/components/newtab/content-src/asrouter/components/Button/_Button.scss
new file mode 100644
index 0000000000..330bfbb4fb
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/components/Button/_Button.scss
@@ -0,0 +1,94 @@
+.ASRouterButton {
+ font-weight: 600;
+ font-size: 14px;
+ white-space: nowrap;
+ border-radius: 2px;
+ border: 0;
+ font-family: inherit;
+ padding: 8px 15px;
+ margin-inline-start: 12px;
+ color: inherit;
+ cursor: pointer;
+
+ .tall & {
+ margin-inline-start: 20px;
+ }
+
+ &.test-only {
+ width: 0;
+ height: 0;
+ overflow: hidden;
+ display: block;
+ visibility: hidden;
+ }
+
+ &.primary {
+ border: 1px solid var(--newtab-button-primary-color);
+ background-color: var(--newtab-button-primary-color);
+ color: $grey-10;
+
+ &:hover {
+ background-color: $blue-70;
+ }
+
+ &:active {
+ background-color: $blue-80;
+ }
+ }
+
+ &.secondary {
+ background-color: $grey-90-10;
+
+ &:hover {
+ background-color: $grey-90-20;
+ }
+
+ &:active {
+ background-color: $grey-90-30;
+ }
+
+ &:focus {
+ box-shadow: 0 0 0 1px $blue-50 inset, 0 0 0 1px $blue-50, 0 0 0 4px $blue-50-30;
+ }
+ }
+
+ &.slim {
+ background-color: $grey-90-10;
+ margin-inline-start: 0;
+ font-size: 12px;
+ padding: 6px 12px;
+
+ &:hover {
+ background-color: $grey-90-20;
+ }
+ }
+}
+
+[lwt-newtab-brighttext] {
+ .secondary {
+ background-color: $grey-10-10;
+
+ &:hover {
+ background-color: $grey-10-20;
+ }
+
+ &:active {
+ background-color: $grey-10-30;
+ }
+ }
+
+ // Snippets scene 2 footer
+ .footer {
+ .secondary {
+ background-color: $grey-10-30;
+
+ &:hover {
+ background-color: $grey-10-40;
+ }
+
+ &:active {
+ background-color: $grey-10-50;
+ }
+ }
+ }
+}
diff --git a/browser/components/newtab/content-src/asrouter/components/ConditionalWrapper/ConditionalWrapper.jsx b/browser/components/newtab/content-src/asrouter/components/ConditionalWrapper/ConditionalWrapper.jsx
new file mode 100644
index 0000000000..e4b0812f26
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/components/ConditionalWrapper/ConditionalWrapper.jsx
@@ -0,0 +1,9 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+// lifted from https://gist.github.com/kitze/23d82bb9eb0baabfd03a6a720b1d637f
+const ConditionalWrapper = ({ condition, wrap, children }) =>
+ condition && wrap ? wrap(children) : children;
+
+export default ConditionalWrapper;
diff --git a/browser/components/newtab/content-src/asrouter/components/ImpressionsWrapper/ImpressionsWrapper.jsx b/browser/components/newtab/content-src/asrouter/components/ImpressionsWrapper/ImpressionsWrapper.jsx
new file mode 100644
index 0000000000..8498bde03b
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/components/ImpressionsWrapper/ImpressionsWrapper.jsx
@@ -0,0 +1,76 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import React from "react";
+
+export const VISIBLE = "visible";
+export const VISIBILITY_CHANGE_EVENT = "visibilitychange";
+
+/**
+ * Component wrapper used to send telemetry pings on every impression.
+ */
+export class ImpressionsWrapper extends React.PureComponent {
+ // This sends an event when a user sees a set of new content. If content
+ // changes while the page is hidden (i.e. preloaded or on a hidden tab),
+ // only send the event if the page becomes visible again.
+ sendImpressionOrAddListener() {
+ if (this.props.document.visibilityState === VISIBLE) {
+ this.props.sendImpression({ id: this.props.id });
+ } else {
+ // We should only ever send the latest impression stats ping, so remove any
+ // older listeners.
+ if (this._onVisibilityChange) {
+ this.props.document.removeEventListener(
+ VISIBILITY_CHANGE_EVENT,
+ this._onVisibilityChange
+ );
+ }
+
+ // When the page becomes visible, send the impression stats ping if the section isn't collapsed.
+ this._onVisibilityChange = () => {
+ if (this.props.document.visibilityState === VISIBLE) {
+ this.props.sendImpression({ id: this.props.id });
+ this.props.document.removeEventListener(
+ VISIBILITY_CHANGE_EVENT,
+ this._onVisibilityChange
+ );
+ }
+ };
+ this.props.document.addEventListener(
+ VISIBILITY_CHANGE_EVENT,
+ this._onVisibilityChange
+ );
+ }
+ }
+
+ componentWillUnmount() {
+ if (this._onVisibilityChange) {
+ this.props.document.removeEventListener(
+ VISIBILITY_CHANGE_EVENT,
+ this._onVisibilityChange
+ );
+ }
+ }
+
+ componentDidMount() {
+ if (this.props.sendOnMount) {
+ this.sendImpressionOrAddListener();
+ }
+ }
+
+ componentDidUpdate(prevProps) {
+ if (this.props.shouldSendImpressionOnUpdate(this.props, prevProps)) {
+ this.sendImpressionOrAddListener();
+ }
+ }
+
+ render() {
+ return this.props.children;
+ }
+}
+
+ImpressionsWrapper.defaultProps = {
+ document: global.document,
+ sendOnMount: true,
+};
diff --git a/browser/components/newtab/content-src/asrouter/components/ModalOverlay/ModalOverlay.jsx b/browser/components/newtab/content-src/asrouter/components/ModalOverlay/ModalOverlay.jsx
new file mode 100644
index 0000000000..fdfdf22db2
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/components/ModalOverlay/ModalOverlay.jsx
@@ -0,0 +1,56 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import React from "react";
+
+export class ModalOverlayWrapper extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.onKeyDown = this.onKeyDown.bind(this);
+ }
+
+ // The intended behaviour is to listen for an escape key
+ // but not for a click; see Bug 1582242
+ onKeyDown(event) {
+ if (event.key === "Escape") {
+ this.props.onClose(event);
+ }
+ }
+
+ componentWillMount() {
+ this.props.document.addEventListener("keydown", this.onKeyDown);
+ this.props.document.body.classList.add("modal-open");
+ }
+
+ componentWillUnmount() {
+ this.props.document.removeEventListener("keydown", this.onKeyDown);
+ this.props.document.body.classList.remove("modal-open");
+ }
+
+ render() {
+ const { props } = this;
+ let className = props.unstyled ? "" : "modalOverlayInner active";
+ if (props.innerClassName) {
+ className += ` ${props.innerClassName}`;
+ }
+ return (
+ <div
+ className="modalOverlayOuter active"
+ onKeyDown={this.onKeyDown}
+ role="presentation"
+ >
+ <div
+ className={className}
+ aria-labelledby={props.headerId}
+ id={props.id}
+ role="dialog"
+ >
+ {props.children}
+ </div>
+ </div>
+ );
+ }
+}
+
+ModalOverlayWrapper.defaultProps = { document: global.document };
diff --git a/browser/components/newtab/content-src/asrouter/components/ModalOverlay/_ModalOverlay.scss b/browser/components/newtab/content-src/asrouter/components/ModalOverlay/_ModalOverlay.scss
new file mode 100644
index 0000000000..2cdbfcb1db
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/components/ModalOverlay/_ModalOverlay.scss
@@ -0,0 +1,104 @@
+// Variable for the about:welcome modal scrollbars
+$modal-scrollbar-z-index: 1100;
+
+.activity-stream {
+ &.modal-open {
+ overflow: hidden;
+ }
+}
+
+.modalOverlayOuter {
+ background: var(--newtab-overlay-color);
+ height: 100%;
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ display: none;
+ z-index: $modal-scrollbar-z-index;
+ overflow: auto;
+
+ &.active {
+ display: flex;
+ }
+}
+
+.modalOverlayInner {
+ min-width: min-content;
+ width: 100%;
+ max-width: 960px;
+ position: relative;
+ margin: auto;
+ background: var(--newtab-modal-color);
+ box-shadow: 0 1px 15px 0 $black-30;
+ border-radius: 4px;
+ display: none;
+ z-index: $modal-scrollbar-z-index;
+
+ // modal takes over entire screen
+ @media(max-width: 960px) {
+ height: 100%;
+ top: 0;
+ left: 0;
+ box-shadow: none;
+ border-radius: 0;
+ }
+
+ &.active {
+ display: block;
+ }
+
+ h2 {
+ color: $grey-60;
+ text-align: center;
+ font-weight: 200;
+ margin-top: 30px;
+ font-size: 28px;
+ line-height: 37px;
+ letter-spacing: -0.13px;
+
+ @media(max-width: 960px) {
+ margin-top: 100px;
+ }
+
+ @media(max-width: 850px) {
+ margin-top: 30px;
+ }
+ }
+
+ .footer {
+ border-top: 1px solid $grey-30;
+ border-radius: 4px;
+ height: 70px;
+ width: 100%;
+ position: absolute;
+ bottom: 0;
+ text-align: center;
+ background-color: $white;
+
+ // if modal is short enough, footer becomes sticky
+ @media(max-width: 850px) and (max-height: 730px) {
+ position: sticky;
+ }
+
+ // if modal is narrow enough, footer becomes sticky
+ @media(max-width: 650px) and (max-height: 600px) {
+ position: sticky;
+ }
+
+ .modalButton {
+ margin-top: 20px;
+ min-width: 150px;
+ height: 30px;
+ padding: 4px 30px 6px;
+ font-size: 15px;
+
+ &:focus,
+ &.active,
+ &:hover {
+ box-shadow: 0 0 0 5px $grey-30;
+ transition: box-shadow 150ms;
+ }
+ }
+ }
+}
diff --git a/browser/components/newtab/content-src/asrouter/components/RichText/RichText.jsx b/browser/components/newtab/content-src/asrouter/components/RichText/RichText.jsx
new file mode 100644
index 0000000000..45e35b83cc
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/components/RichText/RichText.jsx
@@ -0,0 +1,83 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { Localized } from "fluent-react";
+import React from "react";
+import { RICH_TEXT_KEYS } from "../../rich-text-strings";
+import { safeURI } from "../../template-utils";
+
+// Elements allowed in snippet content
+const ALLOWED_TAGS = {
+ b: <b />,
+ i: <i />,
+ u: <u />,
+ strong: <strong />,
+ em: <em />,
+ br: <br />,
+};
+
+/**
+ * Transform an object (tag name: {url}) into (tag name: anchor) where the url
+ * is used as href, in order to render links inside a Fluent.Localized component.
+ */
+export function convertLinks(
+ links,
+ sendClick,
+ doNotAutoBlock,
+ openNewWindow = false
+) {
+ if (links) {
+ return Object.keys(links).reduce((acc, linkTag) => {
+ const { action } = links[linkTag];
+ // Setting the value to false will not include the attribute in the anchor
+ const url = action ? false : safeURI(links[linkTag].url);
+
+ acc[linkTag] = (
+ // eslint was getting a false positive caused by the dynamic injection
+ // of content.
+ // eslint-disable-next-line jsx-a11y/anchor-has-content
+ <a
+ href={url}
+ target={openNewWindow ? "_blank" : ""}
+ data-metric={links[linkTag].metric}
+ data-action={action}
+ data-args={links[linkTag].args}
+ data-do_not_autoblock={doNotAutoBlock}
+ data-entrypoint_name={links[linkTag].entrypoint_name}
+ data-entrypoint_value={links[linkTag].entrypoint_value}
+ onClick={sendClick}
+ />
+ );
+ return acc;
+ }, {});
+ }
+
+ return null;
+}
+
+/**
+ * Message wrapper used to sanitize markup and render HTML.
+ */
+export function RichText(props) {
+ if (!RICH_TEXT_KEYS.includes(props.localization_id)) {
+ throw new Error(
+ `ASRouter: ${props.localization_id} is not a valid rich text property. If you want it to be processed, you need to add it to asrouter/rich-text-strings.js`
+ );
+ }
+ return (
+ <Localized
+ id={props.localization_id}
+ {...ALLOWED_TAGS}
+ {...props.customElements}
+ {...convertLinks(
+ props.links,
+ props.sendClick,
+ props.doNotAutoBlock,
+ props.openNewWindow
+ )}
+ >
+ <span>{props.text}</span>
+ </Localized>
+ );
+}
diff --git a/browser/components/newtab/content-src/asrouter/components/SnippetBase/SnippetBase.jsx b/browser/components/newtab/content-src/asrouter/components/SnippetBase/SnippetBase.jsx
new file mode 100644
index 0000000000..fd25337fbf
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/components/SnippetBase/SnippetBase.jsx
@@ -0,0 +1,121 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import React from "react";
+
+export class SnippetBase extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.onBlockClicked = this.onBlockClicked.bind(this);
+ this.onDismissClicked = this.onDismissClicked.bind(this);
+ this.setBlockButtonRef = this.setBlockButtonRef.bind(this);
+ this.onBlockButtonMouseEnter = this.onBlockButtonMouseEnter.bind(this);
+ this.onBlockButtonMouseLeave = this.onBlockButtonMouseLeave.bind(this);
+ this.state = { blockButtonHover: false };
+ }
+
+ componentDidMount() {
+ if (this.blockButtonRef) {
+ this.blockButtonRef.addEventListener(
+ "mouseenter",
+ this.onBlockButtonMouseEnter
+ );
+ this.blockButtonRef.addEventListener(
+ "mouseleave",
+ this.onBlockButtonMouseLeave
+ );
+ }
+ }
+
+ componentWillUnmount() {
+ if (this.blockButtonRef) {
+ this.blockButtonRef.removeEventListener(
+ "mouseenter",
+ this.onBlockButtonMouseEnter
+ );
+ this.blockButtonRef.removeEventListener(
+ "mouseleave",
+ this.onBlockButtonMouseLeave
+ );
+ }
+ }
+
+ setBlockButtonRef(element) {
+ this.blockButtonRef = element;
+ }
+
+ onBlockButtonMouseEnter() {
+ this.setState({ blockButtonHover: true });
+ }
+
+ onBlockButtonMouseLeave() {
+ this.setState({ blockButtonHover: false });
+ }
+
+ onBlockClicked() {
+ if (this.props.provider !== "preview") {
+ this.props.sendUserActionTelemetry({
+ event: "BLOCK",
+ id: this.props.UISurface,
+ });
+ }
+
+ this.props.onBlock();
+ }
+
+ onDismissClicked() {
+ if (this.props.provider !== "preview") {
+ this.props.sendUserActionTelemetry({
+ event: "DISMISS",
+ id: this.props.UISurface,
+ });
+ }
+
+ this.props.onDismiss();
+ }
+
+ renderDismissButton() {
+ if (this.props.footerDismiss) {
+ return (
+ <div className="footer">
+ <div className="footer-content">
+ <button
+ className="ASRouterButton secondary"
+ onClick={this.onDismissClicked}
+ >
+ {this.props.content.scene2_dismiss_button_text}
+ </button>
+ </div>
+ </div>
+ );
+ }
+
+ const label = this.props.content.block_button_text || "Remove this";
+ return (
+ <button
+ className="blockButton"
+ title={label}
+ aria-label={label}
+ onClick={this.onBlockClicked}
+ ref={this.setBlockButtonRef}
+ />
+ );
+ }
+
+ render() {
+ const { props } = this;
+ const { blockButtonHover } = this.state;
+
+ const containerClassName = `SnippetBaseContainer${
+ props.className ? ` ${props.className}` : ""
+ }${blockButtonHover ? " active" : ""}`;
+
+ return (
+ <div className={containerClassName} style={this.props.textStyle}>
+ <div className="innerWrapper">{props.children}</div>
+ {this.renderDismissButton()}
+ </div>
+ );
+ }
+}
diff --git a/browser/components/newtab/content-src/asrouter/components/SnippetBase/_SnippetBase.scss b/browser/components/newtab/content-src/asrouter/components/SnippetBase/_SnippetBase.scss
new file mode 100644
index 0000000000..cfa090f89b
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/components/SnippetBase/_SnippetBase.scss
@@ -0,0 +1,117 @@
+.SnippetBaseContainer {
+ position: fixed;
+ z-index: 2;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ background-color: var(--newtab-snippets-background-color);
+ color: var(--newtab-text-primary-color);
+ font-size: 14px;
+ line-height: 20px;
+ border-top: 1px solid var(--newtab-snippets-hairline-color);
+ box-shadow: $shadow-secondary;
+ display: flex;
+ align-items: center;
+
+ a {
+ cursor: pointer;
+ color: var(--newtab-link-primary-color);
+
+ &:hover {
+ text-decoration: underline;
+ }
+
+ [lwt-newtab-brighttext] & {
+ font-weight: bold;
+ }
+ }
+
+ input {
+ &[type='checkbox'] {
+ margin-inline-start: 0;
+ }
+ }
+
+ .innerWrapper {
+ margin: 0 auto;
+ display: flex;
+ align-items: center;
+ padding: 12px $section-horizontal-padding;
+
+ // This is to account for the block button on smaller screens
+ padding-inline-end: 36px;
+ @media (min-width: $break-point-large) {
+ padding-inline-end: $section-horizontal-padding;
+ }
+
+ max-width: $wrapper-max-width-large + ($section-horizontal-padding * 2);
+ @media (min-width: $break-point-widest) {
+ max-width: $wrapper-max-width-widest + ($section-horizontal-padding * 2);
+ }
+ }
+
+ .blockButton {
+ display: none;
+ background: none;
+ border: 0;
+ position: absolute;
+ top: 50%;
+ inset-inline-end: 12px;
+ height: 16px;
+ width: 16px;
+ background-image: url('chrome://activity-stream/content/data/content/assets/glyph-dismiss-16.svg');
+ -moz-context-properties: fill;
+ color: inherit;
+ fill: currentColor;
+ opacity: 0.5;
+ margin-top: -8px;
+ padding: 0;
+ cursor: pointer;
+ }
+
+ &:hover .blockButton {
+ display: block;
+ }
+
+ .icon {
+ height: 42px;
+ width: 42px;
+ margin-inline-end: 12px;
+ flex-shrink: 0;
+ }
+}
+
+.snippets-preview-banner {
+ font-size: 15px;
+ line-height: 42px;
+ color: $grey-60-70;
+ background: $grey-30-60;
+ text-align: center;
+ position: absolute;
+ top: 0;
+ width: 100%;
+
+ span {
+ vertical-align: middle;
+ }
+}
+
+// We show snippet icons for both themes and conditionally hide
+// based on which theme is currently active
+body {
+ &:not([lwt-newtab-brighttext]) {
+ .icon-dark-theme,
+ .icon.icon-dark-theme,
+ .scene2Icon .icon-dark-theme {
+ display: none;
+ }
+ }
+
+ &[lwt-newtab-brighttext] {
+ .icon-light-theme,
+ .icon.icon-light-theme,
+ .scene2Icon .icon-light-theme {
+ display: none;
+ }
+ }
+}
diff --git a/browser/components/newtab/content-src/asrouter/docs/cfr_doorhanger_screenshot.png b/browser/components/newtab/content-src/asrouter/docs/cfr_doorhanger_screenshot.png
new file mode 100644
index 0000000000..aee3bcf3bd
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/docs/cfr_doorhanger_screenshot.png
Binary files differ
diff --git a/browser/components/newtab/content-src/asrouter/docs/debugging-docs.md b/browser/components/newtab/content-src/asrouter/docs/debugging-docs.md
new file mode 100644
index 0000000000..035118b987
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/docs/debugging-docs.md
@@ -0,0 +1,62 @@
+# Using ASRouter Devtools
+
+## How to enable ASRouter devtools
+- In `about:config`, set `browser.newtabpage.activity-stream.asrouter.devtoolsEnabled` to `true`
+- Visit `about:newtab#asrouter` to see the devtools.
+
+## Overview of ASRouter devtools
+
+![Devtools image](./debugging-guide.png)
+
+## How to enable/disable a provider
+
+To enable a provider such as `snippets`, Look at the list of "Message Providers" at the top of the page. Make sure the checkbox is checked next to the provider you want to enable.
+
+To disable it, uncheck the checkbox. You should see a red label indicating the provider is now disabled.
+
+## How to see all messages from a provider
+
+(Only available in Firefox 65+)
+
+In order to see all active messages for a current provider such as `snippets`, use the drop down selector under the "Messages" section. Select the name of the provider you are interested in.
+
+The messages on the page should now be filtered to include only the provider you selected.
+
+## How to test telemetry pings
+
+To test telemetry pings, complete the the following steps:
+
+- In about:config, set:
+ - `browser.newtabpage.activity-stream.telemetry` to `true`
+ - `browser.ping-centre.log` to `true`
+- Open the Browser Toolbox devtools (Tools > Web Developer > Browser Toolbox) and switch to the console tab. Add a filter for for `activity-stream` to only display relevant pings:
+
+![Devtools telemetry ping](./telemetry-screenshot.png)
+
+You should now see pings show up as you view/interact with ASR messages/templates.
+
+## Snippets debugging
+
+### How to view preview URLs
+
+Follow these steps to view preview URLs (e.g. `about:newtab?endpoint=https://gist.githubusercontent.com/piatra/d193ca7e0f513cc19fc6a1d396c214f7/raw/8bcaf9548212e4c613577e839198cc14e7317630/newsletter_snippet.json&theme=dark`)
+
+You can preview in the two different themes (light and dark) by adding `&theme=dark` or `&theme=light` at the end of the url.
+
+#### IMPORTANT NOTES
+- Links to URLs starting with `about:newtab` cannot be clicked on directly. They must be copy and pasted into the address bar.
+- Previews should only be tested in `Firefox 64` and later.
+- The endpoint must be HTTPS, the host must be allowed (see testing instructions below)
+- Errors are surfaced in the `Console` tab of the `Browser Toolbox`
+
+#### Testing instructions
+- If your endpoint URL has a host name of `snippets-admin.mozilla.org`, you can paste the URL into the address bar view it without any further steps.
+- If your endpoint URL starts with some other host name, it must be **allowed**. Open the Browser Toolbox devtools (Tools > Developer > Browser Toolbox) and paste the following code (where `gist.githubusercontent.com` is the hostname of your endpoint URL):
+```js
+Services.prefs.setStringPref(
+ "browser.newtab.activity-stream.asrouter.allowHosts",
+ "[\"gist.githubusercontent.com\"]"
+);
+```
+- Restart the browser
+- You should now be able to paste the URL into the address bar and view it.
diff --git a/browser/components/newtab/content-src/asrouter/docs/debugging-guide.png b/browser/components/newtab/content-src/asrouter/docs/debugging-guide.png
new file mode 100644
index 0000000000..8616a29ab3
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/docs/debugging-guide.png
Binary files differ
diff --git a/browser/components/newtab/content-src/asrouter/docs/experiment-guide.md b/browser/components/newtab/content-src/asrouter/docs/experiment-guide.md
new file mode 100644
index 0000000000..ac2784bb1f
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/docs/experiment-guide.md
@@ -0,0 +1,52 @@
+# How to run experiments with ASRouter
+
+This guide will tell you how to run an experiment with ASRouter messages.
+Note that the actual experiment process and infrastructure is handled by
+the experiments team (#ask-experimenter).
+
+## Why run an experiment
+
+* To measure the effect of a message on a Firefox metric (e.g. retention)
+* To test a potentially risky message on a smaller group of users
+* To compare the performance of multiple variants of messages in a controlled way
+
+## Choose cohort IDs and request an experiment
+
+First you should decide on a cohort ID (this can be any arbitrary unique string) for each
+individual group you need to segment for your experiment.
+
+For example, if I want to test two variants of an FXA Snippet, I might have two cohort IDs,
+`FXA_SNIPPET_V1` and `FXA_SNIPPET_V2`.
+
+You will then [request](https://experimenter.services.mozilla.com/) a new "pref-flip" study with the Firefox Experiments team.
+The preferences you will submit will be based on the cohort IDs you chose.
+
+For the FXA Snippet example, your preference name would be `browser.newtabpage.activity-stream.asrouter.providers.snippets` and values would be:
+
+Control (default value)
+```json
+{"id":"snippets","enabled":true,"type":"remote","url":"https://snippets.cdn.mozilla.net/%STARTPAGE_VERSION%/%NAME%/%VERSION%/%APPBUILDID%/%BUILD_TARGET%/%LOCALE%/release/%OS_VERSION%/%DISTRIBUTION%/%DISTRIBUTION_VERSION%/","updateCycleInMs":14400000}
+```
+
+Variant 1:
+```json
+{"id":"snippets", "cohort": "FXA_SNIPPET_V1", "enabled":true,"type":"remote","url":"https://snippets.cdn.mozilla.net/%STARTPAGE_VERSION%/%NAME%/%VERSION%/%APPBUILDID%/%BUILD_TARGET%/%LOCALE%/release/%OS_VERSION%/%DISTRIBUTION%/%DISTRIBUTION_VERSION%/","updateCycleInMs":14400000}
+```
+
+Variant 2:
+```json
+{"id":"snippets", "cohort": "FXA_SNIPPET_V1", "enabled":true,"type":"remote","url":"https://snippets.cdn.mozilla.net/%STARTPAGE_VERSION%/%NAME%/%VERSION%/%APPBUILDID%/%BUILD_TARGET%/%LOCALE%/release/%OS_VERSION%/%DISTRIBUTION%/%DISTRIBUTION_VERSION%/","updateCycleInMs":14400000}
+```
+
+## Add targeting to your messages
+
+You must now check for the cohort ID in the `targeting` expression of the messages you want to include in your experiments.
+
+For the previous example, you wold include the following to target the first cohort:
+
+```json
+{
+ "targeting": "providerCohorts.snippets == \"FXA_SNIPPET_V1\""
+}
+
+```
diff --git a/browser/components/newtab/content-src/asrouter/docs/first-run.md b/browser/components/newtab/content-src/asrouter/docs/first-run.md
new file mode 100644
index 0000000000..82ccde3e39
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/docs/first-run.md
@@ -0,0 +1,9 @@
+# First run on-boarding flow
+
+First Run flow describes the entire experience users have after Firefox has successfully been installed up until the first instance of new tab is shown.
+First run help onboard new users by showing relevant messaging on about:welcome and about:newtab using triplets.
+
+### First Run Multistage
+A full-page multistep experience that shows up on first run since Fx80 with browser.aboutwelcome.enabled pref as true.
+
+Setting browser.aboutwelcome.enabled to false make first run looks like about:newtab and hides about:welcome
diff --git a/browser/components/newtab/content-src/asrouter/docs/index.rst b/browser/components/newtab/content-src/asrouter/docs/index.rst
new file mode 100644
index 0000000000..87476d32ac
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/docs/index.rst
@@ -0,0 +1,104 @@
+================
+Messaging System
+================
+
+Vision
+------
+Firefox must be an opinionated user agent that keeps folks safe, informed and
+effective while browsing the Web. In order to have an opinion, Firefox must
+have a voice.
+
+That voice will **respect the user’s attention** while surfacing contextually
+relevant and timely information tailored to their individual needs and choices.
+
+What does Messaging System support?
+-----------------------------------
+There are several key windows of opportunity, such as the first-run activation
+phase or coordinated feature releases, where Firefox engages with users.
+
+The Firefox Messaging System supports this engagement by targeting messages
+exactly to the users who need to see them and enables the development of new
+user messages that can be easily tested and deployed. It offers standard
+mechanisms to measure user engagement and to perform user messaging experiments
+with reduced effort across engineering teams and a faster delivery cycle from
+ideation to analysis of results.
+
+This translates to **users seeing fewer and more relevant in-product
+messages**, while supporting fast delivery, experimentation, and protection of
+our users time and attention.
+
+Messaging System Overview
+-------------------------
+At the core of the Firefox Messaging System is the Messaging System Router
+(called ASRouter for historical reasons). The router is a generalized Firefox
+component and set of conventions that provides:
+
+* Flexible and configurable routing of local or remote Messages to UI
+ Templates. This allows new message campaigns to be started and controlled
+ on or off-trains
+* Traffic Cop message sequencing and intermediation to prevent multiple
+ messages being concurrently shown
+* Programmable message targeting language to show the right message to the
+ right user at the right time
+* A template library of reusable Message and Notification UIs
+* Full compatibility with Normandy pref-flip experiments
+* Generalized and privacy conscious event telemetry
+* Flexible Frequency Capping to mitigate user message fatigue
+* Localized off train Messages
+* Powerful development/debugging/QA tools on about:newtab#devtools
+
+Message Routing
+---------------
+.. image:: ./message-routing-overview.png
+ :align: center
+ :alt: Message Routing Overview
+
+The Firefox Messaging System implements a separation-of-concerns pattern for
+Messages, UI Templates, and Timing/Targeting mechanisms. This allows us to
+maintain a high standard of security and quality while still allowing for
+maximum flexibility around content creation.
+
+UI Templates
+------------
+We have built a library of reusable Notification and Message interfaces which
+land in the Firefox codebase and ride the trains. These templates have a
+defined schema according to the available design components (e.g. titles, text,
+icons) and access to a set of enhanced user actions such as triggering URLs,
+launching menus, or installing addons, which can be attached to interactive
+elements (such as buttons).
+
+Current templates include\:
+
+* What's New Panel - an icon in the toolbar and menu item that appears if a
+ message is available in the panel, usually after major Firefox releases
+* Moments Page - appears on start-up as a full content page
+* Contextual Feature Recommendation - highlighted word in the Location Bar
+ that, if clicked, drops down a panel with information about a feature
+ relevant to that user at that time
+* First Run - shown on startup in a content page as a set of onboarding cards
+ with calls to action that persist for several days
+* Snippets - short messages that appear on New Tab Page to highlight products,
+ features and initiatives
+* Badging - A colorful dot to highlight icons in the toolbar or menu items in
+ order to draw attention with minimal interruption
+
+Detailed Docs
+-------------
+
+* Read more about `trigger listeners and user action schemas`__.
+
+.. __: /toolkit/components/messaging-system/docs
+
+.. In theory, we ought to be able to use the :glob: directive here to
+.. automatically generate the list below. For unknown reasons, however,
+.. `mach doc` _sometimes_ gets confused and refuses to find patterns like
+.. `*.md`.
+.. toctree::
+ :maxdepth: 2
+
+ simple-cfr-template
+ debugging-docs
+ experiment-guide
+ first-run
+ targeting-attributes
+ targeting-guide
diff --git a/browser/components/newtab/content-src/asrouter/docs/message-routing-overview.png b/browser/components/newtab/content-src/asrouter/docs/message-routing-overview.png
new file mode 100644
index 0000000000..0ec2ec3c14
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/docs/message-routing-overview.png
Binary files differ
diff --git a/browser/components/newtab/content-src/asrouter/docs/simple-cfr-template.rst b/browser/components/newtab/content-src/asrouter/docs/simple-cfr-template.rst
new file mode 100644
index 0000000000..d553547420
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/docs/simple-cfr-template.rst
@@ -0,0 +1,37 @@
+Simple CFR Template
+--------------------
+
+The “Simple CFR Template” is a two-stage UI (a chiclet notification and a door-hanger)
+that shows up on a configurable `trigger condition`__, such as when the user visits a
+particular web page.
+
+.. __: /toolkit/components/messaging-system/docs/TriggerActionSchemas
+
+Warning! Before reading, you should consider whether a `Messaging Experiment is relevant for your needs`__.
+
+.. __: https://docs.google.com/document/d/1S45a_nFn8QRM8gvsxCM6HHROrIQlQQl6fUlJ2j63PGI/edit
+
+.. image:: ./cfr_doorhanger_screenshot.png
+ :align: center
+ :alt: Simple CFR Template 2 stage
+
+Doorhanger Configuration
+=========================
+
+Stage 1 – Chiclet
+++++++++++++++++++
+
+* **chiclet_label**: The text that shows up in the chiclet. 20 characters max.
+* **chiclet_color**: The background color of the chiclet as a HEX code.
+
+
+Stage 2 – Door-hanger
+++++++++++++++++++++++
+
+* **title**: Title text at the top of the door hanger.
+* **body**: A longer paragraph of text.
+* **icon**: An image (please provide a URL or the image file up to 96x96px).
+* **primary_button_label**: The label of the button.
+* **primary_button_action**: The special action triggered by clicking on the button. Choose any of the available `button actions`__. Common examples include opening a section of about:preferences, or opening a URL.
+
+.. __: /toolkit/components/messaging-system/docs/SpecialMessageActionSchemas
diff --git a/browser/components/newtab/content-src/asrouter/docs/targeting-attributes.md b/browser/components/newtab/content-src/asrouter/docs/targeting-attributes.md
new file mode 100644
index 0000000000..128ea2a0b3
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/docs/targeting-attributes.md
@@ -0,0 +1,828 @@
+# Targeting attributes
+
+When you create ASRouter messages such as snippets, contextual feature recommendations, or onboarding cards, you may choose to include **targeting information** with those messages.
+
+Targeting information must be captured in [an expression](./targeting-guide.md) that has access to the following attributes. You may combine and compare any of these attributes as needed.
+
+Please note that some targeting attributes require stricter controls on the telemetry than can be colleted, so when in doubt, ask for review.
+
+## Available attributes
+
+* [addonsInfo](#addonsinfo)
+* [attributionData](#attributiondata)
+* [browserSettings](#browsersettings)
+* [currentDate](#currentdate)
+* [devToolsOpenedCount](#devtoolsopenedcount)
+* [isDefaultBrowser](#isdefaultbrowser)
+* [firefoxVersion](#firefoxversion)
+* [locale](#locale)
+* [localeLanguageCode](#localelanguagecode)
+* [needsUpdate](#needsupdate)
+* [pinnedSites](#pinnedsites)
+* [previousSessionEnd](#previoussessionend)
+* [profileAgeCreated](#profileagecreated)
+* [profileAgeReset](#profileagereset)
+* [providerCohorts](#providercohorts)
+* [region](#region)
+* [searchEngines](#searchengines)
+* [sync](#sync)
+* [topFrecentSites](#topfrecentsites)
+* [totalBookmarksCount](#totalbookmarkscount)
+* [usesFirefoxSync](#usesfirefoxsync)
+* [isFxAEnabled](#isFxAEnabled)
+* [xpinstallEnabled](#xpinstallEnabled)
+* [hasPinnedTabs](#haspinnedtabs)
+* [hasAccessedFxAPanel](#hasaccessedfxapanel)
+* [isWhatsNewPanelEnabled](#iswhatsnewpanelenabled)
+* [totalBlockedCount](#totalblockedcount)
+* [recentBookmarks](#recentbookmarks)
+* [userPrefs](#userprefs)
+* [attachedFxAOAuthClients](#attachedfxaoauthclients)
+* [platformName](#platformname)
+* [scores](#scores)
+* [scoreThreshold](#scorethreshold)
+* [messageImpressions](#messageimpressions)
+* [blockedCountByType](#blockedcountbytype)
+* [isChinaRepack](#ischinarepack)
+* [userId](#userid)
+* [profileRestartCount](#profilerestartcount)
+* [homePageSettings](#homepagesettings)
+* [newtabSettings](#newtabsettings)
+* [isFissionExperimentEnabled](#isfissionexperimentenabled)
+* [activeNotifications](#activenotifications)
+
+## Detailed usage
+
+### `addonsInfo`
+Provides information about the add-ons the user has installed.
+
+Note that the `name`, `userDisabled`, and `installDate` is only available if `isFullData` is `true` (this is usually not the case right at start-up).
+
+**Due to an existing bug, `userDisabled` is not currently available**
+
+#### Examples
+* Has the user installed the unicorn addon?
+```java
+addonsInfo.addons["unicornaddon@mozilla.org"]
+```
+
+* Has the user installed and disabled the unicorn addon?
+```java
+addonsInfo.isFullData && addonsInfo.addons["unicornaddon@mozilla.org"].userDisabled
+```
+
+#### Definition
+```ts
+declare const addonsInfo: Promise<AddonsInfoResponse>;
+interface AddonsInfoResponse {
+ // Does this include extra information requiring I/O?
+ isFullData: boolean;
+ // addonId should be something like activity-stream@mozilla.org
+ [addonId: string]: {
+ // Version of the add-on
+ version: string;
+ // (string) e.g. "extension"
+ type: AddonType;
+ // Version of the add-on
+ isSystem: boolean;
+ // Is the add-on a webextension?
+ isWebExtension: boolean;
+ // The name of the add-on
+ name: string;
+ // Is the add-on disabled?
+ // CURRENTLY UNAVAILABLE due to an outstanding bug
+ userDisabled: boolean;
+ // When was it installed? e.g. "2018-03-10T03:41:06.000Z"
+ installDate: string;
+ };
+}
+```
+### `attributionData`
+
+An object containing information on exactly how Firefox was downloaded
+
+#### Examples
+* Was the browser installed via the `"back_to_school"` campaign?
+```java
+attributionData && attributionData.campaign == "back_to_school"
+```
+
+#### Definition
+```ts
+declare const attributionData: AttributionCode;
+interface AttributionCode {
+ // Descriptor for where the download started from
+ campaign: string,
+ // A source, like addons.mozilla.org, or google.com
+ source: string,
+ // The medium for the download, like if this was referral
+ medium: string,
+ // Additional content, like an addonID for instance
+ content: string
+}
+```
+
+### `browserSettings`
+
+Includes two properties:
+* `attribution`, which indicates how Firefox was downloaded - DEPRECATED - please use [attributionData](#attributiondata)
+* `update`, which has information about how Firefox updates
+
+Note that attribution can be `undefined`, so you should check that it exists first.
+
+#### Examples
+* Is updating enabled?
+```java
+browserSettings.update.enabled
+```
+
+#### Definition
+
+```ts
+declare const browserSettings: {
+ attribution: undefined | {
+ // Referring partner domain, when install happens via a known partner
+ // e.g. google.com
+ source: string;
+ // category of the source, such as "organic" for a search engine
+ // e.g. organic
+ medium: string;
+ // identifier of the particular campaign that led to the download of the product
+ // e.g. back_to_school
+ campaign: string;
+ // identifier to indicate the particular link within a campaign
+ // e.g. https://mozilla.org/some-page
+ content: string;
+ },
+ update: {
+ // Is auto-downloading enabled?
+ autoDownload: boolean;
+ // What release channel, e.g. "nightly"
+ channel: string;
+ // Is updating enabled?
+ enabled: boolean;
+ }
+}
+```
+
+### `currentDate`
+
+The current date at the moment message targeting is checked.
+
+#### Examples
+* Is the current date after Oct 3, 2018?
+```java
+currentDate > "Wed Oct 03 2018 00:00:00"|date
+```
+
+#### Definition
+
+```ts
+declare const currentDate; ECMA262DateString;
+// ECMA262DateString = Date.toString()
+type ECMA262DateString = string;
+```
+
+### `devToolsOpenedCount`
+Number of usages of the web console.
+
+#### Examples
+* Has the user opened the web console more than 10 times?
+```java
+devToolsOpenedCount > 10
+```
+
+#### Definition
+```ts
+declare const devToolsOpenedCount: number;
+```
+
+### `isDefaultBrowser`
+
+Is Firefox the user's default browser?
+
+#### Definition
+
+```ts
+declare const isDefaultBrowser: boolean;
+```
+
+### `firefoxVersion`
+
+The major Firefox version of the browser
+
+#### Examples
+* Is the version of the browser greater than 63?
+```java
+firefoxVersion > 63
+```
+
+#### Definition
+
+```ts
+declare const firefoxVersion: number;
+```
+
+### `locale`
+The current locale of the browser including country code, e.g. `en-US`.
+
+#### Examples
+* Is the locale of the browser either English (US) or German (Germany)?
+```java
+locale in ["en-US", "de-DE"]
+```
+
+#### Definition
+```ts
+declare const locale: string;
+```
+
+### `localeLanguageCode`
+The current locale of the browser NOT including country code, e.g. `en`.
+This is useful for matching all countries of a particular language.
+
+#### Examples
+* Is the locale of the browser any English locale?
+```java
+localeLanguageCode == "en"
+```
+
+#### Definition
+```ts
+declare const localeLanguageCode: string;
+```
+
+### `needsUpdate`
+
+Does the client have the latest available version installed
+
+```ts
+declare const needsUpdate: boolean;
+```
+
+### `pinnedSites`
+The sites (including search shortcuts) that are pinned on a user's new tab page.
+
+#### Examples
+* Has the user pinned any site on `foo.com`?
+```java
+"foo.com" in pinnedSites|mapToProperty("host")
+```
+
+* Does the user have a pinned `duckduckgo.com` search shortcut?
+```java
+"duckduckgo.com" in pinnedSites[.searchTopSite == true]|mapToProperty("host")
+```
+
+#### Definition
+```ts
+interface PinnedSite {
+ // e.g. https://foo.mozilla.com/foo/bar
+ url: string;
+ // e.g. foo.mozilla.com
+ host: string;
+ // is the pin a search shortcut?
+ searchTopSite: boolean;
+}
+declare const pinnedSites: Array<PinnedSite>
+```
+
+### `previousSessionEnd`
+
+Timestamp of the previously closed session.
+
+#### Definition
+```ts
+declare const previousSessionEnd: UnixEpochNumber;
+// UnixEpochNumber is UNIX Epoch timestamp, e.g. 1522843725924
+type UnixEpochNumber = number;
+```
+
+### `profileAgeCreated`
+
+The date the profile was created as a UNIX Epoch timestamp.
+
+#### Definition
+
+```ts
+declare const profileAgeCreated: UnixEpochNumber;
+// UnixEpochNumber is UNIX Epoch timestamp, e.g. 1522843725924
+type UnixEpochNumber = number;
+```
+
+### `profileAgeReset`
+
+The date the profile was reset as a UNIX Epoch timestamp (if it was reset).
+
+#### Examples
+* Was the profile never reset?
+```java
+!profileAgeReset
+```
+
+#### Definition
+```ts
+// profileAgeReset can be undefined if the profile was never reset
+// UnixEpochNumber is number, e.g. 1522843725924
+declare const profileAgeReset: undefined | UnixEpochNumber;
+// UnixEpochNumber is UNIX Epoch timestamp, e.g. 1522843725924
+type UnixEpochNumber = number;
+```
+
+### `providerCohorts`
+
+Information about cohort settings (from prefs, including shield studies) for each provider.
+
+#### Examples
+* Is the user in the "foo_test" cohort for snippets?
+```java
+providerCohorts.snippets == "foo_test"
+```
+
+#### Definition
+
+```ts
+declare const providerCohorts: {
+ [providerId: string]: string;
+}
+```
+
+### `region`
+
+Country code retrieved from `location.services.mozilla.com`. Can be `""` if request did not finish or encountered an error.
+
+#### Examples
+* Is the user in Canada?
+```java
+region == "CA"
+```
+
+#### Definition
+
+```ts
+declare const region: string;
+```
+
+### `searchEngines`
+
+Information about the current and available search engines.
+
+#### Examples
+* Is the current default search engine set to google?
+```java
+searchEngines.current == "google"
+```
+
+#### Definition
+
+```ts
+declare const searchEngines: Promise<SearchEnginesResponse>;
+interface SearchEnginesResponse: {
+ current: SearchEngineId;
+ installed: Array<SearchEngineId>;
+}
+// This is an identifier for a search engine such as "google" or "amazondotcom"
+type SearchEngineId = string;
+```
+
+### `sync`
+
+Information about synced devices.
+
+#### Examples
+* Is at least 1 mobile device synced to this profile?
+```java
+sync.mobileDevices > 0
+```
+
+#### Definition
+
+```ts
+declare const sync: {
+ desktopDevices: number;
+ mobileDevices: number;
+ totalDevices: number;
+}
+```
+
+### `topFrecentSites`
+
+Information about the browser's top 25 frecent sites.
+
+**Please note this is a restricted targeting property that influences what telemetry is allowed to be collected may not be used without review**
+
+
+#### Examples
+* Is mozilla.com in the user's top frecent sites with a frececy greater than 400?
+```java
+"mozilla.com" in topFrecentSites[.frecency >= 400]|mapToProperty("host")
+```
+
+#### Definition
+```ts
+declare const topFrecentSites: Promise<Array<TopSite>>
+interface TopSite {
+ // e.g. https://foo.mozilla.com/foo/bar
+ url: string;
+ // e.g. foo.mozilla.com
+ host: string;
+ frecency: number;
+ lastVisitDate: UnixEpochNumber;
+}
+// UnixEpochNumber is UNIX Epoch timestamp, e.g. 1522843725924
+type UnixEpochNumber = number;
+```
+
+### `totalBookmarksCount`
+
+Total number of bookmarks.
+
+#### Definition
+
+```ts
+declare const totalBookmarksCount: number;
+```
+
+### `usesFirefoxSync`
+
+Does the user use Firefox sync?
+
+#### Definition
+
+```ts
+declare const usesFirefoxSync: boolean;
+```
+
+### `isFxAEnabled`
+
+Does the user have Firefox sync enabled? The service could potentially be turned off [for enterprise builds](https://searchfox.org/mozilla-central/rev/b59a99943de4dd314bae4e44ab43ce7687ccbbec/browser/components/enterprisepolicies/Policies.jsm#327).
+
+#### Definition
+
+```ts
+declare const isFxAEnabled: boolean;
+```
+
+### `xpinstallEnabled`
+
+Pref used by system administrators to disallow add-ons from installed altogether.
+
+#### Definition
+
+```ts
+declare const xpinstallEnabled: boolean;
+```
+
+### `hasPinnedTabs`
+
+Does the user have any pinned tabs in any windows.
+
+#### Definition
+
+```ts
+declare const hasPinnedTabs: boolean;
+```
+
+### `hasAccessedFxAPanel`
+
+Boolean pref that gets set the first time the user opens the FxA toolbar panel
+
+#### Definition
+
+```ts
+declare const hasAccessedFxAPanel: boolean;
+```
+
+### `isWhatsNewPanelEnabled`
+
+Boolean pref that controls if the What's New panel feature is enabled
+
+#### Definition
+
+```ts
+declare const isWhatsNewPanelEnabled: boolean;
+```
+
+### `totalBlockedCount`
+
+Total number of events from the content blocking database
+
+#### Definition
+
+```ts
+declare const totalBlockedCount: number;
+```
+
+### `recentBookmarks`
+
+An array of GUIDs of recent bookmarks as provided by [`NewTabUtils.getRecentBookmarks`](https://searchfox.org/mozilla-central/rev/e0b0c38ee83f99d3cf868bad525ace4a395039f1/toolkit/modules/NewTabUtils.jsm#1087)
+
+#### Definition
+
+```ts
+interface Bookmark {
+ bookmarkGuid: string;
+ url: string;
+ title: string;
+ ...
+}
+declare const recentBookmarks: Array<Bookmark>
+```
+
+### `userPrefs`
+
+Information about user facing prefs configurable from `about:preferences`.
+
+#### Examples
+```java
+userPrefs.cfrFeatures == false
+```
+
+#### Definition
+
+```ts
+declare const userPrefs: {
+ cfrFeatures: boolean;
+ cfrAddons: boolean;
+ snippets: boolean;
+}
+```
+
+### `attachedFxAOAuthClients`
+
+Information about connected services associated with the FxA Account.
+Return an empty array if no account is found or an error occurs.
+
+#### Definition
+
+```
+interface OAuthClient {
+ // OAuth client_id of the service
+ // https://docs.telemetry.mozilla.org/datasets/fxa_metrics/attribution.html#service-attribution
+ id: string;
+ lastAccessedDaysAgo: number;
+}
+
+declare const attachedFxAOAuthClients: Promise<OAuthClient[]>
+```
+
+#### Examples
+```javascript
+{
+ id: "7377719276ad44ee",
+ name: "Pocket",
+ lastAccessTime: 1513599164000
+}
+```
+
+### `platformName`
+
+[Platform information](https://searchfox.org/mozilla-central/rev/05a22d864814cb1e4352faa4004e1f975c7d2eb9/toolkit/modules/AppConstants.jsm#156).
+
+#### Definition
+
+```
+declare const platformName = "linux" | "win" | "macosx" | "android" | "other";
+```
+
+### `scores`
+
+#### Definition
+
+See more in [CFR Machine Learning Experiment](https://bugzilla.mozilla.org/show_bug.cgi?id=1594422).
+
+```
+declare const scores = { [cfrId: string]: number (integer); }
+```
+
+### `scoreThreshold`
+
+#### Definition
+
+See more in [CFR Machine Learning Experiment](https://bugzilla.mozilla.org/show_bug.cgi?id=1594422).
+
+```
+declare const scoreThreshold = integer;
+```
+
+### `messageImpressions`
+
+Dictionary that maps message ids to impression timestamps. Timestamps are stored in
+consecutive order. Can be used to detect first impression of a message, number of
+impressions. Can be used in targeting to show a message if another message has been
+seen.
+Impressions are used for frequency capping so we only store them if the message has
+`frequency` configured.
+Impressions for badges might not work as expected: we add a badge for every opened
+window so the number of impressions stored might be higher than expected. Additionally
+not all badges have `frequency` cap so `messageImpressions` might not be defined.
+Badge impressions should not be used for targeting.
+
+#### Definition
+
+```
+declare const messageImpressions: { [key: string]: Array<UnixEpochNumber> };
+```
+
+### `blockedCountByType`
+
+Returns a breakdown by category of all blocked resources in the past 42 days.
+
+#### Definition
+
+```
+declare const messageImpressions: { [key: string]: number };
+```
+
+#### Examples
+
+```javascript
+Object {
+ trackerCount: 0,
+ cookieCount: 34,
+ cryptominerCount: 0,
+ fingerprinterCount: 3,
+ socialCount: 2
+}
+```
+
+### `isChinaRepack`
+
+Does the user use [the partner repack distributed by Mozilla Online](https://github.com/mozilla-partners/mozillaonline),
+a wholly owned subsidiary of the Mozilla Corporation that operates in China.
+
+#### Definition
+
+```ts
+declare const isChinaRepack: boolean;
+```
+
+### `userId`
+
+A unique user id generated by Normandy (note that this is not clientId).
+
+#### Definition
+
+```ts
+declare const userId: string;
+```
+
+### `profileRestartCount`
+
+A session counter that shows how many times the browser was started.
+More info about the details in [the telemetry docs](https://firefox-source-docs.mozilla.org/toolkit/components/telemetry/concepts/sessions.html).
+
+#### Definition
+
+```ts
+declare const profileRestartCount: number;
+```
+
+### `homePageSettings`
+
+An object reflecting the current settings of the browser home page (about:home)
+
+#### Definition
+
+```ts
+declare const homePageSettings: {
+ isDefault: boolean;
+ isLocked: boolean;
+ isWebExt: boolean;
+ isCustomUrl: boolean;
+ urls: Array<URL>;
+}
+
+interface URL {
+ url: string;
+ host: string;
+}
+```
+
+#### Examples
+
+* Default about:home
+```javascript
+Object {
+ isDefault: true,
+ isLocked: false,
+ isCustomUrl: false,
+ isWebExt: false,
+ urls: [
+ { url: "about:home", host: "" }
+ ],
+}
+```
+
+* Default about:home with locked preference
+```javascript
+Object {
+ isDefault: true,
+ isLocked: true,
+ isCustomUrl: false,
+ isWebExt: false,
+ urls: [
+ { url: "about:home", host: "" }
+ ],
+}
+```
+
+* Custom URL
+```javascript
+Object {
+ isDefault: false,
+ isLocked: false,
+ isCustomUrl: true,
+ isWebExt: false,
+ urls: [
+ { url: "https://www.google.com", host: "google.com" }
+ ],
+}
+```
+
+* Custom URLs
+```javascript
+Object {
+ isDefault: false,
+ isLocked: false,
+ isCustomUrl: true,
+ isWebExt: false,
+ urls: [
+ { url: "https://www.google.com", host: "google.com" },
+ { url: "https://www.youtube.com", host: "youtube.com" }
+ ],
+}
+```
+
+* Web extension
+```javascript
+Object {
+ isDefault: false,
+ isLocked: false,
+ isCustomUrl: false,
+ isWebExt: true,
+ urls: [
+ { url: "moz-extension://123dsa43213acklncd/home.html", host: "" }
+ ],
+}
+```
+
+### `newtabSettings`
+
+An object reflecting the current settings of the browser newtab page (about:newtab)
+
+#### Definition
+
+```ts
+declare const newtabSettings: {
+ isDefault: boolean;
+ isWebExt: boolean;
+ isCustomUrl: boolean;
+ url: string;
+ host: string;
+}
+```
+
+#### Examples
+
+* Default about:newtab
+```javascript
+Object {
+ isDefault: true,
+ isCustomUrl: false,
+ isWebExt: false,
+ url: "about:newtab",
+ host: "",
+}
+```
+
+* Custom URL
+```javascript
+Object {
+ isDefault: false,
+ isCustomUrl: true,
+ isWebExt: false,
+ url: "https://www.google.com",
+ host: "google.com",
+}
+```
+
+* Web extension
+```javascript
+Object {
+ isDefault: false,
+ isCustomUrl: false,
+ isWebExt: true,
+ url: "moz-extension://123dsa43213acklncd/home.html",
+ host: "",
+}
+```
+
+### `isFissionExperimentEnabled`
+
+A boolean. `true` if we're running Fission experiment, `false` otherwise.
+
+### `activeNotifications`
+
+True when an infobar style message is displayed or when the awesomebar is
+expanded to show a message (for example onboarding tips).
diff --git a/browser/components/newtab/content-src/asrouter/docs/targeting-guide.md b/browser/components/newtab/content-src/asrouter/docs/targeting-guide.md
new file mode 100644
index 0000000000..901756bca5
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/docs/targeting-guide.md
@@ -0,0 +1,37 @@
+# Guide to targeting with JEXL
+
+For a more in-depth explanation of JEXL syntax you can read the [Normady project docs](https://mozilla.github.io/normandy/user/filters.html?highlight=jexl).
+
+### How to write JEXL targeting expressions
+A message needs to contain the `targeting` property (JEXL string) which is evaluated against the provided attributes.
+Examples:
+
+```javascript
+{
+ "id": "7864",
+ "content": {...},
+ // simple equality check
+ "targeting": "usesFirefoxSync == true"
+}
+
+{
+ "id": "7865",
+ "content": {...},
+ // using JEXL transforms and combining two attributes
+ "targeting": "usesFirefoxSync == true && profileAgeCreated > '2018-01-07'|date"
+}
+
+{
+ "id": "7866",
+ "content": {...},
+ // targeting addon information
+ "targeting": "addonsInfo.addons['activity-stream@mozilla.org'].name == 'Activity Stream'"
+}
+
+{
+ "id": "7866",
+ "content": {...},
+ // targeting based on time
+ "targeting": "currentDate > '2018-08-08'|date"
+}
+```
diff --git a/browser/components/newtab/content-src/asrouter/docs/telemetry-screenshot.png b/browser/components/newtab/content-src/asrouter/docs/telemetry-screenshot.png
new file mode 100644
index 0000000000..b27b4ab958
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/docs/telemetry-screenshot.png
Binary files differ
diff --git a/browser/components/newtab/content-src/asrouter/rich-text-strings.js b/browser/components/newtab/content-src/asrouter/rich-text-strings.js
new file mode 100644
index 0000000000..6a52732ad1
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/rich-text-strings.js
@@ -0,0 +1,44 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { FluentBundle } from "fluent";
+
+/**
+ * Properties that allow rich text MUST be added to this list.
+ * key: the localization_id that should be used
+ * value: a property or array of properties on the message.content object
+ */
+const RICH_TEXT_CONFIG = {
+ text: ["text", "scene1_text"],
+ success_text: "success_text",
+ error_text: "error_text",
+ scene2_text: "scene2_text",
+ amo_html: "amo_html",
+ privacy_html: "scene2_privacy_html",
+ disclaimer_html: "scene2_disclaimer_html",
+};
+
+export const RICH_TEXT_KEYS = Object.keys(RICH_TEXT_CONFIG);
+
+/**
+ * Generates an array of messages suitable for fluent's localization provider
+ * including all needed strings for rich text.
+ * @param {object} content A .content object from an ASR message (i.e. message.content)
+ * @returns {FluentBundle[]} A array containing the fluent message context
+ */
+export function generateBundles(content) {
+ const bundle = new FluentBundle("en-US");
+
+ RICH_TEXT_KEYS.forEach(key => {
+ const attrs = RICH_TEXT_CONFIG[key];
+ const attrsToTry = Array.isArray(attrs) ? [...attrs] : [attrs];
+ let string = "";
+ while (!string && attrsToTry.length) {
+ const attr = attrsToTry.pop();
+ string = content[attr];
+ }
+ bundle.addMessages(`${key} = ${string}`);
+ });
+ return [bundle];
+}
diff --git a/browser/components/newtab/content-src/asrouter/schemas/message-format.md b/browser/components/newtab/content-src/asrouter/schemas/message-format.md
new file mode 100644
index 0000000000..debcce0572
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/schemas/message-format.md
@@ -0,0 +1,101 @@
+## Activity Stream Router message format
+
+Field name | Type | Required | Description | Example / Note
+--- | --- | --- | --- | ---
+`id` | `string` | Yes | A unique identifier for the message that should not conflict with any other previous message | `ONBOARDING_1`
+`template` | `string` | Yes | An id matching an existing Activity Stream Router template | [See example](https://github.com/mozilla/activity-stream/blob/33669c67c2269078a6d3d6d324fb48175d98f634/system-addon/content-src/message-center/templates/SimpleSnippet.jsx)
+`content` | `object` | Yes | An object containing all variables/props to be rendered in the template. Subset of allowed tags detailed below. | [See example below](#html-subset)
+`bundled` | `integer` | No | The number of messages of the same template this one should be shown with | [See example below](#a-bundled-message-example)
+`order` | `integer` | No | If bundled with other messages of the same template, which order should this one be placed in? Defaults to 0 if no order is desired | [See example below](#a-bundled-message-example)
+`campaign` | `string` | No | Campaign id that the message belongs to | `RustWebAssembly`
+`targeting` | `string` `JEXL` | No | A [JEXL expression](http://normandy.readthedocs.io/en/latest/user/filter_expressions.html#jexl-basics) with all targeting information needed in order to decide if the message is shown | Not yet implemented, [Examples](#targeting-attributes)
+`trigger` | `string` | No | An event or condition upon which the message will be immediately shown. This can be combined with `targeting`. Messages that define a trigger will not be shown during non-trigger-based passive message rotation.
+`trigger.params` | `[string]` | No | A set of hostnames passed down as parameters to the trigger condition. Used to restrict the number of domains where the trigger/message is valid. | [See example below](#trigger-params)
+`trigger.patterns` | `[string]` | No | A set of patterns that match multiple hostnames passed down as parameters to the trigger condition. Used to restrict the number of domains where the trigger/message is valid. | [See example below](#trigger-patterns)
+`frequency` | `object` | No | A definition for frequency cap information for the message
+`frequency.lifetime` | `integer` | No | The maximum number of lifetime impressions for the message.
+`frequency.custom` | `array` | No | An array of frequency cap definition objects including `period`, a time period in milliseconds, and `cap`, a max number of impressions for that period.
+
+### Message example
+```javascript
+{
+ id: "ONBOARDING_1",
+ template: "simple_snippet",
+ content: {
+ title: "Find it faster",
+ body: "Access all of your favorite search engines with a click. Search the whole Web or just one website from the search box."
+ },
+ targeting: "usesFirefoxSync && !addonsInfo.addons['activity-stream@mozilla.org']",
+ frequency: {
+ lifetime: 20,
+ custom: [{period: 86400000, cap: 5}, {period: 3600000, cap: 1}]
+ }
+}
+```
+
+### A Bundled Message example
+The following 2 messages have a `bundled` property, indicating that they should be shown together, since they have the same template. The number `2` indicates that this message should be shown in a bundle of 2 messages of the same template. The order property defines that ONBOARDING_2 should be shown after ONBOARDING_3 in the bundle.
+```javascript
+{
+ id: "ONBOARDING_2",
+ template: "onboarding",
+ bundled: 2,
+ order: 2,
+ content: {
+ title: "Private Browsing",
+ body: "Browse by yourself. Private Browsing with Tracking Protection blocks online trackers that follow you around the web."
+ },
+ targeting: "",
+ trigger: "firstRun"
+}
+{
+ id: "ONBOARDING_3",
+ template: "onboarding",
+ bundled: 2,
+ order: 1,
+ content: {
+ title: "Find it faster",
+ body: "Access all of your favorite search engines with a click. Search the whole Web or just one website from the search box."
+ },
+ targeting: "",
+ trigger: "firstRun"
+}
+```
+
+### HTML subset
+The following tags are allowed in the content of the snippet: `i, b, u, strong, em, br`.
+
+Links cannot be rendered using regular anchor tags because [Fluent does not allow for href attributes](https://github.com/projectfluent/fluent.js/blob/a03d3aa833660f8c620738b26c80e46b1a4edb05/fluent-dom/src/overlay.js#L13). They will be wrapped in custom tags, for example `<cta>link</cta>` and the url will be provided as part of the payload:
+```
+{
+ "id": "7899",
+ "content": {
+ "text": "Use the CMD (CTRL) + T keyboard shortcut to <cta>open a new tab quickly!</cta>",
+ "links": {
+ "cta": {
+ "url": "https://support.mozilla.org/en-US/kb/keyboard-shortcuts-perform-firefox-tasks-quickly"
+ }
+ }
+ }
+}
+```
+If a tag that is not on the allowed is used, the text content will be extracted and displayed.
+
+Grouping multiple allowed elements is not possible, only the first level will be used: `<u><b>text</b></u>` will be interpreted as `<u>text</u>`.
+
+### Trigger params
+A set of hostnames that need to exactly match the location of the selected tab in order for the trigger to execute.
+```
+["github.com", "wwww.github.com"]
+```
+More examples in the [CFRMessageProvider](https://github.com/mozilla/activity-stream/blob/e76ce12fbaaac1182aa492b84fc038f78c3acc33/lib/CFRMessageProvider.jsm#L40-L47).
+
+### Trigger patterns
+A set of patterns that can match multiple hostnames. When the location of the selected tab matches one of the patterns it can execute a trigger.
+```
+["*://*.github.com"] // can match `github.com` but also match `https://gist.github.com/`
+```
+More [MatchPattern examples](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Match_patterns#Examples).
+
+### Targeting attributes
+(This section has moved to [targeting-attributes.md](../docs/targeting-attributes.md)).
diff --git a/browser/components/newtab/content-src/asrouter/schemas/message-group.schema.json b/browser/components/newtab/content-src/asrouter/schemas/message-group.schema.json
new file mode 100644
index 0000000000..64f30e7c49
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/schemas/message-group.schema.json
@@ -0,0 +1,63 @@
+{
+ "title": "MessageGroup",
+ "description": "Configuration object for groups of Messaging System messages",
+ "type": "object",
+ "version": "1.0.0",
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "A unique identifier for the message that should not conflict with any other previous message."
+ },
+ "enabled": {
+ "type": "boolean",
+ "description": "Enables or disables all messages associated with this group."
+ },
+ "userPreferences": {
+ "type": "array",
+ "description": "Collection of preferences that control if the group is enabled.",
+ "items": {
+ "type": "string",
+ "description": "Preference name"
+ }
+ },
+ "frequency": {
+ "type": "object",
+ "description": "An object containing frequency cap information for a message.",
+ "properties": {
+ "lifetime": {
+ "type": "integer",
+ "description": "The maximum lifetime impressions for a message.",
+ "minimum": 1,
+ "maximum": 100
+ },
+ "custom": {
+ "type": "array",
+ "description": "An array of custom frequency cap definitions.",
+ "items": {
+ "description": "A frequency cap definition containing time and max impression information",
+ "type": "object",
+ "properties": {
+ "period": {
+ "oneOf": [
+ {
+ "type": "integer",
+ "description": "Period of time in milliseconds (e.g. 86400000 for one day)"
+ }
+ ]
+ },
+ "cap": {
+ "type": "integer",
+ "description": "The maximum impressions for the message within the defined period.",
+ "minimum": 1,
+ "maximum": 100
+ }
+ },
+ "required": ["period", "cap"]
+ }
+ }
+ }
+ }
+ },
+ "required": ["id", "enabled"],
+ "additionalProperties": false
+}
diff --git a/browser/components/newtab/content-src/asrouter/schemas/panel/cfr-fxa-bookmark.schema.json b/browser/components/newtab/content-src/asrouter/schemas/panel/cfr-fxa-bookmark.schema.json
new file mode 100644
index 0000000000..2ea50d482d
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/schemas/panel/cfr-fxa-bookmark.schema.json
@@ -0,0 +1,163 @@
+{
+ "title": "CFRFxABookmark",
+ "description": "A message shown in the bookmark panel when user adds or edits a bookmark",
+ "version": "1.0.0",
+ "type": "object",
+ "definitions": {
+ "plainText": {
+ "description": "Plain text (no HTML allowed)",
+ "type": "string"
+ },
+ "richText": {
+ "description": "Text with HTML subset allowed: i, b, u, strong, em, br",
+ "type": "string"
+ },
+ "link_url": {
+ "description": "Target for links or buttons",
+ "type": "string",
+ "format": "uri"
+ }
+ },
+ "properties": {
+ "title": {
+ "description": "Shown at the top of the message in the largest font size.",
+ "oneOf": [
+ {
+ "allOf": [
+ {"$ref": "#/definitions/richText"},
+ {"description": "Message to be shown"}
+ ]
+ },
+ {
+ "type": "object",
+ "properties": {
+ "string_id": {
+ "type": "string",
+ "description": "Fluent id of localized string"
+ }
+ },
+ "required": ["string_id"]
+ }
+ ]
+ },
+ "text": {
+ "description": "Longest part of the message, below the title, provides explanation.",
+ "oneOf": [
+ {
+ "allOf": [
+ {"$ref": "#/definitions/richText"},
+ {"description": "Message to be shown"}
+ ]
+ },
+ {
+ "type": "object",
+ "properties": {
+ "string_id": {
+ "type": "string",
+ "description": "Fluent id of localized string"
+ }
+ },
+ "required": ["string_id"]
+ }
+ ]
+ },
+ "cta": {
+ "description": "Link shown at the bottom of the message, call to action",
+ "oneOf": [
+ {
+ "allOf": [
+ {"$ref": "#/definitions/richText"},
+ {"description": "Message to be shown"}
+ ]
+ },
+ {
+ "type": "object",
+ "properties": {
+ "string_id": {
+ "type": "string",
+ "description": "Fluent id of localized string"
+ }
+ },
+ "required": ["string_id"]
+ }
+ ]
+ },
+ "info_icon": {
+ "type": "object",
+ "description": "The small icon displayed in the top right corner of the panel. Not configurable, only the tooltip text." ,
+ "properties": {
+ "tooltiptext": {
+ "oneOf": [
+ {
+ "allOf": [
+ {"$ref": "#/definitions/plainText"},
+ {"description": "Message to be shown"}
+ ]
+ },
+ {
+ "type": "object",
+ "properties": {
+ "string_id": {
+ "type": "string",
+ "description": "Fluent id of localized string"
+ }
+ },
+ "required": ["string_id"]
+ }
+ ]
+ }
+ },
+ "required": ["tooltiptext"]
+ },
+ "close_button": {
+ "type": "object",
+ "description": "The small dissmiss icon displayed in the top right corner of the message. Not configurable, only the tooltip text." ,
+ "properties": {
+ "tooltiptext": {
+ "oneOf": [
+ {
+ "allOf": [
+ {"$ref": "#/definitions/plainText"},
+ {"description": "Message to be shown"}
+ ]
+ },
+ {
+ "type": "object",
+ "properties": {
+ "string_id": {
+ "type": "string",
+ "description": "Fluent id of localized string"
+ }
+ },
+ "required": ["string_id"]
+ }
+ ]
+ }
+ },
+ "required": ["tooltiptext"]
+ },
+ "color": {
+ "description": "Message text color",
+ "allOf": [
+ {"$ref": "#/definitions/plainText"},
+ {"description": "Valid CSS color"}
+ ]
+ },
+ "background_color_1": {
+ "description": "Configurable background color through CSS gradient",
+ "allOf": [
+ {"$ref": "#/definitions/plainText"},
+ {"description": "Valid CSS color"}
+ ]
+ },
+ "background_color_2": {
+ "description": "Configurable background color through CSS gradient",
+ "allOf": [
+ {"$ref": "#/definitions/plainText"},
+ {"description": "Valid CSS color"}
+ ]
+ }
+ },
+ "additionalProperties": false,
+ "required": ["title", "text", "cta", "info_icon"]
+}
diff --git a/browser/components/newtab/content-src/asrouter/schemas/provider-response.schema.json b/browser/components/newtab/content-src/asrouter/schemas/provider-response.schema.json
new file mode 100644
index 0000000000..76e2249d31
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/schemas/provider-response.schema.json
@@ -0,0 +1,75 @@
+{
+ "title": "ProviderResponse",
+ "description": "A response object for remote providers of AS Router",
+ "type": "object",
+ "version": "6.1.0",
+ "properties": {
+ "messages": {
+ "type": "array",
+ "description": "An array of router messages",
+ "items": {
+ "title": "RouterMessage",
+ "description": "A definition of an individual message",
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "A unique identifier for the message that should not conflict with any other previous message"
+ },
+ "template": {
+ "type": "string",
+ "description": "An id matching an existing Activity Stream Router template",
+ "enum": ["simple_snippet"]
+ },
+ "bundled": {
+ "type": "integer",
+ "description": "The number of messages of the same template this one should be shown with (optional)"
+ },
+ "order": {
+ "type": "integer",
+ "minimum": 0,
+ "description": "If bundled with other messages of the same template, which order should this one be placed in? (optional - defaults to 0)"
+ },
+ "content": {
+ "type": "object",
+ "description": "An object containing all variables/props to be rendered in the template. See individual template schemas for details."
+ },
+ "targeting": {
+ "type": "string",
+ "description": "A JEXL expression representing targeting information"
+ },
+ "personalized": {
+ "type": "boolean",
+ "description": "Is a personalized score applied to the provider's messages?"
+ },
+ "personalizedModelVersion": {
+ "type": "string",
+ "description": "The version of the model use for personalization"
+ },
+ "trigger": {
+ "type": "object",
+ "description": "An action to trigger potentially showing the message",
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "A string identifying the trigger action",
+ "enum": ["firstRun", "openURL"]
+ },
+ "params": {
+ "type": "array",
+ "description": "An optional array of string parameters for the trigger action",
+ "items": {
+ "type": "string",
+ "description": "A parameter for the trigger action"
+ }
+ }
+ },
+ "required": ["id"]
+ }
+ },
+ "required": ["id", "template", "content"]
+ }
+ }
+ },
+ "required": ["messages"]
+}
diff --git a/browser/components/newtab/content-src/asrouter/template-utils.js b/browser/components/newtab/content-src/asrouter/template-utils.js
new file mode 100644
index 0000000000..8d6109a968
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/template-utils.js
@@ -0,0 +1,21 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+export function safeURI(url) {
+ if (!url) {
+ return "";
+ }
+ const { protocol } = new URL(url);
+ const isAllowed = [
+ "http:",
+ "https:",
+ "data:",
+ "resource:",
+ "chrome:",
+ ].includes(protocol);
+ if (!isAllowed) {
+ console.warn(`The protocol ${protocol} is not allowed for template URLs.`); // eslint-disable-line no-console
+ }
+ return isAllowed ? url : "";
+}
diff --git a/browser/components/newtab/content-src/asrouter/templates/CFR/templates/CFRUrlbarChiclet.schema.json b/browser/components/newtab/content-src/asrouter/templates/CFR/templates/CFRUrlbarChiclet.schema.json
new file mode 100644
index 0000000000..5758efd686
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/templates/CFR/templates/CFRUrlbarChiclet.schema.json
@@ -0,0 +1,75 @@
+{
+ "title": "CFRUrlbarChiclet",
+ "description": "A template with a chiclet button with text.",
+ "version": "1.0.0",
+ "type": "object",
+ "definitions": {
+ "plainText": {
+ "description": "Plain text (no HTML allowed)",
+ "type": "string"
+ },
+ "linkUrl": {
+ "description": "Target for links or buttons",
+ "type": "string",
+ "format": "uri"
+ }
+ },
+ "properties": {
+ "category": {
+ "type": "string",
+ "description": "Attribute used for different groups of messages from the same provider"
+ },
+ "layout": {
+ "type": "string",
+ "description": "Describes how content should be displayed.",
+ "enum": ["chiclet_open_url"]
+ },
+ "bucket_id": {
+ "type": "string",
+ "description": "A bucket identifier for the addon. This is used in order to anonymize telemetry for history-sensitive targeting."
+ },
+ "notification_text": {
+ "description": "The text in the small blue chicklet that appears in the URL bar. This can be a reference to a localized string in Firefox or just a plain string.",
+ "oneOf": [
+ {
+ "type": "string",
+ "description": "Message shown in the location bar notification."
+ },
+ {
+ "type": "object",
+ "properties": {
+ "string_id": {
+ "type": "string",
+ "description": "Id of localized string for the location bar notification."
+ }
+ },
+ "required": ["string_id"]
+ }
+ ]
+ },
+ "active_color": {
+ "type": "string",
+ "description": "Background color of the button"
+ },
+ "action": {
+ "type": "object",
+ "properties": {
+ "url": {
+ "description": "The page to open when the button is clicked.",
+ "allOf": [
+ {"$ref": "#/definitions/linkUrl"},
+ {"description": "Icon associated with the message"}
+ ]
+ },
+ "where": {
+ "description": "Should it open in a new tab or the current tab",
+ "enum": ["current", "tabshifted"]
+ }
+ },
+ "additionalProperties": "false",
+ "required": ["url", "where"]
+ }
+ },
+ "additionalProperties": false,
+ "required": ["layout", "category", "bucket_id", "notification_text", "action"]
+}
diff --git a/browser/components/newtab/content-src/asrouter/templates/CFR/templates/ExtensionDoorhanger.schema.json b/browser/components/newtab/content-src/asrouter/templates/CFR/templates/ExtensionDoorhanger.schema.json
new file mode 100644
index 0000000000..fd9e3acc0e
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/templates/CFR/templates/ExtensionDoorhanger.schema.json
@@ -0,0 +1,365 @@
+{
+ "title": "ExtensionDoorhanger",
+ "description": "A template with a heading, addon icon, title and description. No markup allowed.",
+ "version": "1.0.0",
+ "type": "object",
+ "definitions": {
+ "plainText": {
+ "description": "Plain text (no HTML allowed)",
+ "type": "string"
+ },
+ "linkUrl": {
+ "description": "Target for links or buttons",
+ "type": "string",
+ "format": "uri"
+ }
+ },
+ "properties": {
+ "category": {
+ "type": "string",
+ "description": "Attribute used for different groups of messages from the same provider"
+ },
+ "layout": {
+ "type": "string",
+ "description": "Attribute used for different groups of messages from the same provider",
+ "enum": ["short_message", "message_and_animation", "icon_and_message", "addon_recommendation"]
+ },
+ "anchor_id": {
+ "type": "string",
+ "description": "A DOM element ID that the pop-over will be anchored."
+ },
+ "bucket_id": {
+ "type": "string",
+ "description": "A bucket identifier for the addon. This is used in order to anonymize telemetry for history-sensitive targeting."
+ },
+ "skip_address_bar_notifier": {
+ "type": "boolean",
+ "description": "Skip the 'Recommend' notifier and show directly."
+ },
+ "persistent_doorhanger": {
+ "type": "boolean",
+ "description": "Prevent the doorhanger from being dismissed if user interacts with the page or switches between applications."
+ },
+ "notification_text": {
+ "description": "The text in the small blue chicklet that appears in the URL bar. This can be a reference to a localized string in Firefox or just a plain string.",
+ "oneOf": [
+ {
+ "type": "string",
+ "description": "Message shown in the location bar notification."
+ },
+ {
+ "type": "object",
+ "properties": {
+ "string_id": {
+ "type": "string",
+ "description": "Id of localized string for the location bar notification."
+ }
+ },
+ "required": ["string_id"]
+ }
+ ]
+ },
+ "info_icon": {
+ "type": "object",
+ "description": "The small icon displayed in the top right corner of the pop-over. Should be 19x19px, svg or png. Defaults to a small question mark." ,
+ "properties": {
+ "label": {
+ "oneOf": [
+ {
+ "type": "object",
+ "properties": {
+ "attributes": {
+ "type": "object",
+ "properties": {
+ "tooltiptext": {
+ "type": "string",
+ "description": "Text for button tooltip used to provide information about the doorhanger."
+ }
+ },
+ "required": ["tooltiptext"]
+ }
+ },
+ "required": ["attributes"]
+ },
+ {
+ "type": "object",
+ "properties": {
+ "string_id": {
+ "type": "string",
+ "description": "Id of localized string used to provide information about the doorhanger."
+ }
+ },
+ "required": ["string_id"]
+ }
+ ]
+ },
+ "sumo_path": {
+ "type": "string",
+ "description": "Last part of the path in the URL to the support page with the information about the doorhanger.",
+ "examples": ["extensionpromotions", "extensionrecommendations"]
+ }
+ }
+ },
+ "learn_more": {
+ "type": "string",
+ "description": "Last part of the path in the SUMO URL to the support page with the information about the doorhanger.",
+ "examples": ["extensionpromotions", "extensionrecommendations"]
+ },
+ "heading_text": {
+ "description": "The larger heading text displayed in the pop-over. This can be a reference to a localized string in Firefox or just a plain string.",
+ "oneOf": [
+ {
+ "type": "string",
+ "description": "The message displayed in the title of the extension doorhanger"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "string_id": {
+ "type": "string"
+ }
+ },
+ "required": ["string_id"],
+ "description": "Id of localized string for extension doorhanger title"
+ }
+ ]
+ },
+ "icon": {
+ "description": "The icon displayed in the pop-over. Should be 32x32px or 64x64px and png/svg.",
+ "allOf": [
+ {"$ref": "#/definitions/linkUrl"},
+ {"description": "Icon associated with the message"}
+ ]
+ },
+ "icon_dark_theme": {
+ "type": "string",
+ "description": "Pop-over icon, dark theme variant. Should be 32x32px or 64x64px and png/svg."
+ },
+ "icon_class": {
+ "type": "string",
+ "description": "CSS class of the pop-over icon."
+ },
+ "addon": {
+ "description": "Addon information including AMO URL.",
+ "type": "object",
+ "properties": {
+ "id": {
+ "allOf": [
+ {"$ref": "#/definitions/plainText"},
+ {"description": "Unique addon ID"}
+ ]
+ },
+ "title": {
+ "allOf": [
+ {"$ref": "#/definitions/plainText"},
+ {"description": "Addon name"}
+ ]
+ },
+ "author": {
+ "allOf": [
+ {"$ref": "#/definitions/plainText"},
+ {"description": "Addon author"}
+ ]
+ },
+ "icon": {
+ "description": "The icon displayed in the pop-over. Should be 64x64px and png/svg.",
+ "allOf": [
+ {"$ref": "#/definitions/linkUrl"},
+ {"description": "Addon icon"}
+ ]
+ },
+ "rating": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 5,
+ "description": "Star rating"
+ },
+ "users": {
+ "type": "integer",
+ "minimum": 0,
+ "description": "Installed users"
+ },
+ "amo_url": {
+ "allOf": [
+ {"$ref": "#/definitions/linkUrl"},
+ {"description": "Link that offers more information related to the addon."}
+ ]
+ }
+ },
+ "required": ["title", "author", "icon", "amo_url"]
+ },
+ "text": {
+ "description": "The body text displayed in the pop-over. This can be a reference to a localized string in Firefox or just a plain string.",
+ "oneOf": [
+ {
+ "type": "string",
+ "description": "Description message of the addon."
+ },
+ {
+ "type": "object",
+ "properties": {
+ "string_id": {
+ "type": "string",
+ "description": "Id of string to localized addon description"
+ }
+ },
+ "required": ["string_id"]
+ }
+ ]
+ },
+ "descriptionDetails": {
+ "description": "Additional information and steps on how to use",
+ "type": "object",
+ "properties": {
+ "steps": {
+ "description": "Array of messages or string_ids",
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "string_id": {
+ "type": "string",
+ "description": "Id of string to localized addon description"
+ }
+ },
+ "required": ["string_id"]
+ }
+ }
+ },
+ "required": ["steps"]
+ },
+ "buttons": {
+ "description": "The label and functionality for the buttons in the pop-over.",
+ "type": "object",
+ "properties": {
+ "primary": {
+ "type": "object",
+ "properties": {
+ "label": {
+ "type": "object",
+ "oneOf": [
+ {
+ "properties": {
+ "value": {
+ "allOf": [
+ {"$ref": "#/definitions/plainText"},
+ {"description": "Button label override used when a localized version is not available."}
+ ]
+ },
+ "attributes": {
+ "type": "object",
+ "properties": {
+ "accesskey": {
+ "type": "string",
+ "description": "A single character to be used as a shortcut key for the secondary button. This should be one of the characters that appears in the button label."
+ }
+ },
+ "required": ["accesskey"],
+ "description": "Button attributes."
+ }
+ },
+ "required": ["value", "attributes"]
+ },
+ {
+ "properties": {
+ "string_id": {
+ "allOf": [
+ {"$ref": "#/definitions/plainText"},
+ {"description": "Id of localized string for button"}
+ ]
+ }
+ },
+ "required": ["string_id"]
+ }
+ ],
+ "description": "Id of localized string or message override."
+ },
+ "action": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "description": "Action dispatched by the button."
+ },
+ "data": {
+ "properties": {
+ "url": {
+ "type": "string",
+ "$comment": "This is dynamically generated from the addon.id. See CFRPageActions.jsm",
+ "description": "URL used in combination with the primary action dispatched."
+ }
+ }
+ }
+ }
+ }
+ },
+ "secondary": {
+ "type": "object",
+ "properties": {
+ "label": {
+ "type": "object",
+ "oneOf": [
+ {
+ "properties": {
+ "value": {
+ "allOf": [
+ {"$ref": "#/definitions/plainText"},
+ {"description": "Button label override used when a localized version is not available."}
+ ]
+ },
+ "attributes": {
+ "type": "object",
+ "properties": {
+ "accesskey": {
+ "type": "string",
+ "description": "A single character to be used as a shortcut key for the secondary button. This should be one of the characters that appears in the button label."
+ }
+ },
+ "required": ["accesskey"],
+ "description": "Button attributes."
+ }
+ },
+ "required": ["value", "attributes"]
+ },
+ {
+ "properties": {
+ "string_id": {
+ "allOf": [
+ {"$ref": "#/definitions/plainText"},
+ {"description": "Id of localized string for button"}
+ ]
+ }
+ },
+ "required": ["string_id"]
+ }
+ ],
+ "description": "Id of localized string or message override."
+ },
+ "action": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "description": "Action dispatched by the button."
+ },
+ "data": {
+ "properties": {
+ "url": {
+ "allOf": [
+ {"$ref": "#/definitions/linkUrl"},
+ {"description": "URL used in combination with the primary action dispatched."}
+ ]
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "additionalProperties": false,
+ "required": ["layout", "category", "bucket_id", "notification_text", "heading_text", "text", "buttons"]
+}
diff --git a/browser/components/newtab/content-src/asrouter/templates/CFR/templates/InfoBar.schema.json b/browser/components/newtab/content-src/asrouter/templates/CFR/templates/InfoBar.schema.json
new file mode 100644
index 0000000000..3bbaa1ca4f
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/templates/CFR/templates/InfoBar.schema.json
@@ -0,0 +1,96 @@
+{
+ "title": "InfoBar",
+ "description": "A template with an image, test and buttons.",
+ "version": "1.0.0",
+ "type": "object",
+ "definitions": {
+ "plainText": {
+ "description": "Plain text (no HTML allowed)",
+ "type": "string"
+ },
+ "linkUrl": {
+ "description": "Target for links or buttons",
+ "type": "string",
+ "format": "uri"
+ }
+ },
+ "properties": {
+ "type": {
+ "type": "string",
+ "description": "Should the message be global (persisted across tabs) or local (disappear when switching to a different tab).",
+ "enum": ["global", "tab"]
+ },
+ "text": {
+ "description": "The text show in the notification box.",
+ "oneOf": [
+ {
+ "type": "string",
+ "description": "Message shown in the location bar notification."
+ },
+ {
+ "type": "object",
+ "properties": {
+ "string_id": {
+ "type": "string",
+ "description": "Id of localized string for the location bar notification."
+ }
+ },
+ "required": ["string_id"]
+ }
+ ]
+ },
+ "buttons": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "label": {
+ "description": "The text label of the button.",
+ "oneOf": [
+ {
+ "type": "string",
+ "description": "Message content for the button."
+ },
+ {
+ "type": "object",
+ "properties": {
+ "string_id": {
+ "type": "string",
+ "description": "Id of localized string for the button."
+ }
+ },
+ "required": ["string_id"]
+ }
+ ]
+ },
+ "primary": {
+ "type": "boolean",
+ "description": "Is this the primary button?"
+ },
+ "accessKey": {
+ "type": "string",
+ "description": "Keyboard shortcut letter."
+ },
+ "action": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "description": "Action dispatched by the button."
+ },
+ "data": {
+ "type": "object"
+ }
+ },
+ "required": ["type"],
+ "additionalProperties": false
+ }
+ },
+ "required": ["label", "action", "accessKey"],
+ "additionalProperties": false
+ }
+ }
+ },
+ "additionalProperties": false,
+ "required": ["text", "buttons"]
+}
diff --git a/browser/components/newtab/content-src/asrouter/templates/EOYSnippet/EOYSnippet.jsx b/browser/components/newtab/content-src/asrouter/templates/EOYSnippet/EOYSnippet.jsx
new file mode 100644
index 0000000000..f324a69853
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/templates/EOYSnippet/EOYSnippet.jsx
@@ -0,0 +1,153 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import React from "react";
+import { SimpleSnippet } from "../SimpleSnippet/SimpleSnippet";
+
+class EOYSnippetBase extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.handleSubmit = this.handleSubmit.bind(this);
+ }
+
+ /**
+ * setFrequencyValue - `frequency` form parameter value should be `monthly`
+ * if `monthly-checkbox` is selected or `single` otherwise
+ */
+ setFrequencyValue() {
+ const frequencyCheckbox = this.refs.form.querySelector("#monthly-checkbox");
+ if (frequencyCheckbox.checked) {
+ this.refs.form.querySelector("[name='frequency']").value = "monthly";
+ }
+ }
+
+ handleSubmit(event) {
+ event.preventDefault();
+ this.props.sendClick(event);
+ this.setFrequencyValue();
+ if (!this.props.content.do_not_autoblock) {
+ this.props.onBlock();
+ }
+ this.refs.form.submit();
+ }
+
+ renderDonations() {
+ const fieldNames = ["first", "second", "third", "fourth"];
+ const numberFormat = new Intl.NumberFormat(
+ this.props.content.locale || navigator.language,
+ {
+ style: "currency",
+ currency: this.props.content.currency_code,
+ minimumFractionDigits: 0,
+ }
+ );
+ // Default to `second` button
+ const { selected_button } = this.props.content;
+ const btnStyle = {
+ color: this.props.content.button_color,
+ backgroundColor: this.props.content.button_background_color,
+ };
+ const donationURLParams = [];
+ const paramsStartIndex = this.props.content.donation_form_url.indexOf("?");
+ for (const entry of new URLSearchParams(
+ this.props.content.donation_form_url.slice(paramsStartIndex)
+ ).entries()) {
+ donationURLParams.push(entry);
+ }
+
+ return (
+ <form
+ className="EOYSnippetForm"
+ action={this.props.content.donation_form_url}
+ method={this.props.form_method}
+ onSubmit={this.handleSubmit}
+ data-metric="EOYSnippetForm"
+ ref="form"
+ >
+ {donationURLParams.map(([key, value], idx) => (
+ <input type="hidden" name={key} value={value} key={idx} />
+ ))}
+ {fieldNames.map((field, idx) => {
+ const button_name = `donation_amount_${field}`;
+ const amount = this.props.content[button_name];
+ return (
+ <React.Fragment key={idx}>
+ <input
+ type="radio"
+ name="amount"
+ value={amount}
+ id={field}
+ defaultChecked={button_name === selected_button}
+ />
+ <label htmlFor={field} className="donation-amount">
+ {numberFormat.format(amount)}
+ </label>
+ </React.Fragment>
+ );
+ })}
+
+ <div className="monthly-checkbox-container">
+ <input id="monthly-checkbox" type="checkbox" />
+ <label htmlFor="monthly-checkbox">
+ {this.props.content.monthly_checkbox_label_text}
+ </label>
+ </div>
+
+ <input type="hidden" name="frequency" value="single" />
+ <input
+ type="hidden"
+ name="currency"
+ value={this.props.content.currency_code}
+ />
+ <input
+ type="hidden"
+ name="presets"
+ value={fieldNames.map(
+ field => this.props.content[`donation_amount_${field}`]
+ )}
+ />
+ <button
+ style={btnStyle}
+ type="submit"
+ className="ASRouterButton primary donation-form-url"
+ >
+ {this.props.content.button_label}
+ </button>
+ </form>
+ );
+ }
+
+ render() {
+ const textStyle = {
+ color: this.props.content.text_color,
+ backgroundColor: this.props.content.background_color,
+ };
+ const customElement = (
+ <em style={{ backgroundColor: this.props.content.highlight_color }} />
+ );
+ return (
+ <SimpleSnippet
+ {...this.props}
+ className={this.props.content.test}
+ customElements={{ em: customElement }}
+ textStyle={textStyle}
+ extraContent={this.renderDonations()}
+ />
+ );
+ }
+}
+
+export const EOYSnippet = props => {
+ const extendedContent = {
+ monthly_checkbox_label_text: "Make my donation monthly",
+ locale: "en-US",
+ currency_code: "usd",
+ selected_button: "donation_amount_second",
+ ...props.content,
+ };
+
+ return (
+ <EOYSnippetBase {...props} content={extendedContent} form_method="GET" />
+ );
+};
diff --git a/browser/components/newtab/content-src/asrouter/templates/EOYSnippet/EOYSnippet.schema.json b/browser/components/newtab/content-src/asrouter/templates/EOYSnippet/EOYSnippet.schema.json
new file mode 100644
index 0000000000..a82de98e09
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/templates/EOYSnippet/EOYSnippet.schema.json
@@ -0,0 +1,159 @@
+{
+ "title": "EOYSnippet",
+ "description": "Fundraising Snippet",
+ "version": "1.1.0",
+ "type": "object",
+ "definitions": {
+ "plainText": {
+ "description": "Plain text (no HTML allowed)",
+ "type": "string"
+ },
+ "richText": {
+ "description": "Text with HTML subset allowed: i, b, u, strong, em, br",
+ "type": "string"
+ },
+ "link_url": {
+ "description": "Target for links or buttons",
+ "type": "string",
+ "format": "uri"
+ }
+ },
+ "properties": {
+ "donation_form_url": {
+ "type": "string",
+ "description": "Url to the donation form."
+ },
+ "currency_code": {
+ "type": "string",
+ "description": "The code for the currency. Examle gbp, cad, usd.",
+ "default": "usd"
+ },
+ "locale": {
+ "type": "string",
+ "description": "String for the locale code.",
+ "default": "en-US"
+ },
+ "text": {
+ "allOf": [
+ {"$ref": "#/definitions/richText"},
+ {"description": "Main body text of snippet. HTML subset allowed: i, b, u, strong, em, br"}
+ ]
+ },
+ "text_color": {
+ "type": "string",
+ "description": "Modify the text message color"
+ },
+ "background_color": {
+ "type": "string",
+ "description": "Snippet background color."
+ },
+ "highlight_color": {
+ "type": "string",
+ "description": "Paragraph em highlight color."
+ },
+ "donation_amount_first": {
+ "type": "number",
+ "description": "First button amount."
+ },
+ "donation_amount_second": {
+ "type": "number",
+ "description": "Second button amount."
+ },
+ "donation_amount_third": {
+ "type": "number",
+ "description": "Third button amount."
+ },
+ "donation_amount_fourth": {
+ "type": "number",
+ "description": "Fourth button amount."
+ },
+ "selected_button": {
+ "type": "string",
+ "description": "Default donation_amount_second. Donation amount button that's selected by default.",
+ "default": "donation_amount_second"
+ },
+ "icon": {
+ "type": "string",
+ "description": "Snippet icon. 64x64px. SVG or PNG preferred."
+ },
+ "icon_dark_theme": {
+ "type": "string",
+ "description": "Snippet icon. Dark theme variant. 64x64px. SVG or PNG preferred."
+ },
+ "icon_alt_text": {
+ "type": "string",
+ "description": "Alt text for accessibility",
+ "default": ""
+ },
+ "title": {
+ "allOf": [
+ {"$ref": "#/definitions/plainText"},
+ {"description": "Snippet title displayed before snippet text"}
+ ]
+ },
+ "title_icon": {
+ "type": "string",
+ "description": "Small icon that shows up before the title / text. 16x16px. SVG or PNG preferred. Grayscale."
+ },
+ "title_icon_dark_theme": {
+ "type": "string",
+ "description": "Small icon that shows up before the title / text. Dark theme variant. 16x16px. SVG or PNG preferred. Grayscale."
+ },
+ "button_label": {
+ "allOf": [
+ {"$ref": "#/definitions/plainText"},
+ {"description": "Text for a button next to main snippet text that links to button_url. Requires button_url."}
+ ]
+ },
+ "button_color": {
+ "type": "string",
+ "description": "The text color of the button. Valid CSS color."
+ },
+ "button_background_color": {
+ "type": "string",
+ "description": "The background color of the button. Valid CSS color."
+ },
+ "block_button_text": {
+ "type": "string",
+ "description": "Tooltip text used for dismiss button."
+ },
+ "monthly_checkbox_label_text": {
+ "type": "string",
+ "description": "Label text for monthly checkbox.",
+ "default": "Make my donation monthly"
+ },
+ "test": {
+ "type": "string",
+ "description": "Different styles for the snippet. Options are bold and takeover."
+ },
+ "do_not_autoblock": {
+ "type": "boolean",
+ "description": "Used to prevent blocking the snippet after the CTA (link or button) has been clicked"
+ },
+ "links": {
+ "additionalProperties": {
+ "url": {
+ "allOf": [
+ {"$ref": "#/definitions/link_url"},
+ {"description": "The url where the link points to."}
+ ]
+ },
+ "metric": {
+ "type": "string",
+ "description": "Custom event name sent with telemetry event."
+ },
+ "args": {
+ "type": "string",
+ "description": "Additional parameters for link action, example which specific menu the button should open"
+ }
+ }
+ }
+ },
+ "additionalProperties": false,
+ "required": ["text", "donation_form_url", "donation_amount_first", "donation_amount_second", "donation_amount_third", "donation_amount_fourth", "button_label", "currency_code"],
+ "dependencies": {
+ "button_color": ["button_label"],
+ "button_background_color": ["button_label"]
+ }
+}
+
diff --git a/browser/components/newtab/content-src/asrouter/templates/EOYSnippet/_EOYSnippet.scss b/browser/components/newtab/content-src/asrouter/templates/EOYSnippet/_EOYSnippet.scss
new file mode 100644
index 0000000000..ef17606e80
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/templates/EOYSnippet/_EOYSnippet.scss
@@ -0,0 +1,54 @@
+.EOYSnippetForm {
+ margin: 10px 0 8px;
+ align-self: start;
+ font-size: 14px;
+ display: flex;
+ align-items: center;
+
+ .donation-amount,
+ .donation-form-url {
+ white-space: nowrap;
+ font-size: 14px;
+ padding: 8px 20px;
+ border-radius: 2px;
+ }
+
+ .donation-amount {
+ color: $grey-90;
+ margin-inline-end: 18px;
+ border: 1px solid $grey-40;
+ padding: 5px 14px;
+ background: $grey-10;
+ cursor: pointer;
+ }
+
+ input {
+ &[type='radio'] {
+ opacity: 0;
+ margin-inline-end: -18px;
+
+ &:checked + .donation-amount {
+ background: $grey-50;
+ color: $white;
+ border: 1px solid $grey-60;
+ }
+
+ // accessibility
+ &:checked:focus + .donation-amount,
+ &:not(:checked):focus + .donation-amount {
+ border: 1px dotted var(--newtab-link-primary-color);
+ }
+ }
+ }
+
+ .monthly-checkbox-container {
+ display: flex;
+ width: 100%;
+ }
+
+ .donation-form-url {
+ margin-inline-start: 18px;
+ align-self: flex-end;
+ display: flex;
+ }
+}
diff --git a/browser/components/newtab/content-src/asrouter/templates/FXASignupSnippet/FXASignupSnippet.jsx b/browser/components/newtab/content-src/asrouter/templates/FXASignupSnippet/FXASignupSnippet.jsx
new file mode 100644
index 0000000000..1d8197d675
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/templates/FXASignupSnippet/FXASignupSnippet.jsx
@@ -0,0 +1,38 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import React from "react";
+import { SubmitFormSnippet } from "../SubmitFormSnippet/SubmitFormSnippet.jsx";
+
+export const FXASignupSnippet = props => {
+ const userAgent = window.navigator.userAgent.match(/Firefox\/([0-9]+)\./);
+ const firefox_version = userAgent ? parseInt(userAgent[1], 10) : 0;
+ const extendedContent = {
+ scene1_button_label: "Learn more",
+ retry_button_label: "Try again",
+ scene2_email_placeholder_text: "Your email here",
+ scene2_button_label: "Sign me up",
+ scene2_dismiss_button_text: "Dismiss",
+ ...props.content,
+ hidden_inputs: {
+ action: "email",
+ context: "fx_desktop_v3",
+ entrypoint: "snippets",
+ utm_source: "snippet",
+ utm_content: firefox_version,
+ utm_campaign: props.content.utm_campaign,
+ utm_term: props.content.utm_term,
+ ...props.content.hidden_inputs,
+ },
+ };
+
+ return (
+ <SubmitFormSnippet
+ {...props}
+ content={extendedContent}
+ form_action={"https://accounts.firefox.com/"}
+ form_method="GET"
+ />
+ );
+};
diff --git a/browser/components/newtab/content-src/asrouter/templates/FXASignupSnippet/FXASignupSnippet.schema.json b/browser/components/newtab/content-src/asrouter/templates/FXASignupSnippet/FXASignupSnippet.schema.json
new file mode 100644
index 0000000000..d7d3e37bbc
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/templates/FXASignupSnippet/FXASignupSnippet.schema.json
@@ -0,0 +1,187 @@
+{
+ "title": "FXASignupSnippet",
+ "description": "A snippet template for FxA sign up/sign in",
+ "version": "1.2.0",
+ "type": "object",
+ "definitions": {
+ "plainText": {
+ "description": "Plain text (no HTML allowed)",
+ "type": "string"
+ },
+ "richText": {
+ "description": "Text with HTML subset allowed: i, b, u, strong, em, br",
+ "type": "string"
+ },
+ "link_url": {
+ "description": "Target for links or buttons",
+ "type": "string",
+ "format": "uri"
+ }
+ },
+ "properties": {
+ "scene1_title": {
+ "allof": [
+ {"$ref": "#/definitions/plainText"},
+ {"description": "snippet title displayed before snippet text"}
+ ]
+ },
+ "scene1_text": {
+ "allOf": [
+ {"$ref": "#/definitions/richText"},
+ {"description": "Main body text of snippet. HTML subset allowed: i, b, u, strong, em, br"}
+ ]
+ },
+ "scene1_section_title_icon": {
+ "type": "string",
+ "description": "Section title icon for scene 1. 16x16px. SVG or PNG preferred. scene1_section_title_text must also be specified to display."
+ },
+ "scene1_section_title_icon_dark_theme": {
+ "type": "string",
+ "description": "Section title icon for scene 1, dark theme variant. 16x16px. SVG or PNG preferred. scene1_section_title_text must also be specified to display."
+ },
+ "scene1_section_title_text": {
+ "type": "string",
+ "description": "Section title text for scene 1. scene1_section_title_icon must also be specified to display."
+ },
+ "scene1_section_title_url": {
+ "allOf": [
+ {"$ref": "#/definitions/link_url"},
+ {"description": "A url, scene1_section_title_text links to this"}
+ ]
+ },
+ "scene2_title": {
+ "allOf": [
+ {"$ref": "#/definitions/plainText"},
+ {"description": "Title displayed before text in scene 2. Should be plain text."}
+ ]
+ },
+ "scene2_text": {
+ "allOf": [
+ {"$ref": "#/definitions/richText"},
+ {"description": "Main body text of snippet. HTML subset allowed: i, b, u, strong, em, br"}
+ ]
+ },
+ "scene1_icon": {
+ "type": "string",
+ "description": "Snippet icon. 64x64px. SVG or PNG preferred."
+ },
+ "scene1_icon_dark_theme": {
+ "type": "string",
+ "description": "Snippet icon. Dark theme variant. 64x64px. SVG or PNG preferred."
+ },
+ "scene1_title_icon": {
+ "type": "string",
+ "description": "Small icon that shows up before the title / text. 16x16px. SVG or PNG preferred. Grayscale."
+ },
+ "scene1_title_icon_dark_theme": {
+ "type": "string",
+ "description": "Small icon that shows up before the title / text. Dark theme variant. 16x16px. SVG or PNG preferred. Grayscale."
+ },
+ "scene2_email_placeholder_text": {
+ "type": "string",
+ "description": "Value to show while input is empty.",
+ "default": "Your email here"
+ },
+ "scene2_button_label": {
+ "type": "string",
+ "description": "Label for form submit button",
+ "default": "Sign me up"
+ },
+ "scene2_dismiss_button_text": {
+ "type": "string",
+ "description": "Label for the dismiss button when the sign-up form is expanded.",
+ "default": "Dismiss"
+ },
+ "hidden_inputs": {
+ "type": "object",
+ "description": "Each entry represents a hidden input, key is used as value for the name property.",
+ "properties": {
+ "action": {
+ "type": "string",
+ "enum": ["email"]
+ },
+ "context": {
+ "type": "string",
+ "enum": ["fx_desktop_v3"]
+ },
+ "entrypoint": {
+ "type": "string",
+ "enum": ["snippets"]
+ },
+ "utm_content": {
+ "type": "number",
+ "description": "Firefox version number"
+ },
+ "utm_source": {
+ "type": "string",
+ "enum": ["snippet"]
+ },
+ "utm_campaign": {
+ "type": "string",
+ "description": "(fxa) Value to pass through to GA as utm_campaign."
+ },
+ "utm_term": {
+ "type": "string",
+ "description": "(fxa) Value to pass through to GA as utm_term."
+ },
+ "additionalProperties": false
+ }
+ },
+ "scene1_button_label": {
+ "allOf": [
+ {"$ref": "#/definitions/plainText"},
+ {"description": "Text for a button next to main snippet text that links to button_url. Requires button_url."}
+ ],
+ "default": "Learn more"
+ },
+ "scene1_button_color": {
+ "type": "string",
+ "description": "The text color of the button. Valid CSS color."
+ },
+ "scene1_button_background_color": {
+ "type": "string",
+ "description": "The background color of the button. Valid CSS color."
+ },
+ "retry_button_label": {
+ "allOf": [
+ {"$ref": "#/definitions/plainText"},
+ {"description": "Text for the button in the event of a submission error/failure."}
+ ],
+ "default": "Try again"
+ },
+ "do_not_autoblock": {
+ "type": "boolean",
+ "description": "Used to prevent blocking the snippet after the CTA (link or button) has been clicked",
+ "default": false
+ },
+ "utm_campaign": {
+ "type": "string",
+ "description": "(fxa) Value to pass through to GA as utm_campaign."
+ },
+ "utm_term": {
+ "type": "string",
+ "description": "(fxa) Value to pass through to GA as utm_term."
+ },
+ "links": {
+ "additionalProperties": {
+ "url": {
+ "allOf": [
+ {"$ref": "#/definitions/link_url"},
+ {"description": "The url where the link points to."}
+ ]
+ },
+ "metric": {
+ "type": "string",
+ "description": "Custom event name sent with telemetry event."
+ }
+ }
+ }
+ },
+ "additionalProperties": false,
+ "required": ["scene1_text", "scene2_text", "scene1_button_label"],
+ "dependencies": {
+ "scene1_button_color": ["scene1_button_label"],
+ "scene1_button_background_color": ["scene1_button_label"]
+ }
+}
+
diff --git a/browser/components/newtab/content-src/asrouter/templates/FirstRun/addUtmParams.js b/browser/components/newtab/content-src/asrouter/templates/FirstRun/addUtmParams.js
new file mode 100644
index 0000000000..cb29f66d6e
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/templates/FirstRun/addUtmParams.js
@@ -0,0 +1,30 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * BASE_PARAMS keys/values can be modified from outside this file
+ */
+export const BASE_PARAMS = {
+ utm_source: "activity-stream",
+ utm_campaign: "firstrun",
+ utm_medium: "referral",
+};
+
+/**
+ * Takes in a url as a string or URL object and returns a URL object with the
+ * utm_* parameters added to it. If a URL object is passed in, the paraemeters
+ * are added to it (the return value can be ignored in that case as it's the
+ * same object).
+ */
+export function addUtmParams(url, utmTerm) {
+ let returnUrl = url;
+ if (typeof returnUrl === "string") {
+ returnUrl = new URL(url);
+ }
+ Object.keys(BASE_PARAMS).forEach(key => {
+ returnUrl.searchParams.append(key, BASE_PARAMS[key]);
+ });
+ returnUrl.searchParams.append("utm_term", utmTerm);
+ return returnUrl;
+}
diff --git a/browser/components/newtab/content-src/asrouter/templates/NewsletterSnippet/NewsletterSnippet.jsx b/browser/components/newtab/content-src/asrouter/templates/NewsletterSnippet/NewsletterSnippet.jsx
new file mode 100644
index 0000000000..27c1684762
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/templates/NewsletterSnippet/NewsletterSnippet.jsx
@@ -0,0 +1,34 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import React from "react";
+import { SubmitFormSnippet } from "../SubmitFormSnippet/SubmitFormSnippet.jsx";
+
+export const NewsletterSnippet = props => {
+ const extendedContent = {
+ scene1_button_label: "Learn more",
+ retry_button_label: "Try again",
+ scene2_email_placeholder_text: "Your email here",
+ scene2_button_label: "Sign me up",
+ scene2_dismiss_button_text: "Dismiss",
+ scene2_newsletter: "mozilla-foundation",
+ ...props.content,
+ hidden_inputs: {
+ newsletters: props.content.scene2_newsletter || "mozilla-foundation",
+ fmt: "H",
+ lang: props.content.locale || "en-US",
+ source_url: `https://snippets.mozilla.com/show/${props.id}`,
+ ...props.content.hidden_inputs,
+ },
+ };
+
+ return (
+ <SubmitFormSnippet
+ {...props}
+ content={extendedContent}
+ form_action={"https://basket.mozilla.org/subscribe.json"}
+ form_method="POST"
+ />
+ );
+};
diff --git a/browser/components/newtab/content-src/asrouter/templates/NewsletterSnippet/NewsletterSnippet.schema.json b/browser/components/newtab/content-src/asrouter/templates/NewsletterSnippet/NewsletterSnippet.schema.json
new file mode 100644
index 0000000000..eeb63554ed
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/templates/NewsletterSnippet/NewsletterSnippet.schema.json
@@ -0,0 +1,177 @@
+{
+ "title": "NewsletterSnippet",
+ "description": "A snippet template for send to device mobile download",
+ "version": "1.2.0",
+ "type": "object",
+ "definitions": {
+ "plainText": {
+ "description": "Plain text (no HTML allowed)",
+ "type": "string"
+ },
+ "richText": {
+ "description": "Text with HTML subset allowed: i, b, u, strong, em, br",
+ "type": "string"
+ },
+ "link_url": {
+ "description": "Target for links or buttons",
+ "type": "string",
+ "format": "uri"
+ }
+ },
+ "properties": {
+ "locale": {
+ "type": "string",
+ "description": "Two to five character string for the locale code",
+ "default": "en-US"
+ },
+ "scene1_title": {
+ "allof": [
+ {"$ref": "#/definitions/plainText"},
+ {"description": "snippet title displayed before snippet text"}
+ ]
+ },
+ "scene1_text": {
+ "allOf": [
+ {"$ref": "#/definitions/richText"},
+ {"description": "Main body text of snippet. HTML subset allowed: i, b, u, strong, em, br"}
+ ]
+ },
+ "scene1_section_title_icon": {
+ "type": "string",
+ "description": "Section title icon for scene 1. 16x16px. SVG or PNG preferred. scene1_section_title_text must also be specified to display."
+ },
+ "scene1_section_title_icon_dark_theme": {
+ "type": "string",
+ "description": "Section title icon for scene 1, dark theme variant. 16x16px. SVG or PNG preferred. scene1_section_title_text must also be specified to display."
+ },
+ "scene1_section_title_text": {
+ "type": "string",
+ "description": "Section title text for scene 1. scene1_section_title_icon must also be specified to display."
+ },
+ "scene1_section_title_url": {
+ "allOf": [
+ {"$ref": "#/definitions/link_url"},
+ {"description": "A url, scene1_section_title_text links to this"}
+ ]
+ },
+ "scene2_title": {
+ "allOf": [
+ {"$ref": "#/definitions/plainText"},
+ {"description": "Title displayed before text in scene 2. Should be plain text."}
+ ]
+ },
+ "scene2_text": {
+ "allOf": [
+ {"$ref": "#/definitions/richText"},
+ {"description": "Main body text of snippet. HTML subset allowed: i, b, u, strong, em, br"}
+ ]
+ },
+ "scene1_icon": {
+ "type": "string",
+ "description": "Snippet icon. 64x64px. SVG or PNG preferred."
+ },
+ "scene1_icon_dark_theme": {
+ "type": "string",
+ "description": "Snippet icon. Dark theme variant. 64x64px. SVG or PNG preferred."
+ },
+ "scene1_title_icon": {
+ "type": "string",
+ "description": "Small icon that shows up before the title / text. 16x16px. SVG or PNG preferred. Grayscale."
+ },
+ "scene1_title_icon_dark_theme": {
+ "type": "string",
+ "description": "Small icon that shows up before the title / text. Dark theme variant. 16x16px. SVG or PNG preferred. Grayscale."
+ },
+ "scene2_email_placeholder_text": {
+ "type": "string",
+ "description": "Value to show while input is empty.",
+ "default": "Your email here"
+ },
+ "scene2_button_label": {
+ "type": "string",
+ "description": "Label for form submit button",
+ "default": "Sign me up"
+ },
+ "scene2_privacy_html": {
+ "type": "string",
+ "description": "(send to device) Html for disclaimer and link underneath input box."
+ },
+ "scene2_dismiss_button_text": {
+ "type": "string",
+ "description": "Label for the dismiss button when the sign-up form is expanded.",
+ "default": "Dismiss"
+ },
+ "hidden_inputs": {
+ "type": "object",
+ "description": "Each entry represents a hidden input, key is used as value for the name property.",
+ "properties": {
+ "fmt": {
+ "type": "string",
+ "description": "",
+ "default": "H"
+ }
+ }
+ },
+ "scene1_button_label": {
+ "allOf": [
+ {"$ref": "#/definitions/plainText"},
+ {"description": "Text for a button next to main snippet text that links to button_url. Requires button_url."}
+ ],
+ "default": "Learn more"
+ },
+ "scene1_button_color": {
+ "type": "string",
+ "description": "The text color of the button. Valid CSS color."
+ },
+ "scene1_button_background_color": {
+ "type": "string",
+ "description": "The background color of the button. Valid CSS color."
+ },
+ "retry_button_label": {
+ "allOf": [
+ {"$ref": "#/definitions/plainText"},
+ {"description": "Text for the button in the event of a submission error/failure."}
+ ],
+ "default": "Try again"
+ },
+ "do_not_autoblock": {
+ "type": "boolean",
+ "description": "Used to prevent blocking the snippet after the CTA (link or button) has been clicked",
+ "default": false
+ },
+ "success_text": {
+ "type": "string",
+ "description": "Message shown on successful registration."
+ },
+ "error_text": {
+ "type": "string",
+ "description": "Message shown if registration failed."
+ },
+ "scene2_newsletter": {
+ "type": "string",
+ "description": "Newsletter/basket id user is subscribing to.",
+ "default": "mozilla-foundation"
+ },
+ "links": {
+ "additionalProperties": {
+ "url": {
+ "allOf": [
+ {"$ref": "#/definitions/link_url"},
+ {"description": "The url where the link points to."}
+ ]
+ },
+ "metric": {
+ "type": "string",
+ "description": "Custom event name sent with telemetry event."
+ }
+ }
+ }
+ },
+ "additionalProperties": false,
+ "required": ["scene1_text", "scene2_text", "scene1_button_label"],
+ "dependencies": {
+ "scene1_button_color": ["scene1_button_label"],
+ "scene1_button_background_color": ["scene1_button_label"]
+ }
+}
+
diff --git a/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/OnboardingMessage.jsx b/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/OnboardingMessage.jsx
new file mode 100644
index 0000000000..ce1a840247
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/OnboardingMessage.jsx
@@ -0,0 +1,52 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import React from "react";
+import { Localized } from "../../../aboutwelcome/components/MSLocalized";
+
+export class OnboardingCard extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.onClick = this.onClick.bind(this);
+ }
+
+ onClick() {
+ const { props } = this;
+ const ping = {
+ event: "CLICK_BUTTON",
+ message_id: props.id,
+ id: props.UISurface,
+ };
+ props.sendUserActionTelemetry(ping);
+ props.onAction(props.content.primary_button.action, props.message);
+ }
+
+ render() {
+ const { content } = this.props;
+ const className = this.props.className || "onboardingMessage";
+ return (
+ <div className={className}>
+ <div className={`onboardingMessageImage ${content.icon}`} />
+ <div className="onboardingContent">
+ <span>
+ <Localized text={content.title}>
+ <h2 className="onboardingTitle" />
+ </Localized>
+ <Localized text={content.text}>
+ <p className="onboardingText" />
+ </Localized>
+ </span>
+ <span className="onboardingButtonContainer">
+ <Localized text={content.primary_button.label}>
+ <button
+ className="button onboardingButton"
+ onClick={this.onClick}
+ />
+ </Localized>
+ </span>
+ </div>
+ </div>
+ );
+ }
+}
diff --git a/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/OnboardingMessage.schema.json b/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/OnboardingMessage.schema.json
new file mode 100644
index 0000000000..f355d89da7
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/OnboardingMessage.schema.json
@@ -0,0 +1,142 @@
+{
+ "title": "OnboardingMessage",
+ "description": "A template with a title, icon, button and description. No markup allowed.",
+ "version": "1.0.0",
+ "type": "object",
+ "properties": {
+ "title": {
+ "oneOf": [
+ {
+ "type": "string",
+ "description": "The message displayed in the title of the onboarding card"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "string_id": {
+ "type": "string"
+ }
+ },
+ "required": ["string_id"],
+ "description": "Id of localized string for onboarding card title"
+ }
+ ],
+ "description": "Id of localized string or message override."
+ },
+ "text": {
+ "oneOf": [
+ {
+ "type": "string",
+ "description": "The message displayed in the description of the onboarding card"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "string_id": {
+ "type": "string"
+ },
+ "args": {
+ "type": "object",
+ "description": "An optional argument to pass to the localization module"
+ }
+ },
+ "required": ["string_id"],
+ "description": "Id of localized string for onboarding card description"
+ }
+ ],
+ "description": "Id of localized string or message override."
+ },
+ "icon": {
+ "allOf": [
+ {
+ "type": "string",
+ "description": "Image associated with the onboarding card"
+ }
+ ]
+ },
+ "primary_button": {
+ "type": "object",
+ "properties": {
+ "label": {
+ "oneOf": [
+ {
+ "type": "string",
+ "description": "The label of the onboarding messages' action button"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "string_id": {
+ "type": "string"
+ }
+ },
+ "required": ["string_id"],
+ "description": "Id of localized string for onboarding messages' button"
+ }
+ ],
+ "description": "Id of localized string or message override."
+ },
+ "action": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "description": "Action dispatched by the button."
+ },
+ "data": {
+ "properties": {
+ "args": {
+ "type": "string",
+ "description": "Additional parameters for button action, for example which link the button should open."
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "secondary_buttons": {
+ "type": "object",
+ "properties": {
+ "label": {
+ "oneOf": [
+ {
+ "type": "string",
+ "description": "The label of the onboarding messages' (optional) secondary action button"
+ },
+ {
+ "type": "object",
+ "properties": {
+ "string_id": {
+ "type": "string"
+ }
+ },
+ "required": ["string_id"],
+ "description": "Id of localized string for onboarding messages' button"
+ }
+ ],
+ "description": "Id of localized string or message override."
+ },
+ "action": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "description": "Action dispatched by the button."
+ },
+ "data": {
+ "properties": {
+ "args": {
+ "type": "string",
+ "description": "Additional parameters for button action, for example which link the button should open."
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "additionalProperties": true,
+ "required": ["title", "text", "icon", "primary_button"]
+}
diff --git a/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/ToolbarBadgeMessage.schema.json b/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/ToolbarBadgeMessage.schema.json
new file mode 100644
index 0000000000..b873e62b83
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/ToolbarBadgeMessage.schema.json
@@ -0,0 +1,39 @@
+{
+ "title": "ToolbarBadgeMessage",
+ "description": "A template that specifies to which element in the browser toolbar to add a notification.",
+ "version": "1.1.0",
+ "type": "object",
+ "properties": {
+ "target": {
+ "type": "string"
+ },
+ "action": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string"
+ }
+ },
+ "additionalProperties": false,
+ "required": ["id"],
+ "description": "Optional action to take in addition to showing the notification"
+ },
+ "delay": {
+ "type": "number",
+ "description": "Optional delay in ms after which to show the notification"
+ },
+ "badgeDescription": {
+ "type": "object",
+ "description": "This is used in combination with the badged button to offer a text based alternative to the visual badging. Example 'New Feature: What's New'",
+ "properties": {
+ "string_id": {
+ "type": "string",
+ "description": "Fluent string id"
+ }
+ },
+ "required": ["string_id"]
+ }
+ },
+ "additionalProperties": false,
+ "required": ["target"]
+}
diff --git a/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/UpdateAction.schema.json b/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/UpdateAction.schema.json
new file mode 100644
index 0000000000..7624c67d4c
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/UpdateAction.schema.json
@@ -0,0 +1,36 @@
+{
+ "title": "UpdateActionMessage",
+ "description": "A template for messages that execute predetermined actions.",
+ "version": "1.0.0",
+ "type": "object",
+ "properties": {
+ "action": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "data": {
+ "type": "object",
+ "properties": {
+ "url": {
+ "type": "string",
+ "description": "URL data to be used as argument to the action"
+ },
+ "expireDelta": {
+ "type": "number",
+ "description": "Expiration timestamp to be used as argument to the action"
+ }
+ }
+ },
+ "description": "Additional data provided as argument when executing the action"
+ },
+ "additionalProperties": false,
+ "description": "Optional action to take in addition to showing the notification"
+ },
+ "additionalProperties": false,
+ "required": ["id", "action"]
+ },
+ "additionalProperties": false,
+ "required": ["action"]
+}
diff --git a/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/WhatsNewMessage.schema.json b/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/WhatsNewMessage.schema.json
new file mode 100644
index 0000000000..998f2cfc8d
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/WhatsNewMessage.schema.json
@@ -0,0 +1,97 @@
+{
+ "title": "WhatsNewMessage",
+ "description": "A template for the messages that appear in the What's New panel.",
+ "version": "1.2.0",
+ "type": "object",
+ "definitions": {
+ "localizableText": {
+ "oneOf": [
+ {
+ "type": "string",
+ "description": "The string to be rendered."
+ },
+ {
+ "type": "object",
+ "properties": {
+ "string_id": {
+ "type": "string"
+ }
+ },
+ "required": ["string_id"],
+ "description": "Id of localized string to be rendered."
+ }
+ ]
+ }
+ },
+ "properties": {
+ "layout": {
+ "description": "Different message layouts",
+ "enum": ["tracking-protections"]
+ },
+ "layout_title_content_variable": {
+ "description": "Select what profile specific value to show for the current layout.",
+ "type": "string"
+ },
+ "bucket_id": {
+ "type": "string",
+ "description": "A bucket identifier for the addon. This is used in order to anonymize telemetry for history-sensitive targeting."
+ },
+ "published_date": {
+ "type": "integer",
+ "description": "The date/time (number of milliseconds elapsed since January 1, 1970 00:00:00 UTC) the message was published."
+ },
+ "title": {
+ "allOf": [
+ {"$ref": "#/definitions/localizableText"},
+ {"description": "Id of localized string or message override of What's New message title"}
+ ]
+ },
+ "subtitle": {
+ "allOf": [
+ {"$ref": "#/definitions/localizableText"},
+ {"description": "Id of localized string or message override of What's New message subtitle"}
+ ]
+ },
+ "body": {
+ "allOf": [
+ {"$ref": "#/definitions/localizableText"},
+ {"description": "Id of localized string or message override of What's New message body"}
+ ]
+ },
+ "link_text": {
+ "allOf": [
+ {"$ref": "#/definitions/localizableText"},
+ {"description": "(optional) Id of localized string or message override of What's New message link text"}
+ ]
+ },
+ "cta_url": {
+ "description": "Target URL for the What's New message.",
+ "type": "string",
+ "format": "uri"
+ },
+ "cta_type": {
+ "description": "Type of url open action",
+ "enum": ["OPEN_URL", "OPEN_ABOUT_PAGE", "OPEN_PROTECTION_REPORT"]
+ },
+ "cta_where": {
+ "description": "How to open the cta: new window, tab, focused, unfocused.",
+ "enum": ["current", "tabshifted", "tab", "save", "window"]
+ },
+ "icon_url": {
+ "description": "(optional) URL for the What's New message icon.",
+ "type": "string",
+ "format": "uri"
+ },
+ "icon_alt": {
+ "allOf": [
+ {"$ref": "#/definitions/localizableText"},
+ {"description": "Alt text for image."}
+ ]
+ }
+ },
+ "additionalProperties": false,
+ "required": ["published_date", "title", "body", "cta_url", "bucket_id"],
+ "dependencies": {
+ "layout": ["layout_title_content_variable"]
+ }
+}
diff --git a/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/_OnboardingMessage.scss b/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/_OnboardingMessage.scss
new file mode 100644
index 0000000000..d4109317a1
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/_OnboardingMessage.scss
@@ -0,0 +1,131 @@
+@import '../../../styles/OnboardingImages';
+
+.onboardingMessage {
+ height: 340px;
+ text-align: center;
+ padding: 13px;
+ font-weight: 200;
+
+ // at 850px, img floats left, content floats right next to it
+ @media(max-width: 850px) {
+ height: 170px;
+ text-align: left;
+ padding: 10px;
+ border-bottom: 1px solid $grey-30;
+ display: flex;
+ margin-bottom: 11px;
+
+ &:last-child {
+ border: 0;
+ }
+
+ .onboardingContent {
+ padding-left: 10px;
+ height: 100%;
+
+ > span > h3 {
+ margin-top: 0;
+ margin-bottom: 4px;
+ font-weight: 400;
+ }
+
+ > span > p {
+ margin-top: 0;
+ line-height: 22px;
+ font-size: 15px;
+ }
+ }
+ }
+
+ @media(max-width: 650px) {
+ height: 250px;
+ }
+
+ .onboardingContent {
+ height: 175px;
+
+ > span > h3 {
+ color: $grey-90;
+ margin-bottom: 8px;
+ font-weight: 400;
+ }
+
+ > span > p {
+ color: $grey-60;
+ margin-top: 0;
+ height: 180px;
+ margin-bottom: 12px;
+ font-size: 15px;
+ line-height: 22px;
+
+ @media(max-width: 650px) {
+ margin-bottom: 0;
+ height: 160px;
+ }
+ }
+ }
+
+ .onboardingButton {
+ background-color: $grey-90-10;
+ border: 0;
+ width: 150px;
+ height: 30px;
+ margin-bottom: 23px;
+ padding: 4px 0 6px;
+ font-size: 15px;
+
+ // at 850px, the button shimmies down and to the right
+ @media(max-width: 850px) {
+ float: right;
+ margin-top: -105px;
+ margin-inline-end: -10px;
+ }
+
+ @media(max-width: 650px) {
+ float: none;
+ }
+
+ &:focus,
+ &.active,
+ &:hover {
+ box-shadow: 0 0 0 5px $grey-30;
+ transition: box-shadow 150ms;
+ }
+ }
+
+
+ &::before {
+ content: '';
+ height: 230px;
+ width: 1px;
+ position: absolute;
+ background-color: $grey-30;
+ margin-top: 40px;
+ margin-inline-start: 215px;
+
+ // at 850px, the line goes from vertical to horizontal
+ @media(max-width: 850px) {
+ content: none;
+ }
+ }
+
+ &:last-child::before {
+ content: none;
+ }
+}
+
+.onboardingMessageImage {
+ height: 112px;
+ width: 180px;
+ background-size: auto 140px;
+ background-position: center center;
+ background-repeat: no-repeat;
+ display: inline-block;
+
+ // Cards will wrap into the next line after this breakpoint
+ @media(max-width: 865px) {
+ height: 75px;
+ min-width: 80px;
+ background-size: 140px;
+ }
+}
diff --git a/browser/components/newtab/content-src/asrouter/templates/SendToDeviceSnippet/SendToDeviceSnippet.jsx b/browser/components/newtab/content-src/asrouter/templates/SendToDeviceSnippet/SendToDeviceSnippet.jsx
new file mode 100644
index 0000000000..0929b8f711
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/templates/SendToDeviceSnippet/SendToDeviceSnippet.jsx
@@ -0,0 +1,76 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { isEmailOrPhoneNumber } from "./isEmailOrPhoneNumber";
+import React from "react";
+import { SubmitFormSnippet } from "../SubmitFormSnippet/SubmitFormSnippet.jsx";
+
+function validateInput(value, content) {
+ const type = isEmailOrPhoneNumber(value, content);
+ return type ? "" : "Must be an email or a phone number.";
+}
+
+function processFormData(input, message) {
+ const { content } = message;
+ const type = content.include_sms
+ ? isEmailOrPhoneNumber(input.value, content)
+ : "email";
+ const formData = new FormData();
+ let url;
+ if (type === "phone") {
+ url = "https://basket.mozilla.org/news/subscribe_sms/";
+ formData.append("mobile_number", input.value);
+ formData.append("msg_name", content.message_id_sms);
+ formData.append("country", content.country);
+ } else if (type === "email") {
+ url = "https://basket.mozilla.org/news/subscribe/";
+ formData.append("email", input.value);
+ formData.append("newsletters", content.message_id_email);
+ formData.append(
+ "source_url",
+ encodeURIComponent(`https://snippets.mozilla.com/show/${message.id}`)
+ );
+ }
+ formData.append("lang", content.locale);
+ return { formData, url };
+}
+
+function addDefaultValues(props) {
+ return {
+ ...props,
+ content: {
+ scene1_button_label: "Learn more",
+ retry_button_label: "Try again",
+ scene2_dismiss_button_text: "Dismiss",
+ scene2_button_label: "Send",
+ scene2_input_placeholder: "Your email here",
+ locale: "en-US",
+ country: "us",
+ message_id_email: "",
+ include_sms: false,
+ ...props.content,
+ },
+ };
+}
+
+export const SendToDeviceSnippet = props => {
+ const propsWithDefaults = addDefaultValues(props);
+
+ return (
+ <SubmitFormSnippet
+ {...propsWithDefaults}
+ form_method="POST"
+ className="send_to_device_snippet"
+ inputType={propsWithDefaults.content.include_sms ? "text" : "email"}
+ validateInput={
+ propsWithDefaults.content.include_sms ? validateInput : null
+ }
+ processFormData={processFormData}
+ />
+ );
+};
+
+export const SendToDeviceScene2Snippet = props => {
+ return <SendToDeviceSnippet expandedAlt={true} {...props} />;
+};
diff --git a/browser/components/newtab/content-src/asrouter/templates/SendToDeviceSnippet/SendToDeviceSnippet.schema.json b/browser/components/newtab/content-src/asrouter/templates/SendToDeviceSnippet/SendToDeviceSnippet.schema.json
new file mode 100644
index 0000000000..238840234a
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/templates/SendToDeviceSnippet/SendToDeviceSnippet.schema.json
@@ -0,0 +1,234 @@
+{
+ "title": "SendToDeviceSnippet",
+ "description": "A snippet template for send to device mobile download",
+ "version": "1.2.0",
+ "type": "object",
+ "definitions": {
+ "plainText": {
+ "description": "Plain text (no HTML allowed)",
+ "type": "string"
+ },
+ "richText": {
+ "description": "Text with HTML subset allowed: i, b, u, strong, em, br",
+ "type": "string"
+ },
+ "link_url": {
+ "description": "Target for links or buttons",
+ "type": "string",
+ "format": "uri"
+ }
+ },
+ "properties": {
+ "locale": {
+ "type": "string",
+ "description": "Two to five character string for the locale code",
+ "default": "en-US"
+ },
+ "country": {
+ "type": "string",
+ "description": "Two character string for the country code (used for SMS)",
+ "default": "us"
+ },
+ "scene1_title": {
+ "allof": [
+ {"$ref": "#/definitions/plainText"},
+ {"description": "snippet title displayed before snippet text"}
+ ]
+ },
+ "scene1_text": {
+ "allOf": [
+ {"$ref": "#/definitions/richText"},
+ {"description": "Main body text of snippet. HTML subset allowed: i, b, u, strong, em, br"}
+ ]
+ },
+ "scene1_section_title_icon": {
+ "type": "string",
+ "description": "Section title icon for scene 1. 16x16px. SVG or PNG preferred. scene1_section_title_text must also be specified to display."
+ },
+ "scene1_section_title_icon_dark_theme": {
+ "type": "string",
+ "description": "Section title icon for scene 1, dark theme variant. 16x16px. SVG or PNG preferred. scene1_section_title_text must also be specified to display."
+ },
+ "scene1_section_title_text": {
+ "type": "string",
+ "description": "Section title text for scene 1. scene1_section_title_icon must also be specified to display."
+ },
+ "scene1_section_title_url": {
+ "allOf": [
+ {"$ref": "#/definitions/link_url"},
+ {"description": "A url, scene1_section_title_text links to this"}
+ ]
+ },
+ "scene2_title": {
+ "allOf": [
+ {"$ref": "#/definitions/plainText"},
+ {"description": "Title displayed before text in scene 2. Should be plain text."}
+ ]
+ },
+ "scene2_text": {
+ "allOf": [
+ {"$ref": "#/definitions/richText"},
+ {"description": "Main body text of snippet. HTML subset allowed: i, b, u, strong, em, br"}
+ ]
+ },
+ "scene1_icon": {
+ "type": "string",
+ "description": "Snippet icon. 64x64px. SVG or PNG preferred."
+ },
+ "scene1_icon_dark_theme": {
+ "type": "string",
+ "description": "Snippet icon. Dark theme variant. 64x64px. SVG or PNG preferred."
+ },
+ "scene2_icon": {
+ "type": "string",
+ "description": "(send to device) Image to display above the form. Dark theme variant. 98x98px. SVG or PNG preferred."
+ },
+ "scene2_icon_dark_theme": {
+ "type": "string",
+ "description": "(send to device) Image to display above the form. 98x98px. SVG or PNG preferred."
+ },
+ "scene1_title_icon": {
+ "type": "string",
+ "description": "Small icon that shows up before the title / text. 16x16px. SVG or PNG preferred. Grayscale."
+ },
+ "scene1_title_icon_dark_theme": {
+ "type": "string",
+ "description": "Small icon that shows up before the title / text. Dark theme variant. 16x16px. SVG or PNG preferred. Grayscale."
+ },
+ "scene2_button_label": {
+ "type": "string",
+ "description": "Label for form submit button",
+ "default": "Send"
+ },
+ "scene2_input_placeholder": {
+ "type": "string",
+ "description": "(send to device) Value to show while input is empty.",
+ "default": "Your email here"
+ },
+ "scene2_disclaimer_html": {
+ "type": "string",
+ "description": "(send to device) Html for disclaimer and link underneath input box."
+ },
+ "scene2_dismiss_button_text": {
+ "type": "string",
+ "description": "Label for the dismiss button when the sign-up form is expanded.",
+ "default": "Dismiss"
+ },
+ "hidden_inputs": {
+ "type": "object",
+ "description": "Each entry represents a hidden input, key is used as value for the name property.",
+ "properties": {
+ "action": {
+ "type": "string",
+ "enum": ["email"]
+ },
+ "context": {
+ "type": "string",
+ "enum": ["fx_desktop_v3"]
+ },
+ "entrypoint": {
+ "type": "string",
+ "enum": ["snippets"]
+ },
+ "utm_content": {
+ "type": "string",
+ "description": "Firefox version number"
+ },
+ "utm_source": {
+ "type": "string",
+ "enum": ["snippet"]
+ },
+ "utm_campaign": {
+ "type": "string",
+ "description": "(fxa) Value to pass through to GA as utm_campaign."
+ },
+ "utm_term": {
+ "type": "string",
+ "description": "(fxa) Value to pass through to GA as utm_term."
+ },
+ "additionalProperties": false
+ }
+ },
+ "scene1_button_label": {
+ "allOf": [
+ {"$ref": "#/definitions/plainText"},
+ {"description": "Text for a button next to main snippet text that links to button_url. Requires button_url."}
+ ],
+ "default": "Learn more"
+ },
+ "scene1_button_color": {
+ "type": "string",
+ "description": "The text color of the button. Valid CSS color."
+ },
+ "scene1_button_background_color": {
+ "type": "string",
+ "description": "The background color of the button. Valid CSS color."
+ },
+ "retry_button_label": {
+ "allOf": [
+ {"$ref": "#/definitions/plainText"},
+ {"description": "Text for the button in the event of a submission error/failure."}
+ ],
+ "default": "Try again"
+ },
+ "do_not_autoblock": {
+ "type": "boolean",
+ "description": "Used to prevent blocking the snippet after the CTA (link or button) has been clicked",
+ "default": false
+ },
+ "success_title": {
+ "type": "string",
+ "description": "(send to device) Title shown before text on successful registration."
+ },
+ "success_text": {
+ "type": "string",
+ "description": "Message shown on successful registration."
+ },
+ "error_text": {
+ "type": "string",
+ "description": "Message shown if registration failed."
+ },
+ "include_sms": {
+ "type": "boolean",
+ "description": "(send to device) Allow users to send an SMS message with the form?",
+ "default": false
+ },
+ "message_id_sms": {
+ "type": "string",
+ "description": "(send to device) Newsletter/basket id representing the SMS message to be sent."
+ },
+ "message_id_email": {
+ "type": "string",
+ "description": "(send to device) Newsletter/basket id representing the email message to be sent. Must be a value from the 'Slug' column here: https://basket.mozilla.org/news/."
+ },
+ "utm_campaign": {
+ "type": "string",
+ "description": "(fxa) Value to pass through to GA as utm_campaign."
+ },
+ "utm_term": {
+ "type": "string",
+ "description": "(fxa) Value to pass through to GA as utm_term."
+ },
+ "links": {
+ "additionalProperties": {
+ "url": {
+ "allOf": [
+ {"$ref": "#/definitions/link_url"},
+ {"description": "The url where the link points to."}
+ ]
+ },
+ "metric": {
+ "type": "string",
+ "description": "Custom event name sent with telemetry event."
+ }
+ }
+ }
+ },
+ "additionalProperties": false,
+ "required": ["scene1_text", "scene2_text", "scene1_button_label"],
+ "dependencies": {
+ "scene1_button_color": ["scene1_button_label"],
+ "scene1_button_background_color": ["scene1_button_label"]
+ }
+}
+
diff --git a/browser/components/newtab/content-src/asrouter/templates/SendToDeviceSnippet/isEmailOrPhoneNumber.js b/browser/components/newtab/content-src/asrouter/templates/SendToDeviceSnippet/isEmailOrPhoneNumber.js
new file mode 100644
index 0000000000..29addb688d
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/templates/SendToDeviceSnippet/isEmailOrPhoneNumber.js
@@ -0,0 +1,38 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * Checks if a given string is an email or phone number or neither
+ * @param {string} val The user input
+ * @param {ASRMessageContent} content .content property on ASR message
+ * @returns {"email"|"phone"|""} The type of the input
+ */
+export function isEmailOrPhoneNumber(val, content) {
+ const { locale } = content;
+ // http://emailregex.com/
+ const email_re = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
+ const check_email = email_re.test(val);
+ let check_phone; // depends on locale
+ switch (locale) {
+ case "en-US":
+ case "en-CA":
+ // allow 10-11 digits in case user wants to enter country code
+ check_phone = val.length >= 10 && val.length <= 11 && !isNaN(val);
+ break;
+ case "de":
+ // allow between 2 and 12 digits for german phone numbers
+ check_phone = val.length >= 2 && val.length <= 12 && !isNaN(val);
+ break;
+ // this case should never be hit, but good to have a fallback just in case
+ default:
+ check_phone = !isNaN(val);
+ break;
+ }
+ if (check_email) {
+ return "email";
+ } else if (check_phone) {
+ return "phone";
+ }
+ return "";
+}
diff --git a/browser/components/newtab/content-src/asrouter/templates/SimpleBelowSearchSnippet/SimpleBelowSearchSnippet.jsx b/browser/components/newtab/content-src/asrouter/templates/SimpleBelowSearchSnippet/SimpleBelowSearchSnippet.jsx
new file mode 100644
index 0000000000..2641d51e86
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/templates/SimpleBelowSearchSnippet/SimpleBelowSearchSnippet.jsx
@@ -0,0 +1,133 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import React from "react";
+import { Button } from "../../components/Button/Button";
+import { RichText } from "../../components/RichText/RichText";
+import { safeURI } from "../../template-utils";
+import { SnippetBase } from "../../components/SnippetBase/SnippetBase";
+
+const DEFAULT_ICON_PATH = "chrome://branding/content/icon64.png";
+// Alt text placeholder in case the prop from the server isn't available
+const ICON_ALT_TEXT = "";
+
+export class SimpleBelowSearchSnippet extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.onButtonClick = this.onButtonClick.bind(this);
+ }
+
+ renderText() {
+ const { props } = this;
+ return props.content.text ? (
+ <RichText
+ text={props.content.text}
+ customElements={this.props.customElements}
+ localization_id="text"
+ links={props.content.links}
+ sendClick={props.sendClick}
+ />
+ ) : null;
+ }
+
+ renderTitle() {
+ const { title } = this.props.content;
+ return title ? (
+ <h3 className={"title title-inline"}>
+ {title}
+ <br />
+ </h3>
+ ) : null;
+ }
+
+ async onButtonClick() {
+ if (this.props.provider !== "preview") {
+ this.props.sendUserActionTelemetry({
+ event: "CLICK_BUTTON",
+ id: this.props.UISurface,
+ });
+ }
+ const { button_url } = this.props.content;
+ // If button_url is defined handle it as OPEN_URL action
+ const type = this.props.content.button_action || (button_url && "OPEN_URL");
+ await this.props.onAction({
+ type,
+ data: { args: this.props.content.button_action_args || button_url },
+ });
+ if (!this.props.content.do_not_autoblock) {
+ this.props.onBlock();
+ }
+ }
+
+ _shouldRenderButton() {
+ return (
+ this.props.content.button_action ||
+ this.props.onButtonClick ||
+ this.props.content.button_url
+ );
+ }
+
+ renderButton() {
+ const { props } = this;
+ if (!this._shouldRenderButton()) {
+ return null;
+ }
+
+ return (
+ <Button
+ onClick={props.onButtonClick || this.onButtonClick}
+ color={props.content.button_color}
+ backgroundColor={props.content.button_background_color}
+ >
+ {props.content.button_label}
+ </Button>
+ );
+ }
+
+ render() {
+ const { props } = this;
+ let className = "SimpleBelowSearchSnippet";
+ let containerName = "below-search-snippet";
+
+ if (props.className) {
+ className += ` ${props.className}`;
+ }
+ if (this._shouldRenderButton()) {
+ className += " withButton";
+ containerName += " withButton";
+ }
+
+ return (
+ <div className={containerName}>
+ <div className="snippet-hover-wrapper">
+ <SnippetBase
+ {...props}
+ className={className}
+ textStyle={this.props.textStyle}
+ >
+ <img
+ src={safeURI(props.content.icon) || DEFAULT_ICON_PATH}
+ className="icon icon-light-theme"
+ alt={props.content.icon_alt_text || ICON_ALT_TEXT}
+ />
+ <img
+ src={
+ safeURI(props.content.icon_dark_theme || props.content.icon) ||
+ DEFAULT_ICON_PATH
+ }
+ className="icon icon-dark-theme"
+ alt={props.content.icon_alt_text || ICON_ALT_TEXT}
+ />
+ <div className="textContainer">
+ {this.renderTitle()}
+ <p className="body">{this.renderText()}</p>
+ {this.props.extraContent}
+ </div>
+ {<div className="buttonContainer">{this.renderButton()}</div>}
+ </SnippetBase>
+ </div>
+ </div>
+ );
+ }
+}
diff --git a/browser/components/newtab/content-src/asrouter/templates/SimpleBelowSearchSnippet/SimpleBelowSearchSnippet.schema.json b/browser/components/newtab/content-src/asrouter/templates/SimpleBelowSearchSnippet/SimpleBelowSearchSnippet.schema.json
new file mode 100644
index 0000000000..049f66ef6b
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/templates/SimpleBelowSearchSnippet/SimpleBelowSearchSnippet.schema.json
@@ -0,0 +1,110 @@
+{
+ "title": "SimpleBelowSearchSnippet",
+ "description": "A simple template with an icon, rich text and an optional button. It gets inserted below the Activity Stream search box.",
+ "version": "1.2.0",
+ "type": "object",
+ "definitions": {
+ "plainText": {
+ "description": "Plain text (no HTML allowed)",
+ "type": "string"
+ },
+ "richText": {
+ "description": "Text with HTML subset allowed: i, b, u, strong, em, br",
+ "type": "string"
+ },
+ "link_url": {
+ "description": "Target for links or buttons",
+ "type": "string",
+ "format": "uri"
+ }
+ },
+ "properties": {
+ "title": {
+ "allOf": [
+ {"$ref": "#/definitions/plainText"},
+ {"description": "Snippet title displayed before snippet text"}
+ ]
+ },
+ "text": {
+ "allOf": [
+ {"$ref": "#/definitions/richText"},
+ {"description": "Main body text of snippet. HTML subset allowed: i, b, u, strong, em, br"}
+ ]
+ },
+ "icon": {
+ "type": "string",
+ "description": "Snippet icon. 64x64px. SVG or PNG preferred."
+ },
+ "icon_dark_theme": {
+ "type": "string",
+ "description": "Snippet icon. Dark theme variant. 64x64px. SVG or PNG preferred."
+ },
+ "icon_alt_text": {
+ "type": "string",
+ "description": "Alt text describing icon for screen readers",
+ "default": ""
+ },
+ "block_button_text": {
+ "type": "string",
+ "description": "Tooltip text used for dismiss button.",
+ "default": "Remove this"
+ },
+ "button_action": {
+ "type": "string",
+ "description": "The type of action the button should trigger."
+ },
+ "button_url": {
+ "allOf": [
+ {"$ref": "#/definitions/link_url"},
+ {"description": "A url, button_label links to this"}
+ ]
+ },
+ "button_action_args": {
+ "description": "Additional parameters for button action, example which specific menu the button should open"
+ },
+ "button_label": {
+ "allOf": [
+ {"$ref": "#/definitions/plainText"},
+ {"description": "Text for a button next to main snippet text that links to button_url. Requires button_url."}
+ ]
+ },
+ "button_color": {
+ "type": "string",
+ "description": "The text color of the button. Valid CSS color."
+ },
+ "button_background_color": {
+ "type": "string",
+ "description": "The background color of the button. Valid CSS color."
+ },
+ "do_not_autoblock": {
+ "type": "boolean",
+ "description": "Used to prevent blocking the snippet after the CTA link has been clicked"
+ },
+ "links": {
+ "additionalProperties": {
+ "url": {
+ "allOf": [
+ {"$ref": "#/definitions/link_url"},
+ {"description": "The url where the link points to."}
+ ]
+ },
+ "metric": {
+ "type": "string",
+ "description": "Custom event name sent with telemetry event."
+ },
+ "args": {
+ "type": "string",
+ "description": "Additional parameters for link action, example which specific menu the button should open"
+ }
+ }
+ }
+ },
+ "additionalProperties": false,
+ "required": ["text"],
+ "dependencies": {
+ "button_action": ["button_label"],
+ "button_url": ["button_label"],
+ "button_color": ["button_label"],
+ "button_background_color": ["button_label"]
+ }
+}
diff --git a/browser/components/newtab/content-src/asrouter/templates/SimpleBelowSearchSnippet/_SimpleBelowSearchSnippet.scss b/browser/components/newtab/content-src/asrouter/templates/SimpleBelowSearchSnippet/_SimpleBelowSearchSnippet.scss
new file mode 100644
index 0000000000..dd9e637529
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/templates/SimpleBelowSearchSnippet/_SimpleBelowSearchSnippet.scss
@@ -0,0 +1,198 @@
+
+.below-search-snippet {
+ margin: 0 auto 16px;
+
+ &.withButton {
+ margin: auto;
+ min-height: 60px;
+ background-color: transparent;
+
+ .snippet-hover-wrapper {
+ min-height: 60px;
+ border-radius: 4px;
+
+ &:hover {
+ background-color: var(--newtab-element-hover-color);
+
+ .blockButton {
+ display: block;
+ opacity: 1;
+ }
+ }
+ }
+ }
+}
+
+.SimpleBelowSearchSnippet {
+ background-color: transparent;
+ border: 0;
+ box-shadow: none;
+ position: relative;
+ margin: auto;
+ z-index: auto;
+
+ @media (min-width: $break-point-large) {
+ width: 736px;
+ }
+
+ &.active {
+ background-color: var(--newtab-element-hover-color);
+ border-radius: 4px;
+ }
+
+ .innerWrapper {
+ align-items: center;
+ background-color: transparent;
+ border-radius: 4px;
+ box-shadow: var(--newtab-card-shadow);
+ flex-direction: column;
+ padding: 16px;
+ text-align: center;
+ width: 100%;
+
+ @mixin full-width-styles {
+ align-items: flex-start;
+ background-color: transparent;
+ border-radius: 4px;
+ box-shadow: none;
+ flex-direction: row;
+ padding: 0;
+ text-align: inherit;
+ width: 696px;
+ }
+
+ @media (min-width: $break-point-medium) {
+ @include full-width-styles;
+ }
+
+ @media (max-width: 865px) {
+ margin-inline-start: 0;
+ }
+
+ // There is an off-by-one gap between breakpoints; this is to prevent weirdness at exactly 610px.
+ @media (max-width: $break-point-medium - 1px) {
+ margin: auto;
+ }
+ }
+
+ .blockButton {
+ display: block;
+ inset-inline-end: 10px;
+ opacity: 1;
+ top: 50%;
+
+ &:focus {
+ box-shadow: 0 0 0 1px $blue-50 inset, 0 0 0 1px $blue-50, 0 0 0 4px $blue-50-30;
+ border-radius: 2px;
+ }
+ }
+
+ .title {
+ font-size: inherit;
+ margin: 0;
+ }
+
+ .title-inline {
+ display: inline;
+ }
+
+ .textContainer {
+ margin: 10px;
+ margin-inline-start: 0;
+ padding-inline-end: 20px;
+ }
+
+ .icon {
+ margin-top: 8px;
+ margin-inline-start: 12px;
+ height: 32px;
+ width: 32px;
+
+ @mixin full-width-styles {
+ height: 24px;
+ width: 24px;
+ }
+
+ @media (min-width: $break-point-medium) {
+ @include full-width-styles;
+ }
+
+ @media (max-width: $break-point-medium) {
+ margin: auto;
+ }
+ }
+
+ &.withButton {
+ line-height: 20px;
+ margin-bottom: 10px;
+ min-height: 60px;
+ background-color: transparent;
+
+ .innerWrapper {
+ // There is an off-by-one gap between breakpoints; this is to prevent weirdness at exactly 1121px.
+ @media (max-width: $break-point-widest + 1px) {
+ margin: 0 40px;
+ }
+ }
+
+ .blockButton {
+ display: block;
+ inset-inline-end: -10%;
+ opacity: 0;
+ margin: auto;
+ top: unset;
+
+ &:focus {
+ opacity: 1;
+ box-shadow: none;
+ }
+
+ // There is an off-by-one gap between breakpoints; this is to prevent weirdness at exactly 1121px.
+ @media (max-width: $break-point-widest + 1px) {
+ inset-inline-end: 2%;
+ }
+ }
+
+ .icon {
+ width: 42px;
+ height: 42px;
+ flex-shrink: 0;
+ margin: auto 0;
+ margin-inline-end: 10px;
+
+ @media (max-width: $break-point-medium) {
+ margin: auto;
+ }
+ }
+
+ .buttonContainer {
+ margin: auto;
+ margin-inline-end: 0;
+
+ @media (max-width: $break-point-medium) {
+ margin: auto;
+ }
+ }
+ }
+
+ button {
+ @media (max-width: $break-point-medium) {
+ margin: auto;
+ }
+ }
+
+ .body {
+ display: inline;
+ position: sticky;
+ transform: translateY(-50%);
+ margin: 8px 0 0;
+
+ @media (min-width: $break-point-medium) {
+ margin: 12px 0;
+ }
+
+ a {
+ font-weight: 600;
+ }
+ }
+}
diff --git a/browser/components/newtab/content-src/asrouter/templates/SimpleSnippet/SimpleSnippet.jsx b/browser/components/newtab/content-src/asrouter/templates/SimpleSnippet/SimpleSnippet.jsx
new file mode 100644
index 0000000000..8d7b8c1f7b
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/templates/SimpleSnippet/SimpleSnippet.jsx
@@ -0,0 +1,225 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { Button } from "../../components/Button/Button";
+import ConditionalWrapper from "../../components/ConditionalWrapper/ConditionalWrapper";
+import React from "react";
+import { RichText } from "../../components/RichText/RichText";
+import { safeURI } from "../../template-utils";
+import { SnippetBase } from "../../components/SnippetBase/SnippetBase";
+
+const DEFAULT_ICON_PATH = "chrome://branding/content/icon64.png";
+// Alt text placeholder in case the prop from the server isn't available
+const ICON_ALT_TEXT = "";
+
+export class SimpleSnippet extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.onButtonClick = this.onButtonClick.bind(this);
+ }
+
+ onButtonClick() {
+ if (this.props.provider !== "preview") {
+ this.props.sendUserActionTelemetry({
+ event: "CLICK_BUTTON",
+ id: this.props.UISurface,
+ });
+ }
+ const {
+ button_url,
+ button_entrypoint_value,
+ button_entrypoint_name,
+ } = this.props.content;
+ // If button_url is defined handle it as OPEN_URL action
+ const type = this.props.content.button_action || (button_url && "OPEN_URL");
+ // Assign the snippet referral for the action
+ const entrypoint = button_entrypoint_name
+ ? new URLSearchParams([
+ [button_entrypoint_name, button_entrypoint_value],
+ ]).toString()
+ : button_entrypoint_value;
+ this.props.onAction({
+ type,
+ data: {
+ args: this.props.content.button_action_args || button_url,
+ ...(entrypoint && { entrypoint }),
+ },
+ });
+ if (!this.props.content.do_not_autoblock) {
+ this.props.onBlock();
+ }
+ }
+
+ _shouldRenderButton() {
+ return (
+ this.props.content.button_action ||
+ this.props.onButtonClick ||
+ this.props.content.button_url
+ );
+ }
+
+ renderTitle() {
+ const { title } = this.props.content;
+ return title ? (
+ <h3
+ className={`title ${this._shouldRenderButton() ? "title-inline" : ""}`}
+ >
+ {this.renderTitleIcon()} {title}
+ </h3>
+ ) : null;
+ }
+
+ renderTitleIcon() {
+ const titleIconLight = safeURI(this.props.content.title_icon);
+ const titleIconDark = safeURI(
+ this.props.content.title_icon_dark_theme || this.props.content.title_icon
+ );
+ if (!titleIconLight) {
+ return null;
+ }
+
+ return (
+ <React.Fragment>
+ <span
+ className="titleIcon icon-light-theme"
+ style={{ backgroundImage: `url("${titleIconLight}")` }}
+ />
+ <span
+ className="titleIcon icon-dark-theme"
+ style={{ backgroundImage: `url("${titleIconDark}")` }}
+ />
+ </React.Fragment>
+ );
+ }
+
+ renderButton() {
+ const { props } = this;
+ if (!this._shouldRenderButton()) {
+ return null;
+ }
+
+ return (
+ <Button
+ onClick={props.onButtonClick || this.onButtonClick}
+ color={props.content.button_color}
+ backgroundColor={props.content.button_background_color}
+ >
+ {props.content.button_label}
+ </Button>
+ );
+ }
+
+ renderText() {
+ const { props } = this;
+ return (
+ <RichText
+ text={props.content.text}
+ customElements={this.props.customElements}
+ localization_id="text"
+ links={props.content.links}
+ sendClick={props.sendClick}
+ />
+ );
+ }
+
+ wrapSectionHeader(url) {
+ return function(children) {
+ return <a href={url}>{children}</a>;
+ };
+ }
+
+ wrapSnippetContent(children) {
+ return <div className="innerContentWrapper">{children}</div>;
+ }
+
+ renderSectionHeader() {
+ const { props } = this;
+
+ // an icon and text must be specified to render the section header
+ if (props.content.section_title_icon && props.content.section_title_text) {
+ const sectionTitleIconLight = safeURI(props.content.section_title_icon);
+ const sectionTitleIconDark = safeURI(
+ props.content.section_title_icon_dark_theme ||
+ props.content.section_title_icon
+ );
+ const sectionTitleURL = props.content.section_title_url;
+
+ return (
+ <div className="section-header">
+ <h3 className="section-title">
+ <ConditionalWrapper
+ condition={sectionTitleURL}
+ wrap={this.wrapSectionHeader(sectionTitleURL)}
+ >
+ <span
+ className="icon icon-small-spacer icon-light-theme"
+ style={{ backgroundImage: `url("${sectionTitleIconLight}")` }}
+ />
+ <span
+ className="icon icon-small-spacer icon-dark-theme"
+ style={{ backgroundImage: `url("${sectionTitleIconDark}")` }}
+ />
+ <span className="section-title-text">
+ {props.content.section_title_text}
+ </span>
+ </ConditionalWrapper>
+ </h3>
+ </div>
+ );
+ }
+
+ return null;
+ }
+
+ render() {
+ const { props } = this;
+ const sectionHeader = this.renderSectionHeader();
+ let className = "SimpleSnippet";
+
+ if (props.className) {
+ className += ` ${props.className}`;
+ }
+ if (props.content.tall) {
+ className += " tall";
+ }
+ if (sectionHeader) {
+ className += " has-section-header";
+ }
+
+ return (
+ <div className="snippet-hover-wrapper">
+ <SnippetBase
+ {...props}
+ className={className}
+ textStyle={this.props.textStyle}
+ >
+ {sectionHeader}
+ <ConditionalWrapper
+ condition={sectionHeader}
+ wrap={this.wrapSnippetContent}
+ >
+ <img
+ src={safeURI(props.content.icon) || DEFAULT_ICON_PATH}
+ className="icon icon-light-theme"
+ alt={props.content.icon_alt_text || ICON_ALT_TEXT}
+ />
+ <img
+ src={
+ safeURI(props.content.icon_dark_theme || props.content.icon) ||
+ DEFAULT_ICON_PATH
+ }
+ className="icon icon-dark-theme"
+ alt={props.content.icon_alt_text || ICON_ALT_TEXT}
+ />
+ <div>
+ {this.renderTitle()} <p className="body">{this.renderText()}</p>
+ {this.props.extraContent}
+ </div>
+ {<div>{this.renderButton()}</div>}
+ </ConditionalWrapper>
+ </SnippetBase>
+ </div>
+ );
+ }
+}
diff --git a/browser/components/newtab/content-src/asrouter/templates/SimpleSnippet/SimpleSnippet.schema.json b/browser/components/newtab/content-src/asrouter/templates/SimpleSnippet/SimpleSnippet.schema.json
new file mode 100644
index 0000000000..1229700d67
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/templates/SimpleSnippet/SimpleSnippet.schema.json
@@ -0,0 +1,155 @@
+{
+ "title": "SimpleSnippet",
+ "description": "A simple template with an icon, text, and optional button.",
+ "version": "1.1.2",
+ "type": "object",
+ "definitions": {
+ "plainText": {
+ "description": "Plain text (no HTML allowed)",
+ "type": "string"
+ },
+ "richText": {
+ "description": "Text with HTML subset allowed: i, b, u, strong, em, br",
+ "type": "string"
+ },
+ "link_url": {
+ "description": "Target for links or buttons",
+ "type": "string",
+ "format": "uri"
+ }
+ },
+ "properties": {
+ "title": {
+ "allOf": [
+ {"$ref": "#/definitions/plainText"},
+ {"description": "Snippet title displayed before snippet text"}
+ ]
+ },
+ "text": {
+ "allOf": [
+ {"$ref": "#/definitions/richText"},
+ {"description": "Main body text of snippet. HTML subset allowed: i, b, u, strong, em, br"}
+ ]
+ },
+ "icon": {
+ "type": "string",
+ "description": "Snippet icon. 64x64px. SVG or PNG preferred."
+ },
+ "icon_dark_theme": {
+ "type": "string",
+ "description": "Snippet icon, dark theme variant. 64x64px. SVG or PNG preferred."
+ },
+ "icon_alt_text": {
+ "type": "string",
+ "description": "Alt text describing icon for screen readers",
+ "default": ""
+ },
+ "title_icon": {
+ "type": "string",
+ "description": "Small icon that shows up before the title / text. 16x16px. SVG or PNG preferred. Grayscale."
+ },
+ "title_icon_dark_theme": {
+ "type": "string",
+ "description": "Small icon that shows up before the title / text. Dark theme variant. 16x16px. SVG or PNG preferred. Grayscale."
+ },
+ "title_icon_alt_text": {
+ "type": "string",
+ "description": "Alt text describing title icon for screen readers",
+ "default": ""
+ },
+ "button_action": {
+ "type": "string",
+ "description": "The type of action the button should trigger."
+ },
+ "button_url": {
+ "allOf": [
+ {"$ref": "#/definitions/link_url"},
+ {"description": "A url, button_label links to this"}
+ ]
+ },
+ "button_action_args": {
+ "description": "Additional parameters for button action, example which specific menu the button should open"
+ },
+ "button_entrypoint_value": {
+ "description": "String used for telemetry attribution of clicks",
+ "type": "string"
+ },
+ "button_entrypoint_name": {
+ "description": "String used for telemetry attribution of clicks",
+ "type": "string"
+ },
+ "button_label": {
+ "allOf": [
+ {"$ref": "#/definitions/plainText"},
+ {"description": "Text for a button next to main snippet text that links to button_url. Requires button_url."}
+ ]
+ },
+ "button_color": {
+ "type": "string",
+ "description": "The text color of the button. Valid CSS color."
+ },
+ "button_background_color": {
+ "type": "string",
+ "description": "The background color of the button. Valid CSS color."
+ },
+ "block_button_text": {
+ "type": "string",
+ "description": "Tooltip text used for dismiss button.",
+ "default": "Remove this"
+ },
+ "tall": {
+ "type": "boolean",
+ "description": "To be used by fundraising only, increases height to roughly 120px. Defaults to false."
+ },
+ "do_not_autoblock": {
+ "type": "boolean",
+ "description": "Used to prevent blocking the snippet after the CTA (link or button) has been clicked"
+ },
+ "links": {
+ "additionalProperties": {
+ "url": {
+ "allOf": [
+ {"$ref": "#/definitions/link_url"},
+ {"description": "The url where the link points to."}
+ ]
+ },
+ "metric": {
+ "type": "string",
+ "description": "Custom event name sent with telemetry event."
+ },
+ "args": {
+ "type": "string",
+ "description": "Additional parameters for link action, example which specific menu the button should open"
+ }
+ }
+ },
+ "section_title_icon": {
+ "type": "string",
+ "description": "Section title icon. 16x16px. SVG or PNG preferred. section_title_text must also be specified to display."
+ },
+ "section_title_icon_dark_theme": {
+ "type": "string",
+ "description": "Section title icon, dark theme variant. 16x16px. SVG or PNG preferred. section_title_text must also be specified to display."
+ },
+ "section_title_text": {
+ "type": "string",
+ "description": "Section title text. section_title_icon must also be specified to display."
+ },
+ "section_title_url": {
+ "allOf": [
+ {"$ref": "#/definitions/link_url"},
+ {"description": "A url, section_title_text links to this"}
+ ]
+ }
+ },
+ "additionalProperties": false,
+ "required": ["text"],
+ "dependencies": {
+ "button_action": ["button_label"],
+ "button_url": ["button_label"],
+ "button_color": ["button_label"],
+ "button_background_color": ["button_label"],
+ "section_title_url": ["section_title_text"],
+ "button_entrypoint_name": ["button_entrypoint_value"]
+ }
+}
diff --git a/browser/components/newtab/content-src/asrouter/templates/SimpleSnippet/_SimpleSnippet.scss b/browser/components/newtab/content-src/asrouter/templates/SimpleSnippet/_SimpleSnippet.scss
new file mode 100644
index 0000000000..b16f78dc93
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/templates/SimpleSnippet/_SimpleSnippet.scss
@@ -0,0 +1,135 @@
+$section-header-height: 30px;
+$icon-width: 54px; // width of primary icon + margin
+
+.SimpleSnippet {
+ &.tall {
+ padding: 27px 0;
+ }
+
+ p em {
+ color: $grey-90;
+ font-style: normal;
+ background: $yellow-50;
+ }
+
+ &.bold,
+ &.takeover {
+ .donation-form-url,
+ .donation-amount {
+ padding-top: 8px;
+ padding-bottom: 8px;
+ }
+ }
+
+ &.bold {
+ height: 176px;
+
+ .body {
+ font-size: 14px;
+ line-height: 20px;
+ margin-bottom: 20px;
+ }
+
+ .icon {
+ width: 71px;
+ height: 71px;
+ }
+ }
+
+ &.takeover {
+ height: 344px;
+
+ .body {
+ font-size: 16px;
+ line-height: 24px;
+ margin-bottom: 35px;
+ }
+
+ .icon {
+ width: 79px;
+ height: 79px;
+ }
+ }
+
+ .title {
+ font-size: inherit;
+ margin: 0;
+ }
+
+ .title-inline {
+ display: inline;
+ }
+
+ .titleIcon {
+ background-repeat: no-repeat;
+ background-size: 14px;
+ background-position: center;
+ height: 16px;
+ width: 16px;
+ margin-top: 2px;
+ margin-inline-end: 2px;
+ display: inline-block;
+ vertical-align: top;
+ }
+
+ .body {
+ display: inline;
+ margin: 0;
+ }
+
+ &.tall .icon {
+ margin-inline-end: 20px;
+ }
+
+ &.takeover,
+ &.bold {
+ .icon {
+ margin-inline-end: 20px;
+ }
+ }
+
+ .icon {
+ align-self: flex-start;
+ }
+
+ &.has-section-header .innerWrapper {
+ // account for section header being 100% width
+ flex-wrap: wrap;
+ padding-top: 7px;
+ }
+
+ // wrapper div added if section-header is displayed that allows icon/text/button
+ // to squish instead of wrapping. this is effectively replicating layout behavior
+ // when section-header is *not* present.
+ .innerContentWrapper {
+ align-items: center;
+ display: flex;
+ }
+
+ .section-header {
+ flex: 0 0 100%;
+ margin-bottom: 10px;
+ }
+
+ .section-title {
+ // color should match that of 'Recommended by Pocket' and 'Highlights' in newtab page
+ color: var(--newtab-section-header-text-color);
+ display: inline-block;
+ font-size: 13px;
+ font-weight: bold;
+ margin: 0;
+
+ a {
+ color: var(--newtab-section-header-text-color);
+ font-weight: inherit;
+ text-decoration: none;
+ }
+
+ .icon {
+ height: 16px;
+ margin-inline-end: 6px;
+ margin-top: -2px;
+ width: 16px;
+ }
+ }
+}
diff --git a/browser/components/newtab/content-src/asrouter/templates/SubmitFormSnippet/SubmitFormScene2Snippet.schema.json b/browser/components/newtab/content-src/asrouter/templates/SubmitFormSnippet/SubmitFormScene2Snippet.schema.json
new file mode 100644
index 0000000000..f3dcde11af
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/templates/SubmitFormSnippet/SubmitFormScene2Snippet.schema.json
@@ -0,0 +1,163 @@
+{
+ "title": "SubmitFormSnippet",
+ "description": "A template with two states: a SimpleSnippet and another that contains a form",
+ "version": "1.2.0",
+ "type": "object",
+ "definitions": {
+ "plainText": {
+ "description": "Plain text (no HTML allowed)",
+ "type": "string"
+ },
+ "richText": {
+ "description": "Text with HTML subset allowed: i, b, u, strong, em, br",
+ "type": "string"
+ },
+ "link_url": {
+ "description": "Target for links or buttons",
+ "type": "string",
+ "format": "uri"
+ }
+ },
+ "properties": {
+ "locale": {
+ "type": "string",
+ "description": "Two to five character string for the locale code"
+ },
+ "country": {
+ "type": "string",
+ "description": "Two character string for the country code (used for SMS)"
+ },
+ "section_title_icon": {
+ "type": "string",
+ "description": "Section title icon. 16x16px. SVG or PNG preferred. section_title_text must also be specified to display."
+ },
+ "section_title_icon_dark_theme": {
+ "type": "string",
+ "description": "Section title icon, dark theme variant. 16x16px. SVG or PNG preferred. section_title_text must also be specified to display."
+ },
+ "section_title_text": {
+ "type": "string",
+ "description": "Section title text. section_title_icon must also be specified to display."
+ },
+ "section_title_url": {
+ "allOf": [
+ {"$ref": "#/definitions/link_url"},
+ {"description": "A url, section_title_text links to this"}
+ ]
+ },
+ "scene2_text": {
+ "allOf": [
+ {"$ref": "#/definitions/richText"},
+ {"description": "Main body text of snippet. HTML subset allowed: i, b, u, strong, em, br"}
+ ]
+ },
+ "form_action": {
+ "type": "string",
+ "description": "Endpoint to submit form data."
+ },
+ "success_title": {
+ "type": "string",
+ "description": "(send to device) Title shown before text on successful registration."
+ },
+ "success_text": {
+ "type": "string",
+ "description": "Message shown on successful registration."
+ },
+ "error_text": {
+ "type": "string",
+ "description": "Message shown if registration failed."
+ },
+ "scene2_email_placeholder_text": {
+ "type": "string",
+ "description": "Value to show while input is empty."
+ },
+ "scene2_input_placeholder": {
+ "type": "string",
+ "description": "(send to device) Value to show while input is empty."
+ },
+ "scene2_button_label": {
+ "type": "string",
+ "description": "Label for form submit button"
+ },
+ "scene2_privacy_html": {
+ "type": "string",
+ "description": "Information about how the form data is used."
+ },
+ "scene2_disclaimer_html": {
+ "type": "string",
+ "description": "(send to device) Html for disclaimer and link underneath input box."
+ },
+ "scene2_icon": {
+ "type": "string",
+ "description": "(send to device) Image to display above the form. 98x98px. SVG or PNG preferred."
+ },
+ "scene2_icon_dark_theme": {
+ "type": "string",
+ "description": "(send to device) Image to display above the form. Dark theme variant. 98x98px. SVG or PNG preferred."
+ },
+ "scene2_icon_alt_text": {
+ "type": "string",
+ "description": "Alt text describing scene2 icon for screen readers",
+ "default": ""
+ },
+ "scene2_newsletter": {
+ "type": "string",
+ "description": "Newsletter/basket id user is subscribing to. Must be a value from the 'Slug' column here: https://basket.mozilla.org/news/. Default 'mozilla-foundation'."
+ },
+ "hidden_inputs": {
+ "type": "object",
+ "description": "Each entry represents a hidden input, key is used as value for the name property."
+ },
+ "retry_button_label": {
+ "allOf": [
+ {"$ref": "#/definitions/plainText"},
+ {"description": "Text for the button in the event of a submission error/failure."}
+ ],
+ "default": "Try again"
+ },
+ "do_not_autoblock": {
+ "type": "boolean",
+ "description": "Used to prevent blocking the snippet after the CTA (link or button) has been clicked"
+ },
+ "include_sms": {
+ "type": "boolean",
+ "description": "(send to device) Allow users to send an SMS message with the form?"
+ },
+ "message_id_sms": {
+ "type": "string",
+ "description": "(send to device) Newsletter/basket id representing the SMS message to be sent."
+ },
+ "message_id_email": {
+ "type": "string",
+ "description": "(send to device) Newsletter/basket id representing the email message to be sent. Must be a value from the 'Slug' column here: https://basket.mozilla.org/news/."
+ },
+ "utm_campaign": {
+ "type": "string",
+ "description": "(fxa) Value to pass through to GA as utm_campaign."
+ },
+ "utm_term": {
+ "type": "string",
+ "description": "(fxa) Value to pass through to GA as utm_term."
+ },
+ "links": {
+ "additionalProperties": {
+ "url": {
+ "allOf": [
+ {"$ref": "#/definitions/link_url"},
+ {"description": "The url where the link points to."}
+ ]
+ },
+ "metric": {
+ "type": "string",
+ "description": "Custom event name sent with telemetry event."
+ }
+ }
+ }
+ },
+ "additionalProperties": false,
+ "required": ["scene2_text"],
+ "dependencies": {
+ "section_title_icon": ["section_title_text"],
+ "section_title_icon_dark_theme": ["section_title_text"]
+ }
+}
diff --git a/browser/components/newtab/content-src/asrouter/templates/SubmitFormSnippet/SubmitFormSnippet.jsx b/browser/components/newtab/content-src/asrouter/templates/SubmitFormSnippet/SubmitFormSnippet.jsx
new file mode 100644
index 0000000000..d1f267f2fa
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/templates/SubmitFormSnippet/SubmitFormSnippet.jsx
@@ -0,0 +1,409 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { Button } from "../../components/Button/Button";
+import React from "react";
+import { RichText } from "../../components/RichText/RichText";
+import { safeURI } from "../../template-utils";
+import { SimpleSnippet } from "../SimpleSnippet/SimpleSnippet";
+import { SnippetBase } from "../../components/SnippetBase/SnippetBase";
+import ConditionalWrapper from "../../components/ConditionalWrapper/ConditionalWrapper";
+
+// Alt text placeholder in case the prop from the server isn't available
+const ICON_ALT_TEXT = "";
+
+export class SubmitFormSnippet extends React.PureComponent {
+ constructor(props) {
+ super(props);
+ this.expandSnippet = this.expandSnippet.bind(this);
+ this.handleSubmit = this.handleSubmit.bind(this);
+ this.handleSubmitAttempt = this.handleSubmitAttempt.bind(this);
+ this.onInputChange = this.onInputChange.bind(this);
+ this.state = {
+ expanded: false,
+ submitAttempted: false,
+ signupSubmitted: false,
+ signupSuccess: false,
+ disableForm: false,
+ };
+ }
+
+ handleSubmitAttempt() {
+ if (!this.state.submitAttempted) {
+ this.setState({ submitAttempted: true });
+ }
+ }
+
+ async handleSubmit(event) {
+ let json;
+
+ if (this.state.disableForm) {
+ return;
+ }
+
+ event.preventDefault();
+ this.setState({ disableForm: true });
+ this.props.sendUserActionTelemetry({
+ event: "CLICK_BUTTON",
+ event_context: "conversion-subscribe-activation",
+ id: "NEWTAB_FOOTER_BAR_CONTENT",
+ });
+
+ if (this.props.form_method.toUpperCase() === "GET") {
+ this.props.onBlock({ preventDismiss: true });
+ this.refs.form.submit();
+ return;
+ }
+
+ const { url, formData } = this.props.processFormData
+ ? this.props.processFormData(this.refs.mainInput, this.props)
+ : { url: this.refs.form.action, formData: new FormData(this.refs.form) };
+
+ try {
+ const fetchRequest = new Request(url, {
+ body: formData,
+ method: "POST",
+ credentials: "omit",
+ });
+ const response = await fetch(fetchRequest); // eslint-disable-line fetch-options/no-fetch-credentials
+ json = await response.json();
+ } catch (err) {
+ console.log(err); // eslint-disable-line no-console
+ }
+
+ if (json && json.status === "ok") {
+ this.setState({ signupSuccess: true, signupSubmitted: true });
+ if (!this.props.content.do_not_autoblock) {
+ this.props.onBlock({ preventDismiss: true });
+ }
+ this.props.sendUserActionTelemetry({
+ event: "CLICK_BUTTON",
+ event_context: "subscribe-success",
+ id: "NEWTAB_FOOTER_BAR_CONTENT",
+ });
+ } else {
+ // eslint-disable-next-line no-console
+ console.error(
+ "There was a problem submitting the form",
+ json || "[No JSON response]"
+ );
+ this.setState({ signupSuccess: false, signupSubmitted: true });
+ this.props.sendUserActionTelemetry({
+ event: "CLICK_BUTTON",
+ event_context: "subscribe-error",
+ id: "NEWTAB_FOOTER_BAR_CONTENT",
+ });
+ }
+
+ this.setState({ disableForm: false });
+ }
+
+ expandSnippet() {
+ this.props.sendUserActionTelemetry({
+ event: "CLICK_BUTTON",
+ event_context: "scene1-button-learn-more",
+ id: this.props.UISurface,
+ });
+
+ this.setState({
+ expanded: true,
+ signupSuccess: false,
+ signupSubmitted: false,
+ });
+ }
+
+ renderHiddenFormInputs() {
+ const { hidden_inputs } = this.props.content;
+
+ if (!hidden_inputs) {
+ return null;
+ }
+
+ return Object.keys(hidden_inputs).map((key, idx) => (
+ <input key={idx} type="hidden" name={key} value={hidden_inputs[key]} />
+ ));
+ }
+
+ renderDisclaimer() {
+ const { content } = this.props;
+ if (!content.scene2_disclaimer_html) {
+ return null;
+ }
+ return (
+ <p className="disclaimerText">
+ <RichText
+ text={content.scene2_disclaimer_html}
+ localization_id="disclaimer_html"
+ links={content.links}
+ doNotAutoBlock={true}
+ openNewWindow={true}
+ sendClick={this.props.sendClick}
+ />
+ </p>
+ );
+ }
+
+ renderFormPrivacyNotice() {
+ const { content } = this.props;
+ if (!content.scene2_privacy_html) {
+ return null;
+ }
+ return (
+ <p className="privacyNotice">
+ <input
+ type="checkbox"
+ id="id_privacy"
+ name="privacy"
+ required="required"
+ />
+ <label htmlFor="id_privacy">
+ <RichText
+ text={content.scene2_privacy_html}
+ localization_id="privacy_html"
+ links={content.links}
+ doNotAutoBlock={true}
+ openNewWindow={true}
+ sendClick={this.props.sendClick}
+ />
+ </label>
+ </p>
+ );
+ }
+
+ renderSignupSubmitted() {
+ const { content } = this.props;
+ const isSuccess = this.state.signupSuccess;
+ const successTitle = isSuccess && content.success_title;
+ const bodyText = isSuccess
+ ? { success_text: content.success_text }
+ : { error_text: content.error_text };
+ const retryButtonText = content.retry_button_label;
+ return (
+ <SnippetBase {...this.props}>
+ <div className="submissionStatus">
+ {successTitle ? (
+ <h2 className="submitStatusTitle">{successTitle}</h2>
+ ) : null}
+ <p>
+ <RichText
+ {...bodyText}
+ localization_id={isSuccess ? "success_text" : "error_text"}
+ />
+ {isSuccess ? null : (
+ <Button onClick={this.expandSnippet}>{retryButtonText}</Button>
+ )}
+ </p>
+ </div>
+ </SnippetBase>
+ );
+ }
+
+ onInputChange(event) {
+ if (!this.props.validateInput) {
+ return;
+ }
+ const hasError = this.props.validateInput(
+ event.target.value,
+ this.props.content
+ );
+ event.target.setCustomValidity(hasError);
+ }
+
+ wrapSectionHeader(url) {
+ return function(children) {
+ return <a href={url}>{children}</a>;
+ };
+ }
+
+ renderInput() {
+ const placholder =
+ this.props.content.scene2_email_placeholder_text ||
+ this.props.content.scene2_input_placeholder;
+ return (
+ <input
+ ref="mainInput"
+ type={this.props.inputType || "email"}
+ className={`mainInput${this.state.submitAttempted ? "" : " clean"}`}
+ name="email"
+ required={true}
+ placeholder={placholder}
+ onChange={this.props.validateInput ? this.onInputChange : null}
+ />
+ );
+ }
+
+ renderForm() {
+ return (
+ <form
+ action={this.props.form_action}
+ method={this.props.form_method}
+ onSubmit={this.handleSubmit}
+ ref="form"
+ >
+ {this.renderHiddenFormInputs()}
+ <div>
+ {this.renderInput()}
+ <button
+ type="submit"
+ className="ASRouterButton primary"
+ onClick={this.handleSubmitAttempt}
+ ref="formSubmitBtn"
+ >
+ {this.props.content.scene2_button_label}
+ </button>
+ </div>
+ {this.renderFormPrivacyNotice() || this.renderDisclaimer()}
+ </form>
+ );
+ }
+
+ renderScene2Icon() {
+ const { content } = this.props;
+ if (!content.scene2_icon) {
+ return null;
+ }
+
+ return (
+ <div className="scene2Icon">
+ <img
+ src={safeURI(content.scene2_icon)}
+ className="icon-light-theme"
+ alt={content.scene2_icon_alt_text || ICON_ALT_TEXT}
+ />
+ <img
+ src={safeURI(content.scene2_icon_dark_theme || content.scene2_icon)}
+ className="icon-dark-theme"
+ alt={content.scene2_icon_alt_text || ICON_ALT_TEXT}
+ />
+ </div>
+ );
+ }
+
+ renderSignupView() {
+ const { content } = this.props;
+ const containerClass = `SubmitFormSnippet ${this.props.className}`;
+ return (
+ <SnippetBase
+ {...this.props}
+ className={containerClass}
+ footerDismiss={true}
+ >
+ {this.renderScene2Icon()}
+ <div className="message">
+ <p>
+ {content.scene2_title && (
+ <h3 className="scene2Title">{content.scene2_title}</h3>
+ )}{" "}
+ {content.scene2_text && (
+ <RichText
+ scene2_text={content.scene2_text}
+ localization_id="scene2_text"
+ />
+ )}
+ </p>
+ </div>
+ {this.renderForm()}
+ </SnippetBase>
+ );
+ }
+
+ renderSectionHeader() {
+ const { props } = this;
+
+ // an icon and text must be specified to render the section header
+ if (props.content.section_title_icon && props.content.section_title_text) {
+ const sectionTitleIconLight = safeURI(props.content.section_title_icon);
+ const sectionTitleIconDark = safeURI(
+ props.content.section_title_icon_dark_theme ||
+ props.content.section_title_icon
+ );
+ const sectionTitleURL = props.content.section_title_url;
+
+ return (
+ <div className="section-header">
+ <h3 className="section-title">
+ <ConditionalWrapper
+ wrap={this.wrapSectionHeader(sectionTitleURL)}
+ condition={sectionTitleURL}
+ >
+ <span
+ className="icon icon-small-spacer icon-light-theme"
+ style={{ backgroundImage: `url("${sectionTitleIconLight}")` }}
+ />
+ <span
+ className="icon icon-small-spacer icon-dark-theme"
+ style={{ backgroundImage: `url("${sectionTitleIconDark}")` }}
+ />
+ <span className="section-title-text">
+ {props.content.section_title_text}
+ </span>
+ </ConditionalWrapper>
+ </h3>
+ </div>
+ );
+ }
+
+ return null;
+ }
+
+ renderSignupViewAlt() {
+ const { content } = this.props;
+ const containerClass = `SubmitFormSnippet ${this.props.className} scene2Alt`;
+ return (
+ <SnippetBase
+ {...this.props}
+ className={containerClass}
+ // Don't show bottom dismiss button
+ footerDismiss={false}
+ >
+ {this.renderSectionHeader()}
+ {this.renderScene2Icon()}
+ <div className="message">
+ <p>
+ {content.scene2_text && (
+ <RichText
+ scene2_text={content.scene2_text}
+ localization_id="scene2_text"
+ />
+ )}
+ </p>
+ {this.renderForm()}
+ </div>
+ </SnippetBase>
+ );
+ }
+
+ getFirstSceneContent() {
+ return Object.keys(this.props.content)
+ .filter(key => key.includes("scene1"))
+ .reduce((acc, key) => {
+ acc[key.substr(7)] = this.props.content[key];
+ return acc;
+ }, {});
+ }
+
+ render() {
+ const content = { ...this.props.content, ...this.getFirstSceneContent() };
+
+ if (this.state.signupSubmitted) {
+ return this.renderSignupSubmitted();
+ }
+ // Render only scene 2 (signup view). Must check before `renderSignupView`
+ // to catch the Failure/Try again scenario where we want to return and render
+ // the scene again.
+ if (this.props.expandedAlt) {
+ return this.renderSignupViewAlt();
+ }
+ if (this.state.expanded) {
+ return this.renderSignupView();
+ }
+ return (
+ <SimpleSnippet
+ {...this.props}
+ content={content}
+ onButtonClick={this.expandSnippet}
+ />
+ );
+ }
+}
diff --git a/browser/components/newtab/content-src/asrouter/templates/SubmitFormSnippet/SubmitFormSnippet.schema.json b/browser/components/newtab/content-src/asrouter/templates/SubmitFormSnippet/SubmitFormSnippet.schema.json
new file mode 100644
index 0000000000..0fc3128d1c
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/templates/SubmitFormSnippet/SubmitFormSnippet.schema.json
@@ -0,0 +1,225 @@
+{
+ "title": "SubmitFormSnippet",
+ "description": "A template with two states: a SimpleSnippet and another that contains a form",
+ "version": "1.2.0",
+ "type": "object",
+ "definitions": {
+ "plainText": {
+ "description": "Plain text (no HTML allowed)",
+ "type": "string"
+ },
+ "richText": {
+ "description": "Text with HTML subset allowed: i, b, u, strong, em, br",
+ "type": "string"
+ },
+ "link_url": {
+ "description": "Target for links or buttons",
+ "type": "string",
+ "format": "uri"
+ }
+ },
+ "properties": {
+ "locale": {
+ "type": "string",
+ "description": "Two to five character string for the locale code"
+ },
+ "country": {
+ "type": "string",
+ "description": "Two character string for the country code (used for SMS)"
+ },
+ "scene1_title": {
+ "allof": [
+ {"$ref": "#/definitions/plainText"},
+ {"description": "snippet title displayed before snippet text"}
+ ]
+ },
+ "scene1_text": {
+ "allOf": [
+ {"$ref": "#/definitions/richText"},
+ {"description": "Main body text of snippet. HTML subset allowed: i, b, u, strong, em, br"}
+ ]
+ },
+ "scene1_section_title_icon": {
+ "type": "string",
+ "description": "Section title icon for scene 1. 16x16px. SVG or PNG preferred. scene1_section_title_text must also be specified to display."
+ },
+ "scene1_section_title_icon_dark_theme": {
+ "type": "string",
+ "description": "Section title icon for scene 1, dark theme variant. 16x16px. SVG or PNG preferred. scene1_section_title_text must also be specified to display."
+ },
+ "scene1_section_title_text": {
+ "type": "string",
+ "description": "Section title text for scene 1. scene1_section_title_icon must also be specified to display."
+ },
+ "scene1_section_title_url": {
+ "allOf": [
+ {"$ref": "#/definitions/link_url"},
+ {"description": "A url, scene1_section_title_text links to this"}
+ ]
+ },
+ "scene2_title": {
+ "allOf": [
+ {"$ref": "#/definitions/plainText"},
+ {"description": "Title displayed before text in scene 2. Should be plain text."}
+ ]
+ },
+ "scene2_text": {
+ "allOf": [
+ {"$ref": "#/definitions/richText"},
+ {"description": "Main body text of snippet. HTML subset allowed: i, b, u, strong, em, br"}
+ ]
+ },
+ "scene1_icon": {
+ "type": "string",
+ "description": "Snippet icon. 64x64px. SVG or PNG preferred."
+ },
+ "scene1_icon_dark_theme": {
+ "type": "string",
+ "description": "Snippet icon. Dark theme variant. 64x64px. SVG or PNG preferred."
+ },
+ "scene1_icon_alt_text": {
+ "type": "string",
+ "description": "Alt text describing scene1 icon for screen readers",
+ "default": ""
+ },
+ "scene1_title_icon": {
+ "type": "string",
+ "description": "Small icon that shows up before the title / text. 16x16px. SVG or PNG preferred. Grayscale."
+ },
+ "scene1_title_icon_dark_theme": {
+ "type": "string",
+ "description": "Small icon that shows up before the title / text. Dark theme variant. 16x16px. SVG or PNG preferred. Grayscale."
+ },
+ "scene1_title_icon_alt_text": {
+ "type": "string",
+ "description": "Alt text describing scene1 title icon for screen readers",
+ "default": ""
+ },
+ "form_action": {
+ "type": "string",
+ "description": "Endpoint to submit form data."
+ },
+ "success_title": {
+ "type": "string",
+ "description": "(send to device) Title shown before text on successful registration."
+ },
+ "success_text": {
+ "type": "string",
+ "description": "Message shown on successful registration."
+ },
+ "error_text": {
+ "type": "string",
+ "description": "Message shown if registration failed."
+ },
+ "scene2_email_placeholder_text": {
+ "type": "string",
+ "description": "Value to show while input is empty."
+ },
+ "scene2_input_placeholder": {
+ "type": "string",
+ "description": "(send to device) Value to show while input is empty."
+ },
+ "scene2_button_label": {
+ "type": "string",
+ "description": "Label for form submit button"
+ },
+ "scene2_privacy_html": {
+ "type": "string",
+ "description": "Information about how the form data is used."
+ },
+ "scene2_disclaimer_html": {
+ "type": "string",
+ "description": "(send to device) Html for disclaimer and link underneath input box."
+ },
+ "scene2_dismiss_button_text": {
+ "type": "string",
+ "description": "Label for the dismiss button when the sign-up form is expanded."
+ },
+ "scene2_icon": {
+ "type": "string",
+ "description": "(send to device) Image to display above the form. 98x98px. SVG or PNG preferred."
+ },
+ "scene2_icon_dark_theme": {
+ "type": "string",
+ "description": "(send to device) Image to display above the form. Dark theme variant. 98x98px. SVG or PNG preferred."
+ },
+ "scene2_icon_alt_text": {
+ "type": "string",
+ "description": "Alt text describing scene2 icon for screen readers",
+ "default": ""
+ },
+ "scene2_newsletter": {
+ "type": "string",
+ "description": "Newsletter/basket id user is subscribing to. Must be a value from the 'Slug' column here: https://basket.mozilla.org/news/. Default 'mozilla-foundation'."
+ },
+ "hidden_inputs": {
+ "type": "object",
+ "description": "Each entry represents a hidden input, key is used as value for the name property."
+ },
+ "scene1_button_label": {
+ "allOf": [
+ {"$ref": "#/definitions/plainText"},
+ {"description": "Text for a button next to main snippet text that links to button_url. Requires button_url."}
+ ]
+ },
+ "scene1_button_color": {
+ "type": "string",
+ "description": "The text color of the button. Valid CSS color."
+ },
+ "scene1_button_background_color": {
+ "type": "string",
+ "description": "The background color of the button. Valid CSS color."
+ },
+ "retry_button_label": {
+ "allOf": [
+ {"$ref": "#/definitions/plainText"},
+ {"description": "Text for the button in the event of a submission error/failure."}
+ ],
+ "default": "Try again"
+ },
+ "do_not_autoblock": {
+ "type": "boolean",
+ "description": "Used to prevent blocking the snippet after the CTA (link or button) has been clicked"
+ },
+ "include_sms": {
+ "type": "boolean",
+ "description": "(send to device) Allow users to send an SMS message with the form?"
+ },
+ "message_id_sms": {
+ "type": "string",
+ "description": "(send to device) Newsletter/basket id representing the SMS message to be sent."
+ },
+ "message_id_email": {
+ "type": "string",
+ "description": "(send to device) Newsletter/basket id representing the email message to be sent. Must be a value from the 'Slug' column here: https://basket.mozilla.org/news/."
+ },
+ "utm_campaign": {
+ "type": "string",
+ "description": "(fxa) Value to pass through to GA as utm_campaign."
+ },
+ "utm_term": {
+ "type": "string",
+ "description": "(fxa) Value to pass through to GA as utm_term."
+ },
+ "links": {
+ "additionalProperties": {
+ "url": {
+ "allOf": [
+ {"$ref": "#/definitions/link_url"},
+ {"description": "The url where the link points to."}
+ ]
+ },
+ "metric": {
+ "type": "string",
+ "description": "Custom event name sent with telemetry event."
+ }
+ }
+ }
+ },
+ "additionalProperties": false,
+ "required": ["scene1_text", "scene2_text", "scene1_button_label"],
+ "dependencies": {
+ "scene1_button_color": ["scene1_button_label"],
+ "scene1_button_background_color": ["scene1_button_label"]
+ }
+}
diff --git a/browser/components/newtab/content-src/asrouter/templates/SubmitFormSnippet/_SubmitFormSnippet.scss b/browser/components/newtab/content-src/asrouter/templates/SubmitFormSnippet/_SubmitFormSnippet.scss
new file mode 100644
index 0000000000..286366c12b
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/templates/SubmitFormSnippet/_SubmitFormSnippet.scss
@@ -0,0 +1,176 @@
+.SubmitFormSnippet {
+ flex-direction: column;
+ flex: 1 1 100%;
+ width: 100%;
+
+ .disclaimerText {
+ margin: 5px 0 0;
+ font-size: 12px;
+ color: var(--newtab-text-secondary-color);
+ }
+
+ p {
+ margin: 0;
+ }
+
+ &.send_to_device_snippet {
+ text-align: center;
+
+ .message {
+ font-size: 16px;
+ margin-bottom: 20px;
+ }
+
+ .scene2Title {
+ font-size: 24px;
+ display: block;
+ }
+ }
+
+ .ASRouterButton {
+ &.primary {
+ flex: 1 1 0;
+ }
+ }
+
+ .scene2Icon {
+ width: 100%;
+ margin-bottom: 20px;
+
+ img {
+ width: 98px;
+ display: inline-block;
+ }
+ }
+
+ .scene2Title {
+ font-size: inherit;
+ margin: 0 0 10px;
+ font-weight: bold;
+ display: inline;
+ }
+
+ form {
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+ }
+
+ .message {
+ font-size: 14px;
+ align-self: stretch;
+ flex: 0 0 100%;
+ margin-bottom: 10px;
+ }
+
+ .privacyNotice {
+ font-size: 12px;
+ color: var(--newtab-text-secondary-color);
+ margin-top: 10px;
+ display: flex;
+ flex: 0 0 100%;
+ }
+
+ .innerWrapper {
+ // https://github.com/mozmeao/snippets/blob/2054899350590adcb3c0b0a341c782b0e2f81d0b/activity-stream/newsletter-subscribe.html#L46
+ max-width: 736px;
+ flex-wrap: wrap;
+ justify-items: center;
+ padding-top: 40px;
+ padding-bottom: 40px;
+ }
+
+ .footer {
+ width: 100%;
+ margin: 0 auto;
+ text-align: right;
+ background-color: var(--newtab-background-color);
+ padding: 10px 0;
+
+ .footer-content {
+ margin: 0 auto;
+ max-width: 768px;
+ width: 100%;
+ text-align: right;
+
+ [dir='rtl'] & {
+ text-align: left;
+ }
+ }
+ }
+
+ input {
+ &.mainInput {
+ border-radius: 2px;
+ background-color: var(--newtab-textbox-background-color);
+ border: $input-border;
+ padding: 0 8px;
+ height: 100%;
+ font-size: 14px;
+ width: 50%;
+
+ &.clean {
+ &:invalid,
+ &:required {
+ box-shadow: none;
+ }
+ }
+
+ &:focus {
+ border: $input-border-active;
+ box-shadow: var(--newtab-textbox-focus-boxshadow);
+ }
+ }
+ }
+
+ &.scene2Alt {
+ text-align: start;
+
+ .scene2Icon {
+ flex: 1;
+ margin-bottom: 0;
+ }
+
+ .message {
+ flex: 5;
+ margin-bottom: 0;
+
+ p {
+ margin-bottom: 10px;
+ }
+ }
+
+ .section-header {
+ width: 100%;
+
+ .icon {
+ width: 16px;
+ height: 16px;
+ }
+ }
+
+ .section-title {
+ font-size: 13px;
+ }
+
+ .section-title a {
+ color: var(--newtab-section-header-text-color);
+ font-weight: inherit;
+ text-decoration: none;
+ }
+
+ .innerWrapper {
+ padding: 0 0 16px;
+ }
+ }
+}
+
+.submissionStatus {
+ text-align: center;
+ font-size: 14px;
+ padding: 20px 0;
+
+ .submitStatusTitle {
+ font-size: 20px;
+ }
+}
diff --git a/browser/components/newtab/content-src/asrouter/templates/template-manifest.jsx b/browser/components/newtab/content-src/asrouter/templates/template-manifest.jsx
new file mode 100644
index 0000000000..57f8afa6f5
--- /dev/null
+++ b/browser/components/newtab/content-src/asrouter/templates/template-manifest.jsx
@@ -0,0 +1,24 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { EOYSnippet } from "./EOYSnippet/EOYSnippet";
+import { FXASignupSnippet } from "./FXASignupSnippet/FXASignupSnippet";
+import { NewsletterSnippet } from "./NewsletterSnippet/NewsletterSnippet";
+import {
+ SendToDeviceSnippet,
+ SendToDeviceScene2Snippet,
+} from "./SendToDeviceSnippet/SendToDeviceSnippet";
+import { SimpleBelowSearchSnippet } from "./SimpleBelowSearchSnippet/SimpleBelowSearchSnippet";
+import { SimpleSnippet } from "./SimpleSnippet/SimpleSnippet";
+
+// Key names matching schema name of templates
+export const SnippetsTemplates = {
+ simple_snippet: SimpleSnippet,
+ newsletter_snippet: NewsletterSnippet,
+ fxa_signup_snippet: FXASignupSnippet,
+ send_to_device_snippet: SendToDeviceSnippet,
+ send_to_device_scene2_snippet: SendToDeviceScene2Snippet,
+ eoy_snippet: EOYSnippet,
+ simple_below_search_snippet: SimpleBelowSearchSnippet,
+};