From 36d22d82aa202bb199967e9512281e9a53db42c9 Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Sun, 7 Apr 2024 21:33:14 +0200 Subject: Adding upstream version 115.7.0esr. Signed-off-by: Daniel Baumann --- .../newtab/content-src/asrouter/README.md | 34 + .../content-src/asrouter/asrouter-content.jsx | 346 +++++ .../newtab/content-src/asrouter/asrouter-utils.js | 108 ++ .../asrouter/components/Button/Button.jsx | 32 + .../asrouter/components/Button/_Button.scss | 51 + .../ConditionalWrapper/ConditionalWrapper.jsx | 9 + .../ImpressionsWrapper/ImpressionsWrapper.jsx | 76 + .../components/ModalOverlay/ModalOverlay.jsx | 56 + .../components/ModalOverlay/_ModalOverlay.scss | 103 ++ .../asrouter/components/RichText/RichText.jsx | 86 + .../components/SnippetBase/SnippetBase.jsx | 121 ++ .../components/SnippetBase/_SnippetBase.scss | 117 ++ .../asrouter/docs/cfr_doorhanger_screenshot.png | Bin 0 -> 257709 bytes .../content-src/asrouter/docs/debugging-docs.md | 63 + .../content-src/asrouter/docs/debugging-guide.png | Bin 0 -> 247644 bytes .../newtab/content-src/asrouter/docs/first-run.md | 68 + .../newtab/content-src/asrouter/docs/index.rst | 104 ++ .../asrouter/docs/message-routing-overview.png | Bin 0 -> 50250 bytes .../asrouter/docs/simple-cfr-template.rst | 37 + .../asrouter/docs/targeting-attributes.md | 986 ++++++++++++ .../content-src/asrouter/docs/targeting-guide.md | 37 + .../asrouter/docs/telemetry-screenshot.png | Bin 0 -> 104954 bytes .../content-src/asrouter/rich-text-strings.js | 44 + .../BackgroundTaskMessagingExperiment.schema.json | 312 ++++ .../asrouter/schemas/FxMSCommon.schema.json | 128 ++ .../schemas/MessagingExperiment.schema.json | 1640 ++++++++++++++++++++ .../schemas/corpus/ReachExperiments.messages.json | 15 + .../asrouter/schemas/extract-test-corpus.js | 65 + .../content-src/asrouter/schemas/make-schemas.py | 475 ++++++ .../content-src/asrouter/schemas/message-format.md | 101 ++ .../asrouter/schemas/message-group.schema.json | 64 + .../asrouter/schemas/provider-response.schema.json | 67 + .../newtab/content-src/asrouter/template-utils.js | 22 + .../CFR/templates/CFRUrlbarChiclet.schema.json | 66 + .../CFR/templates/ExtensionDoorhanger.schema.json | 320 ++++ .../templates/CFR/templates/InfoBar.schema.json | 89 ++ .../asrouter/templates/EOYSnippet/EOYSnippet.jsx | 153 ++ .../templates/EOYSnippet/EOYSnippet.schema.json | 171 ++ .../asrouter/templates/EOYSnippet/_EOYSnippet.scss | 55 + .../FXASignupSnippet/FXASignupSnippet.jsx | 38 + .../FXASignupSnippet/FXASignupSnippet.schema.json | 196 +++ .../asrouter/templates/FirstRun/addUtmParams.js | 32 + .../NewsletterSnippet/NewsletterSnippet.jsx | 34 + .../NewsletterSnippet.schema.json | 186 +++ .../ProtectionsPanelMessage.schema.json | 62 + .../OnboardingMessage/Spotlight.schema.json | 66 + .../ToolbarBadgeMessage.schema.json | 45 + .../OnboardingMessage/UpdateAction.schema.json | 47 + .../OnboardingMessage/WhatsNewMessage.schema.json | 73 + .../PBNewtab/NewtabPromoMessage.schema.json | 153 ++ .../SendToDeviceSnippet/SendToDeviceSnippet.jsx | 76 + .../SendToDeviceSnippet.schema.json | 243 +++ .../SendToDeviceSnippet/isEmailOrPhoneNumber.js | 39 + .../SimpleBelowSearchSnippet.jsx | 133 ++ .../SimpleBelowSearchSnippet.schema.json | 114 ++ .../_SimpleBelowSearchSnippet.scss | 190 +++ .../templates/SimpleSnippet/SimpleSnippet.jsx | 222 +++ .../SimpleSnippet/SimpleSnippet.schema.json | 159 ++ .../templates/SimpleSnippet/_SimpleSnippet.scss | 131 ++ .../SubmitFormScene2Snippet.schema.json | 167 ++ .../SubmitFormSnippet/SubmitFormSnippet.jsx | 408 +++++ .../SubmitFormSnippet.schema.json | 235 +++ .../SubmitFormSnippet/_SubmitFormSnippet.scss | 176 +++ .../ToastNotification.schema.json | 85 + .../asrouter/templates/template-manifest.jsx | 24 + 65 files changed, 9555 insertions(+) create mode 100644 browser/components/newtab/content-src/asrouter/README.md create mode 100644 browser/components/newtab/content-src/asrouter/asrouter-content.jsx create mode 100644 browser/components/newtab/content-src/asrouter/asrouter-utils.js create mode 100644 browser/components/newtab/content-src/asrouter/components/Button/Button.jsx create mode 100644 browser/components/newtab/content-src/asrouter/components/Button/_Button.scss create mode 100644 browser/components/newtab/content-src/asrouter/components/ConditionalWrapper/ConditionalWrapper.jsx create mode 100644 browser/components/newtab/content-src/asrouter/components/ImpressionsWrapper/ImpressionsWrapper.jsx create mode 100644 browser/components/newtab/content-src/asrouter/components/ModalOverlay/ModalOverlay.jsx create mode 100644 browser/components/newtab/content-src/asrouter/components/ModalOverlay/_ModalOverlay.scss create mode 100644 browser/components/newtab/content-src/asrouter/components/RichText/RichText.jsx create mode 100644 browser/components/newtab/content-src/asrouter/components/SnippetBase/SnippetBase.jsx create mode 100644 browser/components/newtab/content-src/asrouter/components/SnippetBase/_SnippetBase.scss create mode 100644 browser/components/newtab/content-src/asrouter/docs/cfr_doorhanger_screenshot.png create mode 100644 browser/components/newtab/content-src/asrouter/docs/debugging-docs.md create mode 100644 browser/components/newtab/content-src/asrouter/docs/debugging-guide.png create mode 100644 browser/components/newtab/content-src/asrouter/docs/first-run.md create mode 100644 browser/components/newtab/content-src/asrouter/docs/index.rst create mode 100644 browser/components/newtab/content-src/asrouter/docs/message-routing-overview.png create mode 100644 browser/components/newtab/content-src/asrouter/docs/simple-cfr-template.rst create mode 100644 browser/components/newtab/content-src/asrouter/docs/targeting-attributes.md create mode 100644 browser/components/newtab/content-src/asrouter/docs/targeting-guide.md create mode 100644 browser/components/newtab/content-src/asrouter/docs/telemetry-screenshot.png create mode 100644 browser/components/newtab/content-src/asrouter/rich-text-strings.js create mode 100644 browser/components/newtab/content-src/asrouter/schemas/BackgroundTaskMessagingExperiment.schema.json create mode 100644 browser/components/newtab/content-src/asrouter/schemas/FxMSCommon.schema.json create mode 100644 browser/components/newtab/content-src/asrouter/schemas/MessagingExperiment.schema.json create mode 100644 browser/components/newtab/content-src/asrouter/schemas/corpus/ReachExperiments.messages.json create mode 100644 browser/components/newtab/content-src/asrouter/schemas/extract-test-corpus.js create mode 100755 browser/components/newtab/content-src/asrouter/schemas/make-schemas.py create mode 100644 browser/components/newtab/content-src/asrouter/schemas/message-format.md create mode 100644 browser/components/newtab/content-src/asrouter/schemas/message-group.schema.json create mode 100644 browser/components/newtab/content-src/asrouter/schemas/provider-response.schema.json create mode 100644 browser/components/newtab/content-src/asrouter/template-utils.js create mode 100644 browser/components/newtab/content-src/asrouter/templates/CFR/templates/CFRUrlbarChiclet.schema.json create mode 100644 browser/components/newtab/content-src/asrouter/templates/CFR/templates/ExtensionDoorhanger.schema.json create mode 100644 browser/components/newtab/content-src/asrouter/templates/CFR/templates/InfoBar.schema.json create mode 100644 browser/components/newtab/content-src/asrouter/templates/EOYSnippet/EOYSnippet.jsx create mode 100644 browser/components/newtab/content-src/asrouter/templates/EOYSnippet/EOYSnippet.schema.json create mode 100644 browser/components/newtab/content-src/asrouter/templates/EOYSnippet/_EOYSnippet.scss create mode 100644 browser/components/newtab/content-src/asrouter/templates/FXASignupSnippet/FXASignupSnippet.jsx create mode 100644 browser/components/newtab/content-src/asrouter/templates/FXASignupSnippet/FXASignupSnippet.schema.json create mode 100644 browser/components/newtab/content-src/asrouter/templates/FirstRun/addUtmParams.js create mode 100644 browser/components/newtab/content-src/asrouter/templates/NewsletterSnippet/NewsletterSnippet.jsx create mode 100644 browser/components/newtab/content-src/asrouter/templates/NewsletterSnippet/NewsletterSnippet.schema.json create mode 100644 browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/ProtectionsPanelMessage.schema.json create mode 100644 browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/Spotlight.schema.json create mode 100644 browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/ToolbarBadgeMessage.schema.json create mode 100644 browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/UpdateAction.schema.json create mode 100644 browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/WhatsNewMessage.schema.json create mode 100644 browser/components/newtab/content-src/asrouter/templates/PBNewtab/NewtabPromoMessage.schema.json create mode 100644 browser/components/newtab/content-src/asrouter/templates/SendToDeviceSnippet/SendToDeviceSnippet.jsx create mode 100644 browser/components/newtab/content-src/asrouter/templates/SendToDeviceSnippet/SendToDeviceSnippet.schema.json create mode 100644 browser/components/newtab/content-src/asrouter/templates/SendToDeviceSnippet/isEmailOrPhoneNumber.js create mode 100644 browser/components/newtab/content-src/asrouter/templates/SimpleBelowSearchSnippet/SimpleBelowSearchSnippet.jsx create mode 100644 browser/components/newtab/content-src/asrouter/templates/SimpleBelowSearchSnippet/SimpleBelowSearchSnippet.schema.json create mode 100644 browser/components/newtab/content-src/asrouter/templates/SimpleBelowSearchSnippet/_SimpleBelowSearchSnippet.scss create mode 100644 browser/components/newtab/content-src/asrouter/templates/SimpleSnippet/SimpleSnippet.jsx create mode 100644 browser/components/newtab/content-src/asrouter/templates/SimpleSnippet/SimpleSnippet.schema.json create mode 100644 browser/components/newtab/content-src/asrouter/templates/SimpleSnippet/_SimpleSnippet.scss create mode 100644 browser/components/newtab/content-src/asrouter/templates/SubmitFormSnippet/SubmitFormScene2Snippet.schema.json create mode 100644 browser/components/newtab/content-src/asrouter/templates/SubmitFormSnippet/SubmitFormSnippet.jsx create mode 100644 browser/components/newtab/content-src/asrouter/templates/SubmitFormSnippet/SubmitFormSnippet.schema.json create mode 100644 browser/components/newtab/content-src/asrouter/templates/SubmitFormSnippet/_SubmitFormSnippet.scss create mode 100644 browser/components/newtab/content-src/asrouter/templates/ToastNotification/ToastNotification.schema.json create mode 100644 browser/components/newtab/content-src/asrouter/templates/template-manifest.jsx (limited to 'browser/components/newtab/content-src/asrouter') 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..4c3f08d4cd --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/asrouter-content.jsx @@ -0,0 +1,346 @@ +/* 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.sys.mjs"; +import { actionTypes as at } from "common/Actions.sys.mjs"; +import { ASRouterUtils } from "./asrouter-utils"; +import { generateBundles } from "./rich-text-strings"; +import { ImpressionsWrapper } from "./components/ImpressionsWrapper/ImpressionsWrapper"; +import { LocalizationProvider, ReactLocalization } 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 , not +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); + } + + 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); + } + } catch (error) { + console.error(error); + } + 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, + campaign: this.state.message.campaign, + }); + } + + onBlockById(id, options) { + return ASRouterUtils.blockById(id, options).then(clearAll => { + if (clearAll) { + this.setState({ message: {} }); + } + }); + } + + onDismiss() { + this.clearMessage(this.state.message.id); + } + + // Blocking a snippet by id blocks the entire campaign + // so when clearing we use the two values interchangeably + clearMessage(idOrCampaign) { + if ( + idOrCampaign === this.state.message.id || + idOrCampaign === this.state.message.campaign + ) { + 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, prevState) { + if ( + prevProps.adminContent && + JSON.stringify(prevProps.adminContent) !== + JSON.stringify(this.props.adminContent) + ) { + this.updateContent(); + } + if (prevState.message.id !== this.state.message.id) { + const main = global.window.document.querySelector("main"); + if (main) { + if (this.state.message.id) { + main.classList.add("has-snippet"); + } else { + main.classList.remove("has-snippet"); + } + } + } + } + + 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 } = message; + + return ( + + + + + + ); + } + + renderPreviewBanner() { + if (this.state.message.provider !== "preview") { + return null; + } + + return ( +
+ + Preview Purposes Only +
+ ); + } + + 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; +
+ {this.renderSnippets()} +
+ ) : ( + // 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..9864823a77 --- /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.sys.mjs"; +import { actionCreators as ac } from "common/Actions.sys.mjs"; + +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 ( + + ); +}; 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..35234be4b0 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/components/Button/_Button.scss @@ -0,0 +1,51 @@ +.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-primary-action-background); + background-color: var(--newtab-primary-action-background); + color: var(--newtab-primary-element-text-color); + + &:hover { + background-color: var(--newtab-primary-element-hover-color); + } + + &:active { + background-color: var(--newtab-primary-element-active-color); + } + } + + &.slim { + border: $border-primary; + margin-inline-start: 0; + font-size: 12px; + padding: 6px 12px; + + &:hover, + &:focus { + box-shadow: $shadow-primary; + transition: box-shadow 150ms; + } + } +} 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 ( +
+ +
+ ); + } +} + +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..a1006c9437 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/components/ModalOverlay/_ModalOverlay.scss @@ -0,0 +1,103 @@ +// 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-background-color-secondary); + box-shadow: $shadow-large; + 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: var(--newtab-text-primary-color); + 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: $border-secondary; + 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 { + @include fade-in-card; + } + } + } +} 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..d430fa5c3d --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/components/RichText/RichText.jsx @@ -0,0 +1,86 @@ +/* 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: , + i: , + u: , + strong: , + em: , + 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 + + ); + 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 ( + + {props.text} + + ); +} 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 ( +
+
+ +
+
+ ); + } + + const label = this.props.content.block_button_text || "Remove this"; + return ( + + + ); + } + + render() { + const textStyle = { + color: this.props.content.text_color, + backgroundColor: this.props.content.background_color, + }; + const customElement = ( + + ); + return ( + + ); + } +} + +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 ( + + ); +}; 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..d9b6728067 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/EOYSnippet/EOYSnippet.schema.json @@ -0,0 +1,171 @@ +{ + "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..d9911ff02c --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/EOYSnippet/_EOYSnippet.scss @@ -0,0 +1,55 @@ +.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: var(--newtab-text-primary-color); + margin-inline-end: 18px; + border: $input-border; + padding: 5px 14px; + background: var(--newtab-background-color-secondary); + cursor: pointer; + } + + input { + &[type='radio'] { + opacity: 0; + margin-inline-end: -18px; + + &:checked + .donation-amount { + // Use a text color for the background to achieve an inverted look. + background: var(--newtab-text-secondary-color); + color: var(--newtab-background-color-secondary); + border: $border-secondary; + } + + // accessibility + &:checked:focus + .donation-amount, + &:not(:checked):focus + .donation-amount { + border: 1px dotted var(--newtab-primary-action-background); + } + } + } + + .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 ( + + ); +}; 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..315aaba7a0 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/FXASignupSnippet/FXASignupSnippet.schema.json @@ -0,0 +1,196 @@ +{ + "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..6fc4d2283a --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/FirstRun/addUtmParams.js @@ -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/. */ + +/** + * 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); + } + for (let [key, value] of Object.entries(BASE_PARAMS)) { + if (!returnUrl.searchParams.has(key)) { + returnUrl.searchParams.append(key, value); + } + } + 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 ( + + ); +}; 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..c77261c191 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/NewsletterSnippet/NewsletterSnippet.schema.json @@ -0,0 +1,186 @@ +{ + "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/ProtectionsPanelMessage.schema.json b/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/ProtectionsPanelMessage.schema.json new file mode 100644 index 0000000000..8ef9b802e1 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/ProtectionsPanelMessage.schema.json @@ -0,0 +1,62 @@ +{ + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$id": "file:///ProtectionsPanelMessage.schema.json", + "title": "ProtectionsPanelMessage", + "description": "A message shown in the protections panel.", + "allOf": [{ "$ref": "file:///FxMSCommon.schema.json#/$defs/Message" }], + "type": "object", + "properties": { + "content": { + "type": "object", + "properties": { + "title": { + "description": "The message title.", + "$ref": "file:///FxMSCommon.schema.json#/$defs/localizableText" + }, + "body": { + "description": "The body of the message.", + "$ref": "file:///FxMSCommon.schema.json#/$defs/localizableText" + }, + "link_text": { + "description": "The text of the call to action link.", + "$ref": "file:///FxMSCommon.schema.json#/$defs/localizableText" + }, + "cta_type": { + "description": "The type of URL open action.", + "type": "string", + "enum": ["OPEN_URL", "OPEN_PROTECTION_REPORT", "OPEN_ABOUT_PAGE"] + }, + "cta_url": { + "description": "The URL to open when the call to action is clicked", + "type": "string", + "format": "moz-url-format" + }, + "cta_where": { + "description": "How to open the cta.", + "type": "string", + "enum": ["current", "tabshifted", "tab", "save", "window"] + } + }, + "dependantSchemas": { + "link_text": ["cta_type", "cta_url"], + "cta_type": ["link_text"], + "cta_url": ["link_text"], + "cta_where": ["link_text"] + }, + "additionalProperties": false, + "required": ["title", "body"] + }, + "template": { + "type": "string", + "const": "protections_panel" + }, + "trigger": { + "description": "An action to trigger potentially showing the message. The action ID `protectionsPanelOpen` is required.", + "const": { + "id": "protectionsPanelOpen" + } + } + }, + "required": ["content", "template", "trigger"], + "additionalProperties": true +} diff --git a/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/Spotlight.schema.json b/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/Spotlight.schema.json new file mode 100644 index 0000000000..5d5b98f594 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/Spotlight.schema.json @@ -0,0 +1,66 @@ +{ + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$id": "file:///Spotlight.schema.json", + "title": "Spotlight", + "description": "A template with an image, title, content and two buttons.", + "allOf": [{ "$ref": "file:///FxMSCommon.schema.json#/$defs/Message" }], + "type": "object", + "properties": { + "content": { + "type": "object", + "properties": { + "template": { + "type": "string", + "description": "Specify the layout template for the Spotlight", + "const": "multistage" + }, + "backdrop": { + "type": "string", + "description": "Background css behind modal content" + }, + "logo": { + "type": "object", + "properties": { + "imageURL": { + "type": "string", + "description": "URL for image to use with the content" + }, + "imageId": { + "type": "string", + "description": "The ID for a remotely hosted image" + }, + "size": { + "type": "string", + "description": "The logo size." + } + }, + "additionalProperties": true + }, + "screens": { + "type": "array", + "description": "Collection of individual screen content" + }, + "transitions": { + "type": "boolean", + "description": "Show transitions within and between screens" + }, + "disableHistoryUpdates": { + "type": "boolean", + "description": "Don't alter the browser session's history stack - used with messaging surfaces like Feature Callouts" + }, + "startScreen": { + "type": "integer", + "description": "Index of first screen to show from message, defaulting to 0" + } + }, + "additionalProperties": true + }, + "template": { + "type": "string", + "description": "Specify whether the surface is shown as a Spotlight modal or an in-surface Feature Callout dialog", + "enum": ["spotlight", "feature_callout"] + } + }, + "additionalProperties": true, + "required": ["targeting"] +} 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..4ec7dc9522 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/ToolbarBadgeMessage.schema.json @@ -0,0 +1,45 @@ +{ + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$id": "file:///ToolbarBadgeMessage.schema.json", + "title": "ToolbarBadgeMessage", + "description": "A template that specifies to which element in the browser toolbar to add a notification.", + "allOf": [{ "$ref": "file:///FxMSCommon.schema.json#/$defs/Message" }], + "type": "object", + "properties": { + "content": { + "type": "object", + "properties": { + "target": { + "type": "string" + }, + "action": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + }, + "additionalProperties": true, + "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": { + "$ref": "file:///FxMSCommon.schema.json#/$defs/localizedText", + "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'" + } + }, + "additionalProperties": true, + "required": ["target"] + }, + "template": { + "type": "string", + "const": "toolbar_badge" + } + }, + "additionalProperties": true, + "required": ["targeting"] +} 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..c5a466a6e5 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/UpdateAction.schema.json @@ -0,0 +1,47 @@ +{ + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$id": "file:///UpdateAction.schema.json", + "title": "UpdateActionMessage", + "description": "A template for messages that execute predetermined actions.", + "allOf": [{ "$ref": "file:///FxMSCommon.schema.json#/$defs/Message" }], + "type": "object", + "properties": { + "content": { + "type": "object", + "properties": { + "action": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "data": { + "type": "object", + "description": "Additional data provided as argument when executing the action", + "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" + } + } + } + }, + "additionalProperties": true, + "description": "Optional action to take in addition to showing the notification", + "required": ["id", "data"] + } + }, + "additionalProperties": true, + "required": ["action"] + }, + "template": { + "type": "string", + "const": "update_action" + } + }, + "required": ["targeting"] +} 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..26e795d068 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/OnboardingMessage/WhatsNewMessage.schema.json @@ -0,0 +1,73 @@ +{ + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$id": "file:///WhatsNewMessage.schema.json", + "title": "WhatsNewMessage", + "description": "A template for the messages that appear in the What's New panel.", + "allOf": [{ "$ref": "file:///FxMSCommon.schema.json#/$defs/Message" }], + "type": "object", + "properties": { + "content": { + "type": "object", + "properties": { + "layout": { + "description": "Different message layouts", + "enum": ["tracking-protections"] + }, + "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": { + "$ref": "file:///FxMSCommon.schema.json#/$defs/localizableText", + "description": "Id of localized string or message override of What's New message title" + }, + "subtitle": { + "$ref": "file:///FxMSCommon.schema.json#/$defs/localizableText", + "description": "Id of localized string or message override of What's New message subtitle" + }, + "body": { + "$ref": "file:///FxMSCommon.schema.json#/$defs/localizableText", + "description": "Id of localized string or message override of What's New message body" + }, + "link_text": { + "$ref": "file:///FxMSCommon.schema.json#/$defs/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": "moz-url-format" + }, + "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": { + "$ref": "file:///FxMSCommon.schema.json#/$defs/localizableText", + "description": "Alt text for image." + } + }, + "additionalProperties": true, + "required": ["published_date", "title", "body", "cta_url", "bucket_id"] + }, + "template": { + "type": "string", + "const": "whatsnew_panel_message" + } + }, + "required": ["order"], + "additionalProperties": true +} diff --git a/browser/components/newtab/content-src/asrouter/templates/PBNewtab/NewtabPromoMessage.schema.json b/browser/components/newtab/content-src/asrouter/templates/PBNewtab/NewtabPromoMessage.schema.json new file mode 100644 index 0000000000..3719419428 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/PBNewtab/NewtabPromoMessage.schema.json @@ -0,0 +1,153 @@ +{ + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$id": "file:///NewtabPromoMessage.schema.json", + "title": "PBNewtabPromoMessage", + "description": "Message shown on the private browsing newtab page.", + "allOf": [{ "$ref": "file:///FxMSCommon.schema.json#/$defs/Message" }], + "type": "object", + "properties": { + "content": { + "type": "object", + "properties": { + "hideDefault": { + "type": "boolean", + "description": "Should we hide the default promo after the experiment promo is dismissed." + }, + "infoEnabled": { + "type": "boolean", + "description": "Should we show the info section." + }, + "infoIcon": { + "type": "string", + "description": "Icon shown in the left side of the info section. Default is the private browsing icon." + }, + "infoTitle": { + "type": "string", + "description": "Is the title in the info section enabled." + }, + "infoTitleEnabled": { + "type": "boolean", + "description": "Is the title in the info section enabled." + }, + "infoBody": { + "type": "string", + "description": "Text content in the info section." + }, + "infoLinkText": { + "type": "string", + "description": "Text for the link in the info section." + }, + "infoLinkUrl": { + "type": "string", + "description": "URL for the info section link.", + "format": "moz-url-format" + }, + "promoEnabled": { + "type": "boolean", + "description": "Should we show the promo section." + }, + "promoType": { + "type": "string", + "description": "Promo type used to determine if promo should show to a given user", + "enum": ["FOCUS", "VPN", "PIN", "COOKIE_BANNERS", "OTHER"] + }, + "promoSectionStyle": { + "type": "string", + "description": "Sets the position of the promo section. Possible values are: top, below-search, bottom. Default bottom.", + "enum": ["top", "below-search", "bottom"] + }, + "promoTitle": { + "type": "string", + "description": "The text content of the promo section." + }, + "promoTitleEnabled": { + "type": "boolean", + "description": "Should we show text content in the promo section." + }, + "promoLinkText": { + "type": "string", + "description": "The text of the link in the promo box." + }, + "promoHeader": { + "type": "string", + "description": "The title of the promo section." + }, + "promoButton": { + "type": "object", + "properties": { + "action": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "Action dispatched by the button." + }, + "data": { + "type": "object" + } + }, + "required": ["type"], + "additionalProperties": true + } + }, + "required": ["action"] + }, + "promoLinkType": { + "type": "string", + "description": "Type of promo link type. Possible values: link, button. Default is link.", + "enum": ["link", "button"] + }, + "promoImageLarge": { + "type": "string", + "description": "URL for image used on the left side of the promo box, larger, showcases some feature. Default off.", + "format": "uri" + }, + "promoImageSmall": { + "type": "string", + "description": "URL for image used on the right side of the promo box, smaller, usually a logo. Default off.", + "format": "uri" + } + }, + "additionalProperties": true, + "allOf": [ + { + "if": { + "properties": { + "promoEnabled": { "const": true } + }, + "required": ["promoEnabled"] + }, + "then": { + "required": ["promoButton"] + } + }, + { + "if": { + "properties": { + "infoEnabled": { "const": true } + }, + "required": ["infoEnabled"] + }, + "then": { + "required": ["infoLinkText"], + "if": { + "properties": { + "infoTitleEnabled": { "const": true } + }, + "required": ["infoTitleEnabled"] + }, + "then": { + "required": ["infoTitle"] + } + } + } + ] + }, + "template": { + "type": "string", + "const": "pb_newtab" + } + }, + "additionalProperties": true, + "required": ["targeting"] +} 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 ( + + ); +}; + +export const SendToDeviceScene2Snippet = props => { + return ; +}; 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..34567443f4 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/SendToDeviceSnippet/SendToDeviceSnippet.schema.json @@ -0,0 +1,243 @@ +{ + "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..44ef622227 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/SendToDeviceSnippet/isEmailOrPhoneNumber.js @@ -0,0 +1,39 @@ +/* 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 ? ( + + ) : null; + } + + renderTitle() { + const { title } = this.props.content; + return title ? ( +

+ {title} +
+

+ ) : 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 ( + + ); + } + + 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 ( +
+
+ + {props.content.icon_alt_text + {props.content.icon_alt_text +
+ {this.renderTitle()} +

{this.renderText()}

+ {this.props.extraContent} +
+ {
{this.renderButton()}
} +
+
+
+ ); + } +} 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..06368257f0 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/SimpleBelowSearchSnippet/SimpleBelowSearchSnippet.schema.json @@ -0,0 +1,114 @@ +{ + "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..9d902b4cbb --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/SimpleBelowSearchSnippet/_SimpleBelowSearchSnippet.scss @@ -0,0 +1,190 @@ + +.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: $shadow-card; + flex-direction: column; + padding: 16px; + text-align: center; + width: 100%; + + @media (min-width: $break-point-medium) { + align-items: flex-start; + background-color: transparent; + border-radius: 4px; + box-shadow: none; + flex-direction: row; + padding: 0; + text-align: inherit; + width: 696px; + } + + @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: $shadow-primary; + 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; + + @media (min-width: $break-point-medium) { + height: 24px; + width: 24px; + } + + @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..96570e2dbd --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/SimpleSnippet/SimpleSnippet.jsx @@ -0,0 +1,222 @@ +/* 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 ? ( +

+ {this.renderTitleIcon()} {title} +

+ ) : 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 ( + + + + + ); + } + + renderButton() { + const { props } = this; + if (!this._shouldRenderButton()) { + return null; + } + + return ( + + ); + } + + renderText() { + const { props } = this; + return ( + + ); + } + + wrapSectionHeader(url) { + return function (children) { + return
{children}; + }; + } + + wrapSnippetContent(children) { + return
{children}
; + } + + 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 ( +
+

+ + + + + {props.content.section_title_text} + + +

+
+ ); + } + + 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 ( +
+ + {sectionHeader} + + {props.content.icon_alt_text + {props.content.icon_alt_text +
+ {this.renderTitle()}

{this.renderText()}

+ {this.props.extraContent} +
+ {
{this.renderButton()}
} +
+
+
+ ); + } +} 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..4970b124af --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/SimpleSnippet/SimpleSnippet.schema.json @@ -0,0 +1,159 @@ +{ + "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..1ee83a5cc9 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/SimpleSnippet/_SimpleSnippet.scss @@ -0,0 +1,131 @@ +$section-header-height: 30px; +$icon-width: 54px; // width of primary icon + margin + +.SimpleSnippet { + &.tall { + padding: 27px 0; + } + + p em { + color: var(--newtab-text-emphasis-text-color); + font-style: normal; + background: var(--newtab-text-emphasis-background); + } + + &.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; + } + + &.bold, + &.takeover { + .donation-form-url, + .donation-amount { + padding-block: 8px; + } + + .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-text-primary-color); + display: inline-block; + font-size: 13px; + font-weight: bold; + margin: 0; + + a { + color: var(--newtab-text-primary-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..12eeecc084 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/SubmitFormSnippet/SubmitFormScene2Snippet.schema.json @@ -0,0 +1,167 @@ +{ + "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..b9750e0765 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/SubmitFormSnippet/SubmitFormSnippet.jsx @@ -0,0 +1,408 @@ +/* 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.error(err); + } + + 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 { + 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) => ( + + )); + } + + renderDisclaimer() { + const { content } = this.props; + if (!content.scene2_disclaimer_html) { + return null; + } + return ( +

+ +

+ ); + } + + renderFormPrivacyNotice() { + const { content } = this.props; + if (!content.scene2_privacy_html) { + return null; + } + return ( +

+ + +

+ ); + } + + 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 ( + +
+ {successTitle ? ( +

{successTitle}

+ ) : null} +

+ + {isSuccess ? null : ( + + )} +

+
+
+ ); + } + + 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 {children}; + }; + } + + renderInput() { + const placholder = + this.props.content.scene2_email_placeholder_text || + this.props.content.scene2_input_placeholder; + return ( + + ); + } + + renderForm() { + return ( +
+ {this.renderHiddenFormInputs()} +
+ {this.renderInput()} + +
+ {this.renderFormPrivacyNotice() || this.renderDisclaimer()} +
+ ); + } + + renderScene2Icon() { + const { content } = this.props; + if (!content.scene2_icon) { + return null; + } + + return ( +
+ {content.scene2_icon_alt_text + {content.scene2_icon_alt_text +
+ ); + } + + renderSignupView() { + const { content } = this.props; + const containerClass = `SubmitFormSnippet ${this.props.className}`; + return ( + + {this.renderScene2Icon()} +
+

+ {content.scene2_title && ( +

{content.scene2_title}

+ )}{" "} + {content.scene2_text && ( + + )} +

+
+ {this.renderForm()} +
+ ); + } + + 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 ( +
+

+ + + + + {props.content.section_title_text} + + +

+
+ ); + } + + return null; + } + + renderSignupViewAlt() { + const { content } = this.props; + const containerClass = `SubmitFormSnippet ${this.props.className} scene2Alt`; + return ( + + {this.renderSectionHeader()} + {this.renderScene2Icon()} +
+

+ {content.scene2_text && ( + + )} +

+ {this.renderForm()} +
+
+ ); + } + + 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 ( + + ); + } +} 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..2a5ebda7e0 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/SubmitFormSnippet/SubmitFormSnippet.schema.json @@ -0,0 +1,235 @@ +{ + "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..3c1738aef0 --- /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-background-color-secondary); + 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-text-primary-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/ToastNotification/ToastNotification.schema.json b/browser/components/newtab/content-src/asrouter/templates/ToastNotification/ToastNotification.schema.json new file mode 100644 index 0000000000..c6d917d235 --- /dev/null +++ b/browser/components/newtab/content-src/asrouter/templates/ToastNotification/ToastNotification.schema.json @@ -0,0 +1,85 @@ +{ + "$schema": "https://json-schema.org/draft/2019-09/schema", + "$id": "file:///ToastNotification.schema.json", + "title": "ToastNotification", + "description": "A template for toast notifications displayed by the Alert service.", + "allOf": [{ "$ref": "file:///FxMSCommon.schema.json#/$defs/Message" }], + "type": "object", + "properties": { + "content": { + "type": "object", + "properties": { + "title": { + "$ref": "file:///FxMSCommon.schema.json#/$defs/localizableText", + "description": "Id of localized string or message override of toast notification title" + }, + "body": { + "$ref": "file:///FxMSCommon.schema.json#/$defs/localizableText", + "description": "Id of localized string or message override of toast notification body" + }, + "icon_url": { + "description": "The URL of the image used as an icon of the toast notification.", + "type": "string", + "format": "moz-url-format" + }, + "image_url": { + "description": "The URL of an image to be displayed as part of the notification.", + "type": "string", + "format": "moz-url-format" + }, + "launch_url": { + "description": "The URL to launch when the notification or an action button is clicked.", + "type": "string", + "format": "moz-url-format" + }, + "requireInteraction": { + "type": "boolean", + "description": "Whether the toast notification should remain active until the user clicks or dismisses it, rather than closing automatically." + }, + "tag": { + "type": "string", + "description": "An identifying tag for the toast notification." + }, + "data": { + "type": "object", + "description": "Arbitrary data associated with the toast notification." + }, + "actions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "title": { + "$ref": "file:///FxMSCommon.schema.json#/$defs/localizableText", + "description": "The action text to be shown to the user." + }, + "action": { + "type": "string", + "description": "Opaque identifer that identifies action." + }, + "iconURL": { + "type": "string", + "format": "uri", + "description": "URL of an icon to display with the action." + }, + "windowsSystemActivationType": { + "type": "boolean", + "description": "Whether to have Windows process the given `action`." + } + }, + "required": ["action", "title"], + "additionalProperties": true + } + } + }, + "additionalProperties": true, + "required": ["title", "body"] + }, + "template": { + "type": "string", + "const": "toast_notification" + } + }, + "required": ["content", "targeting", "template", "trigger"], + "additionalProperties": true +} 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, +}; -- cgit v1.2.3