diff options
Diffstat (limited to '')
9 files changed, 1653 insertions, 0 deletions
diff --git a/browser/components/newtab/content-src/aboutwelcome/aboutwelcome.jsx b/browser/components/newtab/content-src/aboutwelcome/aboutwelcome.jsx new file mode 100644 index 0000000000..d9503584b9 --- /dev/null +++ b/browser/components/newtab/content-src/aboutwelcome/aboutwelcome.jsx @@ -0,0 +1,188 @@ +/* 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 ReactDOM from "react-dom"; +import { MultiStageAboutWelcome } from "./components/MultiStageAboutWelcome"; +import { SimpleAboutWelcome } from "./components/SimpleAboutWelcome"; +import { ReturnToAMO } from "./components/ReturnToAMO"; + +import { + AboutWelcomeUtils, + DEFAULT_WELCOME_CONTENT, +} from "../lib/aboutwelcome-utils"; + +class AboutWelcome extends React.PureComponent { + constructor(props) { + super(props); + this.state = { metricsFlowUri: null }; + this.fetchFxAFlowUri = this.fetchFxAFlowUri.bind(this); + this.handleStartBtnClick = this.handleStartBtnClick.bind(this); + } + + async fetchFxAFlowUri() { + this.setState({ metricsFlowUri: await window.AWGetFxAMetricsFlowURI() }); + } + + componentDidMount() { + this.fetchFxAFlowUri(); + + // Record impression with performance data after allowing the page to load + const recordImpression = domState => { + const { domComplete, domInteractive } = performance + .getEntriesByType("navigation") + .pop(); + window.AWSendEventTelemetry({ + event: "IMPRESSION", + event_context: { + domComplete, + domInteractive, + mountStart: performance.getEntriesByName("mount").pop().startTime, + domState, + source: this.props.UTMTerm, + page: "about:welcome", + }, + message_id: this.props.messageId, + }); + }; + if (document.readyState === "complete") { + // Page might have already triggered a load event because it waited for async data, + // e.g., attribution, so the dom load timing could be of a empty content + // with domState in telemetry captured as 'complete' + recordImpression(document.readyState); + } else { + window.addEventListener("load", () => recordImpression("load"), { + once: true, + }); + } + + // Captures user has seen about:welcome by setting + // firstrun.didSeeAboutWelcome pref to true and capturing welcome UI unique messageId + window.AWSendToParent("SET_WELCOME_MESSAGE_SEEN", this.props.messageId); + } + + handleStartBtnClick() { + AboutWelcomeUtils.handleUserAction(this.props.startButton.action); + const ping = { + event: "CLICK_BUTTON", + event_context: { + source: this.props.startButton.message_id, + page: "about:welcome", + }, + message_id: this.props.messageId, + id: "ABOUT_WELCOME", + }; + window.AWSendEventTelemetry(ping); + } + + render() { + const { props } = this; + if (props.template === "simplified") { + return ( + <SimpleAboutWelcome + metricsFlowUri={this.state.metricsFlowUri} + message_id={props.messageId} + utm_term={props.UTMTerm} + title={props.title} + subtitle={props.subtitle} + cards={props.cards} + startButton={props.startButton} + handleStartBtnClick={this.handleStartBtnClick} + /> + ); + } else if (props.template === "return_to_amo") { + return ( + <ReturnToAMO + message_id={props.messageId} + name={props.name} + url={props.url} + iconURL={props.iconURL} + /> + ); + } + + return ( + <MultiStageAboutWelcome + screens={props.screens} + metricsFlowUri={this.state.metricsFlowUri} + message_id={props.messageId} + utm_term={props.UTMTerm} + /> + ); + } +} + +AboutWelcome.defaultProps = DEFAULT_WELCOME_CONTENT; + +// Computes messageId and UTMTerm info used in telemetry +function ComputeTelemetryInfo(welcomeContent, experimentId, branchId) { + let messageId = + welcomeContent.template === "return_to_amo" + ? "RTAMO_DEFAULT_WELCOME" + : "DEFAULT_ABOUTWELCOME"; + let UTMTerm = "default"; + + if (welcomeContent.id) { + messageId = welcomeContent.id.toUpperCase(); + } + + if (experimentId && branchId) { + UTMTerm = `${experimentId}-${branchId}`.toLowerCase(); + } + return { + messageId, + UTMTerm, + }; +} + +async function retrieveRenderContent() { + // Check for override content in pref browser.aboutwelcome.overrideContent + let aboutWelcomeProps = await window.AWGetWelcomeOverrideContent(); + if (aboutWelcomeProps?.template) { + let { messageId, UTMTerm } = ComputeTelemetryInfo(aboutWelcomeProps); + return { aboutWelcomeProps, messageId, UTMTerm }; + } + + // Check for experiment and retrieve content + const { slug, branch } = await window.AWGetExperimentData(); + aboutWelcomeProps = branch?.feature ? branch.feature.value : {}; + + // Check if there is any attribution data, this could take a while to await in series + // especially when there is an add-on that requires remote lookup + // Moving RTAMO as part of another screen of multistage is one option to fix the delay + // as it will allow the initial page to be fast while we fetch attribution data in parallel for a later screen. + const attribution = await window.AWGetAttributionData(); + if (attribution?.template) { + aboutWelcomeProps = { + ...aboutWelcomeProps, + // If part of an experiment, render experiment template + template: aboutWelcomeProps?.template + ? aboutWelcomeProps.template + : attribution.template, + ...attribution.extraProps, + }; + } + + let { messageId, UTMTerm } = ComputeTelemetryInfo( + aboutWelcomeProps, + slug, + branch && branch.slug + ); + return { aboutWelcomeProps, messageId, UTMTerm }; +} + +async function mount() { + let { aboutWelcomeProps, messageId, UTMTerm } = await retrieveRenderContent(); + ReactDOM.render( + <AboutWelcome + messageId={messageId} + UTMTerm={UTMTerm} + {...aboutWelcomeProps} + />, + document.getElementById("root") + ); +} + +performance.mark("mount"); +mount(); diff --git a/browser/components/newtab/content-src/aboutwelcome/aboutwelcome.scss b/browser/components/newtab/content-src/aboutwelcome/aboutwelcome.scss new file mode 100644 index 0000000000..501b57952f --- /dev/null +++ b/browser/components/newtab/content-src/aboutwelcome/aboutwelcome.scss @@ -0,0 +1,673 @@ +// sass-lint:disable no-css-comments +/* 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 '../styles/normalize'; +@import '../styles/OnboardingImages'; + +$break-point-medium: 610px; +$break-point-large: 866px; +$break-point-widest: 1122px; +$logo-size: 112px; + +html { + height: 100%; +} + +body { + // sass-lint:disable no-color-literals + --grey-subtitle: #4A4A4F; + --grey-subtitle-1: #696977; + --newtab-background-color: #EDEDF0; + --newtab-background-color-1: #F9F9FA; + --newtab-text-primary-color: #0C0C0D; + --newtab-text-conditional-color: #4A4A4F; + --newtab-button-primary-color: #0060DF; + --newtab-button-secondary-color: #0060DF; + --newtab-card-background-color: #FFF; + --newtab-card-shadow: 0 1px 4px 0 rgba(12, 12, 13, 0.4); + --tiles-theme-section-border-width: 1px; + --welcome-header-text-color: #2B2156; + --welcome-header-text-color-1: #20133A; + --welcome-card-button-background-color: rgba(12, 12, 13, 0.1); + --welcome-card-button-background-hover-color: rgba(12, 12, 13, 0.2); + --welcome-card-button-background-active-color: rgba(12, 12, 13, 0.3); + --welcome-button-box-shadow-color: #0A84FF; + --welcome-button-box-shadow-inset-color: rgba(10, 132, 255, 0.3); + --welcome-button-text-color: #FFF; + --welcome-button-background-hover-color: #003EAA; + --welcome-button-background-active-color: #002275; + --about-welcome-media-fade: linear-gradient(transparent, transparent 35%, #F9F9FA, #F9F9FA); + + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Ubuntu', + 'Helvetica Neue', sans-serif; + font-size: 16px; + position: relative; + /* these two rules fix test failures in + "browser_ext_themes_ntp_colors" & "browser_ext_themes_ntp_colors_perwindow".*/ + color: var(--newtab-text-primary-color); + background-color: var(--newtab-background-color); + + &[lwt-newtab-brighttext] { + --newtab-background-color: #2A2A2E; + --newtab-background-color-1: #1D1133; + --newtab-text-primary-color: #F9F9FA; + --newtab-text-conditional-color: #F9F9FA; + --grey-subtitle-1: #FFF; + --newtab-button-primary-color: #0060DF; + --newtab-button-secondary-color: #FFF; + --newtab-card-background-color: #38383D; + --newtab-card-shadow: 0 1px 8px 0 rgba(12, 12, 13, 0.4); + --welcome-header-text-color: rgba(255, 255, 255, 0.6); + --welcome-header-text-color-1: #7542E5; + --welcome-card-button-background-color: rgba(12, 12, 13, 0.3); + --welcome-card-button-background-hover-color: rgba(12, 12, 13, 0.5); + --welcome-card-button-background-active-color: rgba(12, 12, 13, 0.7); + --welcome-button-box-shadow-color: #0A84FF; + --about-welcome-media-fade: linear-gradient(transparent, transparent 35%, #1D1133, #1D1133); + } +} + +.welcomeCardGrid { + margin: 0; + margin-top: 32px; + display: grid; + grid-gap: 32px; + transition: opacity 0.4s; + transition-delay: 0.1s; + grid-auto-rows: 1fr; + + @media (min-width: $break-point-medium) { + grid-template-columns: repeat(auto-fit, 224px); + } + + @media (min-width: $break-point-widest) { + grid-template-columns: repeat(auto-fit, 309px); + } +} + +.welcomeContainer { + text-align: center; + + @media (min-width: $break-point-medium) { + max-height: 1000px; + } + + h1 { + font-size: 36px; + font-weight: 200; + margin: 0 0 40px; + color: var(--welcome-header-text-color); + } + + .welcome-title { + margin-bottom: 5px; + line-height: 52px; + } + + .welcome-subtitle { + font-size: 28px; + font-weight: 200; + margin: 6px 0 0; + color: var(--grey-subtitle); + line-height: 42px; + } +} + +.welcomeContainerInner { + margin: auto; + padding: 40px 25px; + + @media (min-width: $break-point-medium) { + width: 530px; + } + + @media (min-width: $break-point-large) { + width: 786px; + } + + @media (min-width: $break-point-widest) { + width: 1042px; + } +} + +.welcomeCard { + position: relative; + background: var(--newtab-card-background-color); + border-radius: 4px; + box-shadow: var(--newtab-card-shadow); + font-size: 13px; + padding: 20px 20px 60px; + + @media (max-width: $break-point-large) { + padding: 20px; + } + + @media (min-width: $break-point-widest) { + font-size: 15px; + } +} + +.welcomeCard .onboardingTitle { + font-weight: normal; + color: var(--newtab-text-primary-color); + margin: 10px 0 4px; + font-size: 15px; + + @media (min-width: $break-point-widest) { + font-size: 18px; + } +} + +.welcomeCard .onboardingText { + margin: 0 0 60px; + color: var(--newtab-text-conditional-color); + line-height: 1.5; + font-weight: 200; +} + +.welcomeCard .onboardingButton { + color: var(--newtab-text-conditional-color); + background: var(--welcome-card-button-background-color); + border: 0; + border-radius: 4px; + margin: 14px; + min-width: 70%; + padding: 6px 14px; + white-space: pre-wrap; + cursor: pointer; + + &:focus, + &:hover { + box-shadow: none; + background: var(--welcome-card-button-background-hover-color); + } + + &:focus { + outline: dotted 1px; + } + + &:active { + background: var(--welcome-card-button-background-active-color); + } +} + +.welcomeCard .onboardingButtonContainer { + position: absolute; + bottom: 16px; + left: 0; + width: 100%; + text-align: center; +} + +.onboardingMessageImage { + height: 112px; + width: 180px; + background-size: auto 140px; + background-position: center center; + background-repeat: no-repeat; + display: inline-block; + + @media (max-width: $break-point-large) { + height: 75px; + min-width: 80px; + background-size: 140px; + } +} + +.start-button { + border: 0; + font-size: 15px; + font-family: inherit; + font-weight: 200; + margin-inline-start: 12px; + margin: 30px 0 25px; + padding: 8px 16px; + white-space: nowrap; + background-color: var(--newtab-button-primary-color); + color: var(--welcome-button-text-color); + cursor: pointer; + border-radius: 2px; + + &:focus { + background: var(--welcome-button-background-hover-color); + box-shadow: 0 0 0 1px var(--welcome-button-box-shadow-inset-color) inset, + 0 0 0 1px var(--welcome-button-box-shadow-inset-color), + 0 0 0 4px var(--welcome-button-box-shadow-color); + } + + &:hover { + background: var(--welcome-button-background-hover-color); + } + + &:active { + background: var(--welcome-button-background-active-color); + } +} + + +.onboardingContainer { + text-align: center; + overflow-x: auto; + height: 100vh; + background-color: var(--newtab-background-color-1); + + .screen { + display: flex; + flex-flow: column nowrap; + height: 100%; + } + + .brand-logo { + background: url('chrome://branding/content/about-logo.svg') top + center / $logo-size no-repeat; + padding: $logo-size 0 20px; + margin-top: 60px; + + &.cta-top { + margin-top: 25px; + } + } + + .welcomeZap { + span { + position: relative; + z-index: 1; + white-space: nowrap; + } + + .zap { + &::after { + display: block; + background-repeat: no-repeat; + background-size: 100% 100%; + content: ''; + position: absolute; + top: calc(100% - 0.15em); + width: 100%; + height: 0.3em; + left: 0; + z-index: -1; + } + + &.short::after { + background-image: url('chrome://activity-stream/content/data/content/assets/short-zap.svg'); + } + + &.long::after { + background-image: url('chrome://activity-stream/content/data/content/assets/long-zap.svg'); + } + } + + + } + + .welcome-text { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + margin-bottom: 20px; + + h1, + h2 { + width: 860px; + @media (max-width: $break-point-large) { + width: 530px; + } + + @media (max-width: $break-point-medium) { + width: 430px; + } + } + + h1 { + font-size: 48px; + line-height: 56px; + font-weight: bold; + margin: 0 6px; + color: var(--welcome-header-text-color-1); + } + + h2 { + font-size: 18px; + font-weight: normal; + margin: 10px 6px 0; + color: var(--grey-subtitle-1); + line-height: 28px; + max-width: 750px; + letter-spacing: -0.01em; + } + + img { + margin-inline: 2px; + width: 20px; + height: 20px; + } + } + + .tiles-theme-container { + margin: 10px auto; + border: 0; + } + + .sr-only { + opacity: 0; + overflow: hidden; + position: absolute; + + &.input { + height: 1px; + width: 1px; + } + } + + .tiles-theme-section { + display: grid; + grid-gap: 21px; + grid-template-columns: repeat(4, auto); + + /* --newtab-background-color-1 will be invisible, but it's necessary to + * keep the content from jumping around when it gets focus-within and + * does sprout a dotted border. This way it keeps a 1 pixel wide border + * either way so things don't change position. + */ + border: var(--tiles-theme-section-border-width) + solid + var(--newtab-background-color-1); + + @media (max-width: $break-point-medium) { + grid-template-columns: repeat(2, auto); + } + + &:focus-within { + border: var(--tiles-theme-section-border-width) dotted; + } + + .theme { + display: flex; + flex-direction: column; + padding: 0; + width: 180px; + height: 145px; + color: #000; + background-color: #FFF; + box-shadow: 0 1px 4px 0 rgba(12, 12, 13, 0.4); + border-radius: 4px; + cursor: pointer; + + .icon { + background-size: cover; + border-top-left-radius: 4px; + border-top-right-radius: 4px; + height: 91px; + + &:dir(rtl) { + transform: scaleX(-1); + } + + &.light { + background-image: url('chrome://mozapps/content/extensions/firefox-compact-light.svg'); + } + + &.dark { + background-image: url('chrome://mozapps/content/extensions/firefox-compact-dark.svg'); + } + + &.automatic { + background-image: url('chrome://mozapps/content/extensions/default-theme.svg'); + } + + &.alpenglow { + background-image: url('chrome://mozapps/content/extensions/firefox-alpenglow.svg'); + } + } + + .text { + display: flex; + font-size: 14px; + font-weight: bold; + line-height: 22px; + margin-inline-start: 12px; + margin-top: 9px; + } + + &.selected { + outline: 4px solid #0090ED; + outline-offset: -4px; + } + + &:focus, + &:active { + outline: 4px solid #0090ED; + outline-offset: -4px; + } + } + } + + .tiles-container { + margin: 10px auto; + + &.info { + padding: 6px 12px 12px; + + &:hover, + &:focus { + background-color: rgba(217, 217, 227, 0.3); + border-radius: 4px; + } + } + } + + .tiles-topsites-section { + $host-size: 12px; + $tile-size: 96px; + + display: grid; + grid-gap: $tile-size / 4; + grid-template-columns: repeat(5, auto); + + @media (max-width: $break-point-medium) { + grid-template-columns: repeat(3, auto); + } + + .site { + width: $tile-size; + } + + .icon { + background-size: cover; + border-radius: 4px; + box-shadow: var(--newtab-card-shadow); + color: rgba(255, 255, 255, 0.5); + font-size: $host-size * 2; + font-weight: bold; + height: $tile-size; + line-height: $tile-size; + } + + .host { + font-size: $host-size; + line-height: $host-size * 3; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .site:nth-child(1) .icon { + background-color: #7542E5; + } + + .site:nth-child(2) .icon { + background-color: #952BB9; + } + + .site:nth-child(3) .icon { + background-color: #E31587; + } + + .site:nth-child(4) .icon { + background-color: #E25920; + } + + .site:nth-child(5) .icon { + background-color: #0250BB; + } + } + + // "tiles-media-section" styles here will support tiles of type + // "video" when the screen JSON it set in the below format: + + // "tiles": { + // "type": "video", + // "source": { + // "default" : "<media-file-uri>", + // "dark" : "<media-file-dark-mode-uri>" + // } + // } + + .tiles-media-section { + align-self: center; + position: relative; + margin-top: -12px; + margin-bottom: -155px; + + .fade { + height: 390px; + width: 800px; + position: absolute; + background-image: var(--about-welcome-media-fade); + } + + .media { + height: 390px; + width: 800px; + } + + &.privacy { + background: top no-repeat url('chrome://activity-stream/content/data/content/assets/firefox-protections.svg'); + height: 200px; + width: 800px; + margin: 0; + + &.media { + opacity: 0; + } + } + } + + button { + font-family: inherit; + cursor: pointer; + border: 0; + border-radius: 4px; + + &.primary { + font-size: 16px; + margin-inline-start: 12px; + margin: 20px 0 0; + padding: 12px 20px; + white-space: nowrap; + background-color: var(--newtab-button-primary-color); + color: var(--welcome-button-text-color); + fill: currentColor; + position: relative; + z-index: 1; + // This transparent border will show up in Windows High Contrast Mode to improve accessibility. + border: 1px solid transparent; + + &:focus { + background: var(--welcome-button-background-hover-color); + box-shadow: 0 0 0 4px var(--welcome-button-box-shadow-color); + } + + &:hover { + background: var(--welcome-button-background-hover-color); + } + + &:active { + background: var(--welcome-button-background-active-color); + } + } + + &.secondary { + background-color: initial; + text-decoration: underline; + display: block; + padding: 0; + width: auto; + color: var(--newtab-button-secondary-color); + margin-top: 14px; + + &:hover, + &:active { + background-color: initial; + } + } + } + + .secondary-cta { + display: flex; + flex-direction: row; + justify-content: center; + font-size: 14px; + + &.top { + justify-content: end; + align-items: end; + padding-inline-end: 30px; + padding-top: 4px; + + @media (max-width: $break-point-medium) { + justify-content: center; + } + } + + span { + color: var(--grey-subtitle-1); + margin: 0 4px; + } + } + + .helptext { + padding: 1em; + text-align: center; + color: var(--grey-subtitle-1); + font-size: 12px; + line-height: 18px; + + &.default { + align-self: center; + max-width: 40%; + } + } + + .steps { + display: flex; + flex-direction: row; + justify-content: center; + margin-top: auto; + padding: 32px 0 66px; + z-index: 1; + + &.has-helptext { + padding-bottom: 0; + } + + .indicator { + width: 60px; + height: 4px; + margin-inline-end: 4px; + margin-inline-start: 4px; + background: var(--grey-subtitle-1); + border-radius: 5px; + // This transparent border will show up in Windows High Contrast Mode to improve accessibility. + border: 1px solid transparent; + opacity: 0.25; + + &.current { + opacity: 1; + } + } + } +} diff --git a/browser/components/newtab/content-src/aboutwelcome/components/FxCards.jsx b/browser/components/newtab/content-src/aboutwelcome/components/FxCards.jsx new file mode 100644 index 0000000000..4275c59c8f --- /dev/null +++ b/browser/components/newtab/content-src/aboutwelcome/components/FxCards.jsx @@ -0,0 +1,83 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import React from "react"; +import { addUtmParams } from "../../asrouter/templates/FirstRun/addUtmParams"; +import { OnboardingCard } from "../../asrouter/templates/OnboardingMessage/OnboardingMessage"; +import { AboutWelcomeUtils } from "../../lib/aboutwelcome-utils"; + +export class FxCards extends React.PureComponent { + constructor(props) { + super(props); + + this.state = { flowParams: null }; + + this.fetchFxAFlowParams = this.fetchFxAFlowParams.bind(this); + this.onCardAction = this.onCardAction.bind(this); + } + + componentDidUpdate() { + this.fetchFxAFlowParams(); + } + + componentDidMount() { + this.fetchFxAFlowParams(); + } + + async fetchFxAFlowParams() { + if (this.state.flowParams || !this.props.metricsFlowUri) { + return; + } + + const flowParams = await AboutWelcomeUtils.fetchFlowParams( + this.props.metricsFlowUri + ); + + this.setState({ flowParams }); + } + + onCardAction(action) { + let { type, data } = action; + let UTMTerm = `aboutwelcome-${this.props.utm_term}-card`; + + if (action.type === "OPEN_URL") { + let url = new URL(action.data.args); + addUtmParams(url, UTMTerm); + + if (action.addFlowParams && this.state.flowParams) { + url.searchParams.append("device_id", this.state.flowParams.deviceId); + url.searchParams.append("flow_id", this.state.flowParams.flowId); + url.searchParams.append( + "flow_begin_time", + this.state.flowParams.flowBeginTime + ); + } + + data = { ...data, args: url.toString() }; + } + + AboutWelcomeUtils.handleUserAction({ type, data }); + } + + render() { + const { props } = this; + return ( + <React.Fragment> + <div className={`welcomeCardGrid show`}> + {props.cards.map(card => ( + <OnboardingCard + key={card.id} + message={card} + className="welcomeCard" + sendUserActionTelemetry={props.sendTelemetry} + onAction={this.onCardAction} + UISurface="ABOUT_WELCOME" + {...card} + /> + ))} + </div> + </React.Fragment> + ); + } +} diff --git a/browser/components/newtab/content-src/aboutwelcome/components/HeroText.jsx b/browser/components/newtab/content-src/aboutwelcome/components/HeroText.jsx new file mode 100644 index 0000000000..bb3a296d54 --- /dev/null +++ b/browser/components/newtab/content-src/aboutwelcome/components/HeroText.jsx @@ -0,0 +1,19 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import React from "react"; +import { Localized } from "./MSLocalized"; + +export const HeroText = props => { + return ( + <React.Fragment> + <Localized text={props.title}> + <h1 className="welcome-title" /> + </Localized> + <Localized text={props.subtitle}> + <h2 className="welcome-subtitle" /> + </Localized> + </React.Fragment> + ); +}; diff --git a/browser/components/newtab/content-src/aboutwelcome/components/MSLocalized.jsx b/browser/components/newtab/content-src/aboutwelcome/components/MSLocalized.jsx new file mode 100644 index 0000000000..694a294028 --- /dev/null +++ b/browser/components/newtab/content-src/aboutwelcome/components/MSLocalized.jsx @@ -0,0 +1,50 @@ +/* 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 MS_STRING_PROP = "string_id"; + +/** + * Based on the .text prop, localizes an inner element if a string_id + * is provided, OR renders plain text, OR hides it if nothing is provided. + * + * Examples: + * + * Localized text + * ftl: + * title = Welcome + * jsx: + * <Localized text={{string_id: "title"}}><h1 /></Localized> + * output: + * <h1 data-l10n-id="title">Welcome</h1> + * + * Unlocalized text + * jsx: + * <Localized text="Welcome"><h1 /></Localized> + * output: + * <h1>Welcome</h1> + */ + +export const Localized = ({ text, children }) => { + if (!text) { + return null; + } + + let props = children ? children.props : {}; + let textNode; + + if (typeof text === "object" && text[MS_STRING_PROP]) { + props = { ...props }; + props["data-l10n-id"] = text[MS_STRING_PROP]; + } else if (typeof text === "string") { + textNode = text; + } + + if (!children) { + return React.createElement("span", props, textNode); + } else if (textNode) { + return React.cloneElement(children, props, textNode); + } + return React.cloneElement(children, props); +}; diff --git a/browser/components/newtab/content-src/aboutwelcome/components/MultiStageAboutWelcome.jsx b/browser/components/newtab/content-src/aboutwelcome/components/MultiStageAboutWelcome.jsx new file mode 100644 index 0000000000..fde65d3673 --- /dev/null +++ b/browser/components/newtab/content-src/aboutwelcome/components/MultiStageAboutWelcome.jsx @@ -0,0 +1,445 @@ +/* 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, { useState, useEffect, useRef } from "react"; +import { Localized } from "./MSLocalized"; +import { Zap } from "./Zap"; +import { AboutWelcomeUtils } from "../../lib/aboutwelcome-utils"; +import { + BASE_PARAMS, + addUtmParams, +} from "../../asrouter/templates/FirstRun/addUtmParams"; + +export const MultiStageAboutWelcome = props => { + const [index, setScreenIndex] = useState(0); + useEffect(() => { + // Send impression ping when respective screen first renders + props.screens.forEach(screen => { + if (index === screen.order) { + AboutWelcomeUtils.sendImpressionTelemetry( + `${props.message_id}_${screen.id}` + ); + } + }); + + // Remember that a new screen has loaded for browser navigation + if (index > window.history.state) { + window.history.pushState(index, ""); + } + }, [index]); + + useEffect(() => { + // Switch to the screen tracked in state (null for initial state) + const handler = ({ state }) => setScreenIndex(Number(state)); + + // Handle page load, e.g., going back to about:welcome from about:home + handler(window.history); + + // Watch for browser back/forward button navigation events + window.addEventListener("popstate", handler); + return () => window.removeEventListener("popstate", handler); + }, []); + + const [flowParams, setFlowParams] = useState(null); + const { metricsFlowUri } = props; + useEffect(() => { + (async () => { + if (metricsFlowUri) { + setFlowParams(await AboutWelcomeUtils.fetchFlowParams(metricsFlowUri)); + } + })(); + }, [metricsFlowUri]); + + // Transition to next screen, opening about:home on last screen button CTA + const handleTransition = + index < props.screens.length - 1 + ? () => setScreenIndex(prevState => prevState + 1) + : () => + AboutWelcomeUtils.handleUserAction({ + type: "OPEN_ABOUT_PAGE", + data: { args: "home", where: "current" }, + }); + + // Update top sites with default sites by region when region is available + const [region, setRegion] = useState(null); + useEffect(() => { + (async () => { + setRegion(await window.AWGetRegion()); + })(); + }, []); + + // Get the active theme so the rendering code can make it selected + // by default. + const [activeTheme, setActiveTheme] = useState(null); + const [initialTheme, setInitialTheme] = useState(null); + useEffect(() => { + (async () => { + let theme = await window.AWGetSelectedTheme(); + setInitialTheme(theme); + setActiveTheme(theme); + })(); + }, []); + + const useImportable = props.message_id.includes("IMPORTABLE"); + // Track whether we have already sent the importable sites impression telemetry + const importTelemetrySent = useRef(false); + const [topSites, setTopSites] = useState([]); + useEffect(() => { + (async () => { + let DEFAULT_SITES = await window.AWGetDefaultSites(); + const importable = JSON.parse(await window.AWGetImportableSites()); + const showImportable = useImportable && importable.length >= 5; + if (!importTelemetrySent.current) { + AboutWelcomeUtils.sendImpressionTelemetry(`${props.message_id}_SITES`, { + display: showImportable ? "importable" : "static", + importable: importable.length, + }); + importTelemetrySent.current = true; + } + setTopSites( + showImportable + ? { data: importable, showImportable } + : { data: DEFAULT_SITES, showImportable } + ); + })(); + }, [useImportable, region]); + + return ( + <React.Fragment> + <div className={`outer-wrapper onboardingContainer`}> + {props.screens.map(screen => { + return index === screen.order ? ( + <WelcomeScreen + key={screen.id} + id={screen.id} + totalNumberOfScreens={props.screens.length} + order={screen.order} + content={screen.content} + navigate={handleTransition} + topSites={topSites} + messageId={`${props.message_id}_${screen.id}`} + UTMTerm={props.utm_term} + flowParams={flowParams} + activeTheme={activeTheme} + initialTheme={initialTheme} + setActiveTheme={setActiveTheme} + /> + ) : null; + })} + </div> + </React.Fragment> + ); +}; + +export class WelcomeScreen extends React.PureComponent { + constructor(props) { + super(props); + this.handleAction = this.handleAction.bind(this); + } + + handleOpenURL(action, flowParams, UTMTerm) { + let { type, data } = action; + if (type === "SHOW_FIREFOX_ACCOUNTS") { + let params = { + ...BASE_PARAMS, + utm_term: `aboutwelcome-${UTMTerm}-screen`, + }; + if (action.addFlowParams && flowParams) { + params = { + ...params, + ...flowParams, + }; + } + data = { ...data, extraParams: params }; + } else if (type === "OPEN_URL") { + let url = new URL(data.args); + addUtmParams(url, `aboutwelcome-${UTMTerm}-screen`); + if (action.addFlowParams && flowParams) { + url.searchParams.append("device_id", flowParams.deviceId); + url.searchParams.append("flow_id", flowParams.flowId); + url.searchParams.append("flow_begin_time", flowParams.flowBeginTime); + } + data = { ...data, args: url.toString() }; + } + AboutWelcomeUtils.handleUserAction({ type, data }); + } + + async handleAction(event) { + let { props } = this; + + let targetContent = + props.content[event.currentTarget.value] || props.content.tiles; + if (!(targetContent && targetContent.action)) { + return; + } + + // Send telemetry before waiting on actions + AboutWelcomeUtils.sendActionTelemetry( + props.messageId, + event.currentTarget.value + ); + + let { action } = targetContent; + + if (["OPEN_URL", "SHOW_FIREFOX_ACCOUNTS"].includes(action.type)) { + this.handleOpenURL(action, props.flowParams, props.UTMTerm); + } else if (action.type) { + AboutWelcomeUtils.handleUserAction(action); + // Wait until migration closes to complete the action + if (action.type === "SHOW_MIGRATION_WIZARD") { + await window.AWWaitForMigrationClose(); + AboutWelcomeUtils.sendActionTelemetry(props.messageId, "migrate_close"); + } + } + + // A special tiles.action.theme value indicates we should use the event's value vs provided value. + if (action.theme) { + let themeToUse = + action.theme === "<event>" + ? event.currentTarget.value + : this.props.initialTheme || action.theme; + + this.props.setActiveTheme(themeToUse); + window.AWSelectTheme(themeToUse); + } + + if (action.navigate) { + props.navigate(); + } + } + + renderSecondaryCTA(className) { + return ( + <div + className={className ? `secondary-cta ${className}` : `secondary-cta`} + > + <Localized text={this.props.content.secondary_button.text}> + <span /> + </Localized> + <Localized text={this.props.content.secondary_button.label}> + <button + className="secondary" + value="secondary_button" + onClick={this.handleAction} + /> + </Localized> + </div> + ); + } + + renderTiles() { + switch (this.props.content.tiles.type) { + case "topsites": + return this.props.topSites && this.props.topSites.data ? ( + <div + className={`tiles-container ${ + this.props.content.tiles.info ? "info" : "" + }`} + > + <div + className="tiles-topsites-section" + name="topsites-section" + id="topsites-section" + aria-labelledby="helptext" + role="region" + > + {this.props.topSites.data + .slice(0, 5) + .map(({ icon, label, title }) => ( + <div + className="site" + key={icon + label} + aria-label={title ? title : label} + role="img" + > + <div + className="icon" + style={ + icon + ? { + backgroundColor: "transparent", + backgroundImage: `url(${icon})`, + } + : {} + } + > + {icon ? "" : label && label[0].toUpperCase()} + </div> + {this.props.content.tiles.showTitles && ( + <div className="host">{title || label}</div> + )} + </div> + ))} + </div> + </div> + ) : null; + case "theme": + return this.props.content.tiles.data ? ( + <div className="tiles-theme-container"> + <div> + <fieldset className="tiles-theme-section"> + <Localized text={this.props.content.subtitle}> + <legend className="sr-only" /> + </Localized> + {this.props.content.tiles.data.map( + ({ theme, label, tooltip, description }) => ( + <Localized + key={theme + label} + text={typeof tooltip === "object" ? tooltip : {}} + > + <label + className={`theme${ + theme === this.props.activeTheme ? " selected" : "" + }`} + title={theme + label} + > + <Localized + text={ + typeof description === "object" ? description : {} + } + > + <input + type="radio" + value={theme} + name="theme" + checked={theme === this.props.activeTheme} + className="sr-only input" + onClick={this.handleAction} + data-l10n-attrs="aria-description" + /> + </Localized> + <div className={`icon ${theme}`} /> + {label && ( + <Localized text={label}> + <div className="text" /> + </Localized> + )} + </label> + </Localized> + ) + )} + </fieldset> + </div> + </div> + ) : null; + case "video": + return this.props.content.tiles.source ? ( + <div + className={`tiles-media-section ${this.props.content.tiles.media_type}`} + > + <div className="fade" /> + <video + className="media" + autoPlay="true" + loop="true" + muted="true" + src={ + AboutWelcomeUtils.hasDarkMode() + ? this.props.content.tiles.source.dark + : this.props.content.tiles.source.default + } + /> + </div> + ) : null; + case "image": + return this.props.content.tiles.source ? ( + <div className={`${this.props.content.tiles.media_type}`}> + <img + src={ + AboutWelcomeUtils.hasDarkMode() && + this.props.content.tiles.source.dark + ? this.props.content.tiles.source.dark + : this.props.content.tiles.source.default + } + role="presentation" + alt="" + /> + </div> + ) : null; + } + return null; + } + + renderStepsIndicator() { + let steps = []; + for (let i = 0; i < this.props.totalNumberOfScreens; i++) { + let className = i === this.props.order ? "current" : ""; + steps.push(<div key={i} className={`indicator ${className}`} />); + } + return steps; + } + + renderHelpText() { + return ( + <Localized text={this.props.content.help_text.text}> + <p + id="helptext" + className={`helptext ${this.props.content.help_text.position}`} + /> + </Localized> + ); + } + + render() { + const { content, topSites } = this.props; + const hasSecondaryTopCTA = + content.secondary_button && content.secondary_button.position === "top"; + const showImportableSitesDisclaimer = + content.tiles && + content.tiles.type === "topsites" && + topSites && + topSites.showImportable; + + return ( + <main className={`screen ${this.props.id}`}> + {hasSecondaryTopCTA ? this.renderSecondaryCTA("top") : null} + <div className={`brand-logo ${hasSecondaryTopCTA ? "cta-top" : ""}`} /> + <div className="welcome-text"> + <Zap hasZap={content.zap} text={content.title} /> + <Localized text={content.subtitle}> + <h2 /> + </Localized> + </div> + {content.tiles ? this.renderTiles() : null} + <div> + <Localized + text={content.primary_button ? content.primary_button.label : null} + > + <button + className="primary" + value="primary_button" + onClick={this.handleAction} + /> + </Localized> + </div> + {content.secondary_button && content.secondary_button.position !== "top" + ? this.renderSecondaryCTA() + : null} + {content.help_text && content.help_text.position === "default" + ? this.renderHelpText() + : null} + <nav + className={ + (content.help_text && content.help_text.position === "footer") || + showImportableSitesDisclaimer + ? "steps has-helptext" + : "steps" + } + data-l10n-id={"onboarding-welcome-steps-indicator"} + data-l10n-args={`{"current": ${parseInt(this.props.order, 10) + + 1}, "total": ${this.props.totalNumberOfScreens}}`} + > + {/* These empty elements are here to help trigger the nav for screen readers. */} + <br /> + <p /> + {this.renderStepsIndicator()} + </nav> + {(content.help_text && content.help_text.position === "footer") || + showImportableSitesDisclaimer + ? this.renderHelpText() + : null} + </main> + ); + } +} diff --git a/browser/components/newtab/content-src/aboutwelcome/components/ReturnToAMO.jsx b/browser/components/newtab/content-src/aboutwelcome/components/ReturnToAMO.jsx new file mode 100644 index 0000000000..2d5501dc46 --- /dev/null +++ b/browser/components/newtab/content-src/aboutwelcome/components/ReturnToAMO.jsx @@ -0,0 +1,100 @@ +/* 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 { + AboutWelcomeUtils, + DEFAULT_RTAMO_CONTENT, +} from "../../lib/aboutwelcome-utils"; +import { Localized } from "./MSLocalized"; + +export class ReturnToAMO extends React.PureComponent { + constructor(props) { + super(props); + this.onClickAddExtension = this.onClickAddExtension.bind(this); + this.handleStartBtnClick = this.handleStartBtnClick.bind(this); + } + + onClickAddExtension() { + const { content, message_id, url } = this.props; + if (!content?.primary_button?.action?.data) { + return; + } + + // Set add-on url in action.data.url property from JSON + content.primary_button.action.data.url = url; + AboutWelcomeUtils.handleUserAction(content.primary_button.action); + const ping = { + event: "INSTALL", + event_context: { + source: "ADD_EXTENSION_BUTTON", + page: "about:welcome", + }, + message_id, + }; + window.AWSendEventTelemetry(ping); + } + + handleStartBtnClick() { + const { content, message_id } = this.props; + AboutWelcomeUtils.handleUserAction(content.startButton.action); + const ping = { + event: "CLICK_BUTTON", + event_context: { + source: content.startButton.message_id, + page: "about:welcome", + }, + message_id, + }; + window.AWSendEventTelemetry(ping); + } + + render() { + const { content } = this.props; + if (!content) { + return null; + } + // For experiments, when needed below rendered UI allows settings hard coded strings + // directly inside JSON except for ReturnToAMOText which picks add-on name and icon from fluent string + return ( + <div className="outer-wrapper onboardingContainer"> + <main className="screen"> + <div className="brand-logo" /> + <div className="welcome-text"> + <Localized text={content.subtitle}> + <h1 /> + </Localized> + <Localized text={content.text}> + <h2 + data-l10n-args={ + this.props.name + ? JSON.stringify({ "addon-name": this.props.name }) + : null + } + > + <img + data-l10n-name="icon" + src={this.props.iconURL} + role="presentation" + alt="" + /> + </h2> + </Localized> + <Localized text={content.primary_button.label}> + <button onClick={this.onClickAddExtension} className="primary" /> + </Localized> + <Localized text={content.startButton.label}> + <button + onClick={this.handleStartBtnClick} + className="secondary" + /> + </Localized> + </div> + </main> + </div> + ); + } +} + +ReturnToAMO.defaultProps = DEFAULT_RTAMO_CONTENT; diff --git a/browser/components/newtab/content-src/aboutwelcome/components/SimpleAboutWelcome.jsx b/browser/components/newtab/content-src/aboutwelcome/components/SimpleAboutWelcome.jsx new file mode 100644 index 0000000000..4186cb2d69 --- /dev/null +++ b/browser/components/newtab/content-src/aboutwelcome/components/SimpleAboutWelcome.jsx @@ -0,0 +1,35 @@ +/* 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 { HeroText } from "./HeroText"; +import { FxCards } from "./FxCards"; +import { Localized } from "./MSLocalized"; + +export class SimpleAboutWelcome extends React.PureComponent { + render() { + const { props } = this; + return ( + <div className="outer-wrapper welcomeContainer"> + <div className="welcomeContainerInner"> + <main> + <HeroText title={props.title} subtitle={props.subtitle} /> + <FxCards + cards={props.cards} + metricsFlowUri={this.props.metricsFlowUri} + sendTelemetry={window.AWSendEventTelemetry} + utm_term={this.props.UTMTerm} + /> + <Localized text={props.startButton.label}> + <button + className="start-button" + onClick={this.props.handleStartBtnClick} + /> + </Localized> + </main> + </div> + </div> + ); + } +} diff --git a/browser/components/newtab/content-src/aboutwelcome/components/Zap.jsx b/browser/components/newtab/content-src/aboutwelcome/components/Zap.jsx new file mode 100644 index 0000000000..a067c4d7fe --- /dev/null +++ b/browser/components/newtab/content-src/aboutwelcome/components/Zap.jsx @@ -0,0 +1,60 @@ +/* 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, { useEffect } from "react"; +import { Localized } from "./MSLocalized"; +const MS_STRING_PROP = "string_id"; +const ZAP_SIZE_THRESHOLD = 160; + +function calculateZapLength() { + let span = document.querySelector(".zap"); + if (!span) { + return; + } + let rect = span.getBoundingClientRect(); + if (rect && rect.width > ZAP_SIZE_THRESHOLD) { + span.classList.add("long"); + } else { + span.classList.add("short"); + } +} + +export const Zap = props => { + useEffect(() => { + requestAnimationFrame(() => calculateZapLength()); + }); + + if (!props.text) { + return null; + } + + if (props.hasZap) { + if (typeof props.text === "object" && props.text[MS_STRING_PROP]) { + return ( + <Localized text={props.text}> + <h1 className="welcomeZap"> + <span data-l10n-name="zap" className="zap" /> + </h1> + </Localized> + ); + } else if (typeof props.text === "string") { + // Parse string to zap style last word of the props.text + let titleArray = props.text.split(" "); + let lastWord = `${titleArray.pop()}`; + return ( + <h1 className="welcomeZap"> + {titleArray.join(" ").concat(" ")} + <span className="zap">{lastWord}</span> + </h1> + ); + } + } else { + return ( + <Localized text={props.text}> + <h1 /> + </Localized> + ); + } + return null; +}; |