diff options
Diffstat (limited to 'browser/components/aboutwelcome/content-src')
23 files changed, 5061 insertions, 0 deletions
diff --git a/browser/components/aboutwelcome/content-src/aboutwelcome.jsx b/browser/components/aboutwelcome/content-src/aboutwelcome.jsx new file mode 100644 index 0000000000..28bef55998 --- /dev/null +++ b/browser/components/aboutwelcome/content-src/aboutwelcome.jsx @@ -0,0 +1,144 @@ +/* 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 { AboutWelcomeUtils } from "./lib/aboutwelcome-utils.mjs"; +import { MultiStageAboutWelcome } from "./components/MultiStageAboutWelcome"; +import { ReturnToAMO } from "./components/ReturnToAMO"; + +class AboutWelcome extends React.PureComponent { + constructor(props) { + super(props); + this.state = { metricsFlowUri: null }; + this.fetchFxAFlowUri = this.fetchFxAFlowUri.bind(this); + } + + async fetchFxAFlowUri() { + this.setState({ metricsFlowUri: await window.AWGetFxAMetricsFlowURI?.() }); + } + + componentDidMount() { + if (!this.props.skipFxA) { + this.fetchFxAFlowUri(); + } + + if (document.location.href === "about:welcome") { + // Record impression with performance data after allowing the page to load + const recordImpression = domState => { + const { domComplete, domInteractive } = performance + .getEntriesByType("navigation") + .pop(); + AboutWelcomeUtils.sendImpressionTelemetry(this.props.messageId, { + domComplete, + domInteractive, + mountStart: performance.getEntriesByName("mount").pop().startTime, + domState, + source: this.props.UTMTerm, + }); + }; + 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); + } + } + + render() { + const { props } = this; + if (props.template === "return_to_amo") { + return ( + <ReturnToAMO + message_id={props.messageId} + type={props.type} + name={props.name} + url={props.url} + iconURL={props.iconURL} + themeScreenshots={props.screenshots} + metricsFlowUri={this.state.metricsFlowUri} + /> + ); + } + return ( + <MultiStageAboutWelcome + message_id={props.messageId} + defaultScreens={props.screens} + updateHistory={!props.disableHistoryUpdates} + metricsFlowUri={this.state.metricsFlowUri} + utm_term={props.UTMTerm} + transitions={props.transitions} + backdrop={props.backdrop} + startScreen={props.startScreen || 0} + appAndSystemLocaleInfo={props.appAndSystemLocaleInfo} + ariaRole={props.aria_role} + /> + ); + } +} + +// Computes messageId and UTMTerm info used in telemetry +function ComputeTelemetryInfo(welcomeContent, experimentId, branchId) { + let messageId = + welcomeContent.template === "return_to_amo" + ? `RTAMO_DEFAULT_WELCOME_${welcomeContent.type.toUpperCase()}` + : "DEFAULT_ID"; + let UTMTerm = "aboutwelcome-default"; + + if (welcomeContent.id) { + messageId = welcomeContent.id.toUpperCase(); + } + + if (experimentId && branchId) { + UTMTerm = `aboutwelcome-${experimentId}-${branchId}`.toLowerCase(); + } + return { + messageId, + UTMTerm, + }; +} + +async function retrieveRenderContent() { + // Feature config includes RTAMO attribution data if exists + // else below data in order specified + // user prefs + // experiment data + // defaults + let featureConfig = await window.AWGetFeatureConfig(); + + let { messageId, UTMTerm } = ComputeTelemetryInfo( + featureConfig, + featureConfig.slug, + featureConfig.branch && featureConfig.branch.slug + ); + return { featureConfig, messageId, UTMTerm }; +} + +async function mount() { + let { + featureConfig: aboutWelcomeProps, + messageId, + UTMTerm, + } = await retrieveRenderContent(); + ReactDOM.render( + <AboutWelcome + messageId={messageId} + UTMTerm={UTMTerm} + {...aboutWelcomeProps} + />, + document.getElementById("multi-stage-message-root") + ); +} + +performance.mark("mount"); +mount(); diff --git a/browser/components/aboutwelcome/content-src/aboutwelcome.scss b/browser/components/aboutwelcome/content-src/aboutwelcome.scss new file mode 100644 index 0000000000..aa49a04799 --- /dev/null +++ b/browser/components/aboutwelcome/content-src/aboutwelcome.scss @@ -0,0 +1,1940 @@ +/* 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/. */ +@use 'sass:math'; + +// Don't import any styles that are not scoped to .onboardingContainer. This +// stylesheet is loaded by FeatureCallout.sys.mjs into the browser chrome. To +// add other stylesheets to about:welcome or spotlight, add them to +// aboutwelcome.html or spotlight.html. Ideally, there should be no `@import` +// statements in the built aboutwelcome.css file. +@import '../../asrouter/content-src/styles/feature-callout'; +@import '../../asrouter/content-src/styles/shopping'; + +/* stylelint-disable max-nesting-depth */ + +$break-point-small: 570px; +$break-point-medium: 610px; +$break-point-large: 866px; +$container-min-width: 700px; +$logo-size: 80px; +$main-section-width: 504px; +$split-section-width: 400px; +$split-screen-height: 550px; +$small-main-section-height: 450px; +$small-secondary-section-height: 100px; +$noodle-buffer: 106px; +$double-section-width: 800px; + +html { + height: 100%; +} + +// Below variables are used via config JSON in AboutWelcomeDefaults +// and referenced below inside dummy class to pass test browser_parsable_css +.dummy { + background: var(--mr-welcome-background-color) var(--mr-welcome-background-gradient) var(--mr-secondary-position) var(--mr-screen-background-color); +} + +// Styling for content rendered in a Spotlight messaging surface. +:root { + &[dialogroot] { + background-color: transparent; + + body { + padding: 0; + } + + .onboardingContainer { + // Without this, the container will be 100vh in height. When the dialog + // overflows horizontally, the horizontal scrollbar will appear. If the + // scrollbars aren't overlay scrollbars (this is controlled by + // Theme::ScrollbarStyle), they will take up vertical space in the + // viewport, causing the dialog to overflow vertically. This causes the + // vertical scrollbar to appear, which takes up horizontal space, causing + // the horizontal scrollbar to appear, and so on. + height: 100%; + background-color: transparent; + + &:dir(rtl) { + transform: unset; + } + + .logo-container { + pointer-events: none; + } + + .screen { + &:dir(rtl) { + transform: unset; + } + } + } + } +} + +// Styling for about:welcome background container +.welcome-container { + .onboardingContainer { + min-height: $break-point-medium; + min-width: fit-content; + } +} + +.onboardingContainer { + --grey-subtitle-1: #696977; + --mr-welcome-background-color: #F8F6F4; + --mr-screen-heading-color: var(--in-content-text-color); + --mr-welcome-background-gradient: linear-gradient(0deg, rgba(144, 89, 255, 20%) 0%, rgba(2, 144, 238, 20%) 100%); + --mr-screen-background-color: #F8F6F4; + + @media (prefers-color-scheme: dark) { + --grey-subtitle-1: #FFF; + --mr-welcome-background-color: #333336; + --mr-welcome-background-gradient: linear-gradient(0deg, rgba(144, 89, 255, 30%) 0%, rgba(2, 144, 238, 30%) 100%); + --mr-screen-background-color: #62697A; + } + + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Ubuntu, + 'Helvetica Neue', sans-serif; + font-size: 16px; + position: relative; + text-align: center; + height: 100vh; + + @media (prefers-contrast) { + --mr-screen-background-color: buttontext; + --mr-screen-heading-color: buttonface; + + background-color: var(--in-content-page-background); + } + + // Transition all of these and reduced motion effectively only does opacity. + --transition: 0.6s opacity, 0.6s scale, 0.6s rotate, 0.6s translate; + + // Define some variables that are used for in/out states. + @media (prefers-reduced-motion: no-preference) { + --translate: 30px; + --rotate: 20deg; + --scale: 0.4; + --progress-bar-transition: 0.6s translate; + + // Scale is used for noodles that can be flipped. + &:dir(rtl) { + --scale: -0.4 0.4; + } + } + + // Use default values that match "unmoved" state to avoid motion. + @media (prefers-reduced-motion: reduce) { + --translate: 0; + --rotate: 0deg; + --scale: 1; + // To reduce motion, progress bar fades in instead of wiping in. + --progress-bar-transition: none; + + &:dir(rtl) { + --scale: -1 1; + } + } + + &:dir(rtl) { + transform: rotateY(180deg); + } + + .section-main { + display: flex; + flex-direction: column; + justify-content: center; + width: $main-section-width; + flex-shrink: 0; + } + + .section-main:not(.embedded-migration) { + position: relative; + } + + .main-content { + background-color: var(--in-content-page-background); + border-radius: 20px; + box-shadow: 0 2px 14px 0 rgba(0, 0, 0, 20%); + display: flex; + flex-direction: column; + height: 100%; + padding: 0; + transition: var(--transition); + z-index: 1; + box-sizing: border-box; + + &.no-steps { + padding-bottom: 48px; + } + + .main-content-inner { + display: flex; + flex-direction: column; + flex-grow: 1; + justify-content: space-around; + } + } + + // Handle conditional display of steps indicator + // Don't show when there's only one screen + .main-content .no-steps { + .main-content { + padding-bottom: 48px; + } + + .steps { + display: none; + } + } + + @mixin arrow-icon-styles { + .arrow-icon { + -moz-context-properties: fill; + fill: currentColor; + text-decoration: none; + + &::after { + content: ''; + padding-inline-end: 12px; + margin-inline-start: 4px; + background: url('chrome://browser/skin/forward.svg') no-repeat center / 12px; + } + + &:dir(rtl)::after { + background-image: url('chrome://browser/skin/back.svg'); + } + } + } + + @mixin secondary-cta-styles { + background-color: var(--in-content-button-background); + border: 1px solid var(--in-content-button-border-color); + line-height: 12px; + font-size: 0.72em; + font-weight: 600; + padding: 8px 16px; + text-decoration: none; + cursor: default; + + &:hover, + &[open] { + background-color: var(--in-content-button-background-hover); + color: var(--in-content-button-text-color-hover); + + &:active { + background-color: var(--in-content-button-background-active); + color: var(--in-content-button-text-color-active); + } + } + } + + @mixin text-link-styles { + background: none; + text-decoration: underline; + cursor: pointer; + color: var(--link-color); + + &:hover { + color: var(--link-color-hover); + } + + &:active { + color: var(--link-color-active); + + @media (prefers-contrast) { + text-decoration: none; + } + } + } + + .screen { + display: flex; + position: relative; + flex-flow: row nowrap; + height: 100%; + min-height: 500px; + overflow: hidden; + + &.light-text { + --in-content-page-color: rgb(251, 251, 254); + --in-content-primary-button-text-color: rgb(43, 42, 51); + --in-content-primary-button-text-color-hover: rgb(43, 42, 51); + --in-content-primary-button-background: rgb(0, 221, 255); + --in-content-primary-button-background-hover: rgb(128, 235, 255); + --in-content-primary-button-background-active: rgb(170, 242, 255); + --in-content-button-text-color: var(--in-content-page-color); + } + + &.dark-text { + --in-content-page-color: rgb(21, 20, 26); + --in-content-primary-button-text-color: rgb(251, 251, 254); + --in-content-primary-button-text-color-hover: rgb(251, 251, 254); + --in-content-primary-button-background: #0061E0; + --in-content-primary-button-background-hover: #0250BB; + --in-content-primary-button-background-active: #053E94; + --in-content-primary-button-border-color: transparent; + --in-content-primary-button-border-hover: transparent; + --in-content-button-text-color: var(--in-content-page-color); + } + + &:dir(rtl) { + transform: rotateY(180deg); + } + + &[pos='center'] { + background-color: rgba(21, 20, 26, 50%); + min-width: $main-section-width; + + &.with-noodles { + // Adjust for noodles partially extending out from the square modal + min-width: $main-section-width + $noodle-buffer; + min-height: $main-section-width + $noodle-buffer; + + .section-main { + height: $main-section-width; + } + } + + &.with-video { + justify-content: center; + background: none; + align-items: center; + + .section-main { + width: $double-section-width; + height: $split-screen-height; + } + + .main-content { + background-color: var(--mr-welcome-background-color); + border-radius: 8px; + box-shadow: 0 2px 14px rgba(58, 57, 68, 20%); + padding: 44px 85px 20px; + + .welcome-text { + margin: 0; + } + + .main-content-inner { + justify-content: space-between; + } + + h1, + h2 { + align-self: start; + } + + h1 { + font-size: 24px; + line-height: 28.8px; + } + + h2 { + font-size: 15px; + line-height: 22px; + } + + .secondary-cta { + @include arrow-icon-styles; + + justify-content: end; + + .secondary { + @include secondary-cta-styles; + + color: var(--in-content-button-text-color); + } + } + } + } + + &.addons-picker { + justify-content: center; + align-items: center; + background: none; + + .section-main { + width: $double-section-width; + height: $split-screen-height; + } + + .main-content { + background-color: var(--in-content-page-background); + border-radius: 8px; + box-shadow: 0 2px 14px rgba(58, 57, 68, 20%); + overflow: hidden; + + .welcome-text { + display: flex; + margin: 0; + } + + .main-content-inner { + padding: 25px 56px 0; + justify-content: space-between; + } + + h1, + h2 { + align-self: start; + } + + h2 { + font-size: 15px; + text-align: start; + } + + .brand-logo { + display: block; + margin: 40px 56px 0; + transition: var(--transition); + height: 30px; + } + + .additional-cta { + display: block; + margin: 8px 0; + + &.cta-link { + background: none; + padding: 0; + font-weight: normal; + + @include text-link-styles; + } + + &.secondary { + &:hover { + background-color: var(--in-content-button-background-hover); + } + } + } + + .secondary-cta { + @include arrow-icon-styles; + + justify-content: end; + + .secondary { + @include secondary-cta-styles; + + color: var(--in-content-button-text-color); + } + } + } + + .addon-container { + display: flex; + border: 1px solid var(--in-content-border-color); + box-shadow: 0 1px 2px 0 var(--in-content-border-color); + border-radius: 4px; + margin: 5px auto; + text-align: start; + + .rtamo-icon { + img { + margin: 10px; + } + } + + .addon-details { + display: grid; + width: 70%; + } + + .addon-title { + margin: 10px 0 3px; + font-size: 16px; + font-weight: 600; + } + + .addon-description { + margin: 2px 0 10px; + font-size: 13px; + font-weight: 400; + } + + .install-button-wrapper { + display: flex; + } + + button { + align-self: center; + width: 124px; + } + } + + .loader { + width: 1em; + height: 1em; + border: 3px solid var(--in-content-primary-button-text-color); + border-bottom-color: transparent; + border-radius: 50%; + box-sizing: border-box; + animation: rotation 1s linear infinite; + justify-self: center; + } + + @keyframes rotation { + 0% { + transform: rotate(0deg); + } + + 100% { + transform: rotate(360deg); + } + } + + .loaderContainer { + display: flex; + padding: 1.5px 37.5px; + margin: auto; + } + } + } + + &:not([pos='split']) { + .secondary-cta { + .secondary { + @include text-link-styles; + + font-size: 14px; + font-weight: normal; + line-height: 20px; + } + + &.top { + button { + color: #FFF; + + &:hover { + color: #E0E0E6; + } + } + } + } + + migration-wizard { + padding: 5px 60px; + + &::part(header){ + text-align: center; + } + + &::part(buttons){ + margin: 32px auto 0; + } + } + + .welcome-text { + &:empty { + margin: 0; + } + } + } + + &[pos='split'] { + margin: auto; + min-height: $split-screen-height; + + &::before { + content: ''; + position: absolute; + box-shadow: 0 2px 14px 0 rgba(0, 0, 0, 20%); + width: $split-section-width + $split-section-width; + height: $split-screen-height; + border-radius: 8px; + inset: 0; + margin: auto; + pointer-events: none; + } + + .section-secondary, + .section-main { + width: $split-section-width; + height: $split-screen-height; + } + + .secondary-cta.top { + position: fixed; + padding-inline-end: 0; + + button { + padding: 7px 15px; + } + } + + .section-main { + flex-direction: row; + display: block; + margin: auto auto auto 0; + + &:dir(rtl) { + margin: auto 0 auto auto; + } + + &.embedded-migration { + .main-content { + padding-block: 100px 0; + } + } + + .main-content { + border-radius: 0 8px 8px 0; + overflow: hidden; + padding-inline: 35px 20px; + padding-block: 120px 0; + box-shadow: none; + + &.no-steps { + padding-bottom: 48px; + } + + &:dir(rtl) { + border-radius: 8px 0 0 8px; + } + + .main-content-inner { + min-height: 330px; + + .mobile-download-buttons { + padding: 0; + margin-inline-start: -5px; + display: flex; + + button { + cursor: pointer; + } + } + + .qr-code-image { + margin: 5px 0 10px; + display: flex; + } + + .language-switcher-container { + .primary { + margin-bottom: 5px; + } + } + } + + .action-buttons { + position: relative; + text-align: initial; + height: 100%; + + .checkbox-container { + font-size: 13px; + margin-block: 1em; + + &:not(.multi-select-item) { + transition: var(--transition); + } + + input, + label { + vertical-align: middle; + } + } + + .additional-cta-box { + margin: 8px 0; + + .additional-cta { + margin: 0; + + &.cta-link { + @include text-link-styles; + + padding: 0; + font-weight: normal; + } + + &.secondary { + &:hover, + &[open] { + background-color: var(--in-content-button-background-hover); + } + } + } + } + + &.additional-cta-container { + flex-wrap: nowrap; + align-items: start; + } + + .secondary-cta { + position: absolute; + bottom: -30px; + inset-inline-end: 0; + + .secondary { + @include secondary-cta-styles; + } + + @include arrow-icon-styles; + } + } + + .logo-container { + text-align: start; + } + + .brand-logo { + height: 25px; + margin-block: 0; + } + + .logo-alt { + width: inherit; + height: inherit; + } + + .welcome-text { + margin-inline: 0 10px; + margin-block: 10px 35px; + text-align: initial; + align-items: initial; + + &:empty { + margin: 0; + } + + h1 { + font-size: 24px; + line-height: 1.2; + width: 300px; + } + + h2 { + margin: 10px 0 0; + min-height: 1em; + font-size: 15px; + line-height: 1.5; + + @media (prefers-contrast: no-preference) { + color: #5B5B66; + } + } + } + + .welcome-text h1, + .primary { + margin: 0; + } + + .steps { + z-index: 1; + + &.progress-bar { + width: $split-section-width; + margin-inline: -35px; + } + } + + @media (prefers-contrast) { + border: 1px solid var(--in-content-page-color); + + .steps.progress-bar { + border-top: 1px solid var(--in-content-page-color); + background-color: var(--in-content-page-background); + + .indicator { + background-color: var(--in-content-accent-color); + } + } + } + } + } + + .section-secondary { + --mr-secondary-position: center center / auto 350px; + + border-radius: 8px 0 0 8px; + margin: auto 0 auto auto; + display: flex; + align-items: center; + -moz-context-properties: fill, stroke, fill-opacity, stroke-opacity; + stroke: currentColor; + + &:dir(rtl) { + border-radius: 0 8px 8px 0; + margin: auto auto auto 0; + } + + h1 { + color: var(--mr-screen-heading-color); + font-weight: 700; + font-size: 47px; + line-height: 110%; + max-width: 340px; + text-align: initial; + white-space: pre-wrap; + text-shadow: none; + margin-inline: 40px 0; + } + + .image-alt { + width: inherit; + height: inherit; + } + + .hero-image { + flex: 1; + display: flex; + justify-content: center; + max-height: 100%; + + img { + width: 100%; + max-width: 180px; + margin: 25px 0; + padding-bottom: 30px; + + @media only screen and (width <= 800px) { + padding-bottom: unset; + } + } + } + } + + .multi-select-container { + margin-inline: 0 10px; + + @media only screen and (width <= 800px) { + flex-direction: column; + align-self: center; + align-items: start; + justify-content: center; + width: 240px; + padding: 0 30px; + margin-inline: 0; + box-sizing: content-box; + } + } + + .tiles-theme-container { + margin-block: -20px auto; + align-items: initial; + + .theme { + min-width: 38px; + } + } + + @media (prefers-contrast: no-preference) and (prefers-color-scheme: dark) { + .section-main .main-content { + .welcome-text h2 { + color: #CFCFD8; + } + + .action-buttons .secondary { + background-color: #2B2A33; + } + } + } + + @media only screen and (width >= 800px) { + .tiles-theme-section { + margin-inline-start: -10px; + } + } + + @media only screen and (width <= 800px) { + flex-direction: column; + min-height: $small-main-section-height + $small-secondary-section-height; + + &::before { + width: $split-section-width; + } + + .section-secondary, + .section-main { + width: $split-section-width; + } + + .section-secondary { + --mr-secondary-background-position-y: top; + --mr-secondary-position: center var(--mr-secondary-background-position-y) / 75%; + + border-radius: 8px 8px 0 0; + margin: auto auto 0; + height: $small-secondary-section-height; + + .hero-image img { + margin: 6px 0; + } + + .message-text { + margin-inline: auto; + } + + h1 { + font-size: 35px; + text-align: center; + white-space: normal; + margin-inline: auto; + margin-block: 14px 6px; + } + + &:dir(rtl) { + margin: auto auto 0; + border-radius: 8px 8px 0 0; + } + + &.with-secondary-section-hidden { + display: none; + } + } + + migration-wizard { + &::part(deck){ + min-width: 330px; + margin-inline: 36px; + } + } + + .section-main { + margin: 0 auto auto; + height: $small-main-section-height; + + migration-wizard::part(buttons) { + flex-direction: column; + margin-inline: 46px; + } + + &[hide-secondary-section='responsive'] { + height: $split-screen-height; + margin: auto; + + .main-content { + padding: 50px 0 0; + border-radius: 8px; + } + } + + .main-content { + border-radius: 0 0 8px 8px; + padding: 30px 0 0; + + .main-content-inner { + align-items: center; + } + + .logo-container { + text-align: center; + + .brand-logo { + min-height: 25px; + + &, + &:dir(rtl) { + background-position: center; + } + } + + .logo-alt { + width: inherit; + height: inherit; + } + } + + .welcome-text { + align-items: center; + text-align: center; + margin-inline: 0; + padding-inline: 30px; + + .spacer-bottom, + .spacer-top { + display: none; + } + } + + .action-buttons { + text-align: center; + + .checkbox-container { + display: none; + } + + .secondary-cta { + position: relative; + margin-block: 10px 0; + bottom: 0; + } + } + + .primary, + .secondary { + min-width: 240px; + margin-inline: 0; + } + + .steps { + padding-block: 0; + margin: 0; + + &.progress-bar { + margin-inline: 0; + } + } + } + + .additional-cta { + &.cta-link { + align-self: center; + } + } + + .dismiss-button { + top: -$small-secondary-section-height; + } + + &:dir(rtl) { + margin: 0 auto auto; + + .main-content { + border-radius: 0 0 8px 8px; + } + } + } + + } + + @media only screen and (height <= 650px) { + // Hide the "Sign in" button on the welcome screen when it would + // otherwise overlap the screen. We'd reposition it, but then it would + // overlap the dismiss button. We may change the alignment so they don't + // overlap in a future revision. + @media (800px <= width <= 990px) { + .section-main .secondary-cta.top { + display: none; + } + } + + // Reposition the "Sign in" button on the welcome screen to move inside + // the screen when it would otherwise overlap the screen. + @media (width <= 620px) { + .section-main .secondary-cta.top { + position: absolute; + padding: 0; + top: 0; + inset-inline-end: 0; + } + } + } + } + } + + .brand-logo { + margin-block: 60px 10px; + transition: var(--transition); + height: 80px; + + &.cta-top { + margin-top: 25px; + } + + &.hide { + visibility: hidden; + padding: unset; + margin-top: 50px; + } + } + + .logo-alt { + width: inherit; + height: inherit; + } + + .rtamo-theme-icon { + max-height: 30px; + border-radius: 2px; + margin-bottom: 10px; + margin-top: 24px; + } + + .rtamo-icon { + text-align: start; + + @media only screen and (width <= 800px) { + text-align: center; + } + } + + .text-link { + @include text-link-styles; + } + + .welcome-text { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + margin: 0.5em 1em; + transition: var(--transition); + + h1, + h2 { + color: var(--in-content-page-color); + line-height: 1.5; + } + + h1 { + font-size: 24px; + font-weight: 600; + margin: 0 6px; + letter-spacing: -0.02em; + outline: none; + } + + h2 { + font-size: 16px; + font-weight: normal; + margin: 10px 6px 0; + max-width: 750px; + letter-spacing: -0.01em; + } + + &.fancy { + h1 { + background-image: linear-gradient(90deg, #9059FF, #FF4AA2, #FF8C00, #FF4AA2, #9059FF); + background-clip: text; + background-size: 200%; + + @media (prefers-contrast: no-preference) { + color: transparent; + } + + @media (prefers-color-scheme: dark) { + background-image: linear-gradient(90deg, #C688FF, #FF84C0, #FFBD4F, #FF84C0, #C688FF); + + &::selection { + color: #FFF; + background-color: #696977; + } + } + } + } + + &.shine { + h1 { + animation: shine 50s linear infinite; + background-size: 400%; + } + + @keyframes shine { + to { + background-position: 400%; + } + } + } + + .cta-paragraph { + a { + margin: 0; + text-decoration: underline; + cursor: pointer; + } + } + } + + // Override light and dark mode fancy title colors for use over light and dark backgrounds + .screen.light-text .welcome-text.fancy h1 { + background-image: linear-gradient(90deg, #C688FF, #FF84C0, #FFBD4F, #FF84C0, #C688FF); + } + + .screen.dark-text .welcome-text.fancy h1 { + background-image: linear-gradient(90deg, #9059FF, #FF4AA2, #FF8C00, #FF4AA2, #9059FF); + } + + .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; + transform: scaleY(3); + } + + &.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'); + } + } + } + + .language-loader { + filter: invert(1); + margin-inline-end: 10px; + position: relative; + top: 3px; + width: 16px; + height: 16px; + margin-top: -6px; + } + + @media (prefers-color-scheme: dark) { + .language-loader { + filter: invert(0); + } + } + + .tiles-theme-container { + display: flex; + flex-direction: column; + align-items: center; + margin: 10px auto; + } + + .sr-only { + opacity: 0; + overflow: hidden; + position: absolute; + + &.input { + height: 1px; + width: 1px; + } + } + + .tiles-theme-section { + border: 0; + display: flex; + flex-wrap: wrap; + gap: 5px; + justify-content: space-evenly; + margin-inline: 10px; + padding: 10px; + transition: var(--transition); + + &:hover, + &:active, + &:focus-within { + border-radius: 8px; + outline: 2px solid var(--in-content-primary-button-background); + } + + .theme { + align-items: center; + display: flex; + flex-direction: column; + flex: 1; + padding: 0; + min-width: 50px; + width: 180px; + color: #000; + box-shadow: none; + border-radius: 4px; + cursor: pointer; + z-index: 0; + + &:focus, + &:active { + outline: initial; + outline-offset: initial; + } + + .icon { + background-size: cover; + width: 40px; + height: 40px; + border-radius: 40px; + outline: 1px solid var(--in-content-border-color); + outline-offset: -0.5px; + z-index: -1; + + &:dir(rtl) { + transform: scaleX(-1); + } + + &:focus, + &:active, + &.selected { + outline: 2px solid var(--in-content-primary-button-background); + outline-offset: 2px; + } + + &.light { + background-image: url('resource://builtin-themes/light/icon.svg'); + } + + &.dark { + background-image: url('resource://builtin-themes/dark/icon.svg'); + } + + &.alpenglow { + background-image: url('resource://builtin-themes/alpenglow/icon.svg'); + } + + &.default, + &.automatic { + background-image: url('resource://default-theme/icon.svg'); + } + } + + .text { + display: flex; + color: var(--in-content-page-color); + font-size: 14px; + font-weight: normal; + line-height: 20px; + margin-inline-start: 0; + margin-top: 9px; + } + } + + legend { + cursor: default; + } + } + + .tiles-container { + margin: 10px auto; + + &.info { + padding: 6px 12px 12px; + + &:hover, + &:focus { + background-color: rgba(217, 217, 227, 30%); + border-radius: 4px; + } + } + } + + .tiles-delayed { + animation: fadein 0.4s; + } + + .multi-select-container { + display: flex; + flex-direction: column; + flex-wrap: wrap; + flex-shrink: 0; + align-items: flex-start; + gap: 16px; + margin-block: -1em 2em; + margin-inline: 1em; + color: #5B5B66; + font-weight: 400; + font-size: 14px; + text-align: initial; + transition: var(--transition); + z-index: 1; + + #multi-stage-multi-select-label { + // These styles are based on .welcome-text>h2 (subtitle). + color: var(--in-content-page-color); + line-height: 1.5; + font-size: 16px; + font-weight: normal; + letter-spacing: -0.01em; + // Try to get the label positioned the same way it would be if it was a + // subtitle. -0.5em for the welcome-text margin, 1em for the + // multi-select-container margin, and 10px for the desired margin between + // the label and the title. + margin: calc(-0.5em + 1em + 10px) 6px 0; + max-width: 750px; + } + + @at-root .onboardingContainer .screen[pos='split'] .multi-select-container #multi-stage-multi-select-label { + margin: calc(-35px + 1em + 10px) 0 0; + min-height: 1em; + font-size: 15px; + line-height: 1.5; + + @media (prefers-contrast: no-preference) { + color: #5B5B66; + } + + @media (prefers-contrast: no-preference) and (prefers-color-scheme: dark) { + color: #CFCFD8; + } + } + + .checkbox-container { + display: flex; + } + + @media (prefers-contrast: no-preference) and (prefers-color-scheme: dark) { + color: #CFCFD8; + } + } + + .mobile-downloads { + .qr-code-image { + margin: 24px 0 10px; + width: 113px; + height: 113px; + } + + .email-link { + @include text-link-styles; + + font-size: 16px; + font-weight: 400; + + &:hover { + background: none; + } + } + + .ios button { + background-image: url('chrome://app-marketplace-icons/locale/ios.svg'); + } + + .android button { + background-image: url('chrome://app-marketplace-icons/locale/android.png'); + } + } + + .mobile-download-buttons { + list-style: none; + padding: 10px 0; + margin: 0; + + li { + display: inline-block; + + button { + display: inline-block; + height: 45px; + width: 152px; + background-repeat: no-repeat; + background-size: contain; + background-position: center; + box-shadow: none; + border: 0; + } + + &:not(:first-child) { + margin-inline: 5px 0; + } + } + } + + .dismiss-button { + position: absolute; + z-index: 2; + top: 0; + left: auto; + right: 0; + box-sizing: border-box; + padding: 0; + margin: 16px; + display: block; + float: inline-end; + background: url('chrome://global/skin/icons/close.svg') no-repeat center / 16px; + height: 32px; + width: 32px; + align-self: end; + // override default min-height and min-width for buttons + min-height: 32px; + min-width: 32px; + -moz-context-properties: fill; + fill: currentColor; + transition: var(--transition); + + &:dir(rtl) { + left: 0; + right: auto; + } + } + + @keyframes fadein { + from { opacity: 0; } + } + + .secondary-cta { + display: flex; + align-items: end; + flex-direction: row; + justify-content: center; + font-size: 14px; + transition: var(--transition); + + &.top { + justify-content: end; + padding-inline-end: min(150px, 500px - 70vh); + padding-top: 4px; + position: absolute; + top: 10px; + inset-inline-end: 20px; + z-index: 2; + } + + span { + color: var(--grey-subtitle-1); + margin: 0 4px; + } + } + + .message-text { + transition: var(--transition); + } + + .helptext { + padding: 1em; + text-align: center; + color: var(--grey-subtitle-1); + font-size: 12px; + line-height: 18px; + + &.default { + align-self: center; + max-width: 40%; + } + + span { + padding-inline-end: 4px; + } + } + + .helptext-img { + height: 1.5em; + width: 1.5em; + margin-inline-end: 4px; + vertical-align: middle; + + &.end { + margin: 4px; + } + + &.footer { + vertical-align: bottom; + } + } + + .steps { + display: flex; + flex-direction: row; + justify-content: center; + margin-top: 0; + padding-block: 16px 0; + transition: var(--transition); + z-index: -1; + height: 48px; + box-sizing: border-box; + + &.has-helptext { + padding-bottom: 0; + } + + .indicator { + width: 0; + height: 0; + margin-inline-end: 4px; + margin-inline-start: 4px; + background: var(--grey-subtitle-1); + border-radius: 5px; + // using border will show up in Windows High Contrast Mode to improve accessibility. + border: 3px solid var(--in-content-button-text-color); + opacity: 0.35; + box-sizing: inherit; + + &.current { + opacity: 1; + border-color: var(--in-content-primary-button-background); + + // This is the only step shown, so visually hide it to maintain spacing. + &:last-of-type:first-of-type { + opacity: 0; + } + } + } + + &.progress-bar { + height: 6px; + padding-block: 0; + margin-block: 42px 0; + background-color: color-mix(in srgb, var(--in-content-button-text-color) 25%, transparent); + justify-content: start; + opacity: 1; + transition: none; + + .indicator { + width: 100%; + height: 100%; + margin-inline: -1px; + background-color: var(--in-content-primary-button-background); + border: 0; + border-radius: 0; + opacity: 1; + transition: var(--progress-bar-transition); + translate: calc(var(--progress-bar-progress, 0%) - 100%); + + &:dir(rtl) { + translate: calc(var(--progress-bar-progress, 0%) * -1 + 100%); + } + } + } + } + + .additional-cta-container { + &[flow] { + display: flex; + flex-flow: column wrap; + align-items: center; + + &[flow='row'] { + flex-direction: row; + justify-content: center; + + .secondary-cta { + flex-basis: 100%; + } + } + } + } + + .primary, + .secondary, + .additional-cta, + .submenu-button { + font-size: 13px; + line-height: 16px; + padding: 11px 15px; + transition: var(--transition); + + &.rtamo { + margin-top: 24px; + } + } + + .secondary { + background-color: var(--in-content-button-background); + color: var(--in-content-button-text-color); + } + + .split-button-container, + .screen .action-buttons .split-button-container { + align-items: stretch; + + &:not([hidden]) { + display: flex; + } + + .primary, + .secondary, + .additional-cta { + &:not(.submenu-button) { + border-start-end-radius: 0; + border-end-end-radius: 0; + margin-inline-end: 0; + } + + &:focus-visible { + z-index: 2; + } + } + + .submenu-button { + border-start-start-radius: 0; + border-end-start-radius: 0; + margin-inline-start: 1px; + padding: 8px; + min-width: 30px; + box-sizing: border-box; + background-image: url('chrome://global/skin/icons/arrow-down.svg'); + background-repeat: no-repeat; + background-size: 16px; + background-position: center; + -moz-context-properties: fill; + fill: currentColor; + } + } + + // Styles specific to background noodles, with screen-by-screen positions + .noodle { + display: block; + background-repeat: no-repeat; + background-size: 100% 100%; + position: absolute; + transition: var(--transition); + + // Flip noodles in a way that combines individual transforms. + &:dir(rtl) { + scale: -1 1; + } + } + + .outline-L { + background-image: url('chrome://activity-stream/content/data/content/assets/noodle-outline-L.svg'); + } + + .solid-L { + background-image: url('chrome://activity-stream/content/data/content/assets/noodle-solid-L.svg'); + -moz-context-properties: fill; + fill: var(--in-content-page-background); + display: none; + } + + .purple-C { + background-image: url('chrome://activity-stream/content/data/content/assets/noodle-C.svg'); + -moz-context-properties: fill; + fill: #E7258C; + } + + .orange-L { + background-image: url('chrome://activity-stream/content/data/content/assets/noodle-solid-L.svg'); + -moz-context-properties: fill; + fill: #FFA437; + } + + .screen-1 { + .section-main { + z-index: 1; + margin: auto; + } + + // Position of noodles on second screen + .outline-L { + width: 87px; + height: 80px; + transform: rotate(10deg) translate(-30%, 200%); + transition-delay: 0.4s; + z-index: 2; + } + + .orange-L { + width: 550px; + height: 660px; + transform: rotate(-155deg) translate(11%, -18%); + transition-delay: 0.2s; + } + + .purple-C { + width: 310px; + height: 260px; + transform: translate(-18%, -67%); + } + + .yellow-circle { + width: 165px; + height: 165px; + border-radius: 50%; + transform: translate(230%, -5%); + background: #952BB9; + transition-delay: -0.2s; + } + } + + // Defining the timing of the transition-in for items within the dialog, + // These longer delays are to allow for the dialog to slide down on first screen + .dialog-initial { + .brand-logo { + transition-delay: 0.6s; + } + + .welcome-text { + transition-delay: 0.8s; + } + + .tiles-theme-section, + .multi-select-container, + migration-wizard { + transition-delay: 0.9s; + } + + .primary, + .secondary, + .secondary-cta, + .steps, + .cta-link { + transition-delay: 1s; + } + } + + // Delays for transitioning-in of intermediate screens + .screen:not(.dialog-initial) { + .tiles-theme-section, + .multi-select-container + { + transition-delay: 0.2s; + } + + .primary, + .secondary, + .secondary-cta, + .cta-link { + transition-delay: 0.4s; + } + } + + .screen-2 { + .section-main { + z-index: 1; + margin: auto; + } + + // Position of noodles on third screen + .outline-L { + width: 87px; + height: 80px; + transform: rotate(250deg) translate(-420%, 425%); + transition-delay: 0.2s; + z-index: 2; + } + + .orange-L { + height: 800px; + width: 660px; + transform: rotate(35deg) translate(-10%, -7%); + transition-delay: -0.4s; + } + + .purple-C { + width: 392px; + height: 394px; + transform: rotate(260deg) translate(-34%, -35%); + transition-delay: -0.2s; + fill: #952BB9; + } + + .yellow-circle { + width: 165px; + height: 165px; + border-radius: 50%; + transform: translate(160%, 130%); + background: #E7258C; + } + } + + &.transition-in { + .noodle { + opacity: 0; + rotate: var(--rotate); + scale: var(--scale); + } + + .dialog-initial { + .main-content, + .dismiss-button { + translate: 0 calc(-2 * var(--translate)); + } + + .brand-logo, + .steps { + opacity: 0; + translate: 0 calc(-1 * var(--translate)); + } + } + + .screen { + .welcome-text, + .multi-select-container, + .tiles-theme-section, + .primary, + .checkbox-container:not(.multi-select-item), + .secondary, + .secondary-cta:not(.top), + .cta-link, + migration-wizard { + opacity: 0; + translate: 0 calc(-1 * var(--translate)); + } + + &:not(.dialog-initial) { + .steps:not(.progress-bar) { + opacity: 0.2; + } + } + } + } + + &.transition-out { + .noodle { + opacity: 0; + rotate: var(--rotate); + scale: var(--scale); + transition-delay: 0.2s; + } + + .screen:not(.dialog-last) { + .main-content { + overflow: hidden; + } + + .welcome-text, + .multi-select-container { + opacity: 0; + translate: 0 var(--translate); + transition-delay: 0.1s; + } + + // content that is nested between inner main content and navigation CTAs + // requires an additional 0.1s transition to avoid overlap + .tiles-theme-section, + migration-wizard { + opacity: 0; + translate: 0 var(--translate); + transition-delay: 0.2s; + } + + .primary, + .checkbox-container:not(.multi-select-item), + .secondary, + .secondary-cta:not(.top), + .cta-link { + opacity: 0; + translate: 0 var(--translate); + transition-delay: 0.3s; + } + + .steps:not(.progress-bar) { + opacity: 0.2; + transition-delay: 0.5s; + } + } + + .dialog-last { + .noodle { + transition-delay: 0s; + } + + .main-content, + .dismiss-button { + opacity: 0; + translate: 0 calc(2 * var(--translate)); + transition-delay: 0.4s; + } + } + } + + migration-wizard { + width: unset; + transition: var(--transition); + + &::part(buttons) { + margin-top: 24px; + justify-content: flex-start; + } + + &::part(deck) { + font-size: 0.83em; + } + } +} diff --git a/browser/components/aboutwelcome/content-src/components/AdditionalCTA.jsx b/browser/components/aboutwelcome/content-src/components/AdditionalCTA.jsx new file mode 100644 index 0000000000..7685195666 --- /dev/null +++ b/browser/components/aboutwelcome/content-src/components/AdditionalCTA.jsx @@ -0,0 +1,42 @@ +/* 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"; +import { SubmenuButton } from "./SubmenuButton"; + +export const AdditionalCTA = ({ content, handleAction }) => { + let buttonStyle = ""; + const isSplitButton = + content.submenu_button?.attached_to === "additional_button"; + let className = "additional-cta-box"; + if (isSplitButton) { + className += " split-button-container"; + } + + if (!content.additional_button?.style) { + buttonStyle = "primary"; + } else { + buttonStyle = + content.additional_button?.style === "link" + ? "cta-link" + : content.additional_button?.style; + } + + return ( + <div className={className}> + <Localized text={content.additional_button?.label}> + <button + className={`${buttonStyle} additional-cta`} + onClick={handleAction} + value="additional_button" + disabled={content.additional_button?.disabled === true} + /> + </Localized> + {isSplitButton ? ( + <SubmenuButton content={content} handleAction={handleAction} /> + ) : null} + </div> + ); +}; diff --git a/browser/components/aboutwelcome/content-src/components/AddonsPicker.jsx b/browser/components/aboutwelcome/content-src/components/AddonsPicker.jsx new file mode 100644 index 0000000000..10c88008de --- /dev/null +++ b/browser/components/aboutwelcome/content-src/components/AddonsPicker.jsx @@ -0,0 +1,116 @@ +/* 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 } from "react"; +import { AboutWelcomeUtils } from "../lib/aboutwelcome-utils.mjs"; +import { Localized } from "./MSLocalized"; + +export const Loader = () => { + return ( + <button className="primary"> + <div className="loaderContainer"> + <span className="loader" /> + </div> + </button> + ); +}; + +export const InstallButton = props => { + const [installing, setInstalling] = useState(false); + const [installComplete, setInstallComplete] = useState(false); + + let buttonLabel = installComplete ? "Installed" : "Add to Firefox"; + + function onClick(event) { + props.handleAction(event); + // Replace the label with the spinner + setInstalling(true); + + window.AWEnsureAddonInstalled(props.addonId).then(value => { + if (value === "complete") { + // Set the label to "Installed" + setInstallComplete(true); + } + // Whether the addon installs or not, we want to remove the spinner + setInstalling(false); + }); + } + + return ( + <div className="install-button-wrapper"> + {installing ? ( + <Loader /> + ) : ( + <Localized text={buttonLabel}> + <button + id={props.name} + value={props.index} + onClick={onClick} + disabled={installComplete} + className="primary" + /> + </Localized> + )} + </div> + ); +}; + +export const AddonsPicker = props => { + const { content } = props; + + if (!content) { + return null; + } + + function handleAction(event) { + const { message_id } = props; + let { action, source_id } = content.tiles.data[event.currentTarget.value]; + let { type, data } = action; + + if (type === "INSTALL_ADDON_FROM_URL") { + if (!data) { + return; + } + } + + AboutWelcomeUtils.handleUserAction({ type, data }); + AboutWelcomeUtils.sendActionTelemetry(message_id, source_id); + } + + return ( + <div className={"addons-picker-container"}> + {content.tiles.data.map(({ id, name, type, description, icon }, index) => + name ? ( + <div key={id} className="addon-container"> + <div className="rtamo-icon"> + <img + className={`${ + type === "theme" ? "rtamo-theme-icon" : "brand-logo" + }`} + src={icon} + role="presentation" + alt="" + /> + </div> + <div className="addon-details"> + <Localized text={name}> + <div className="addon-title" /> + </Localized> + <Localized text={description}> + <div className="addon-description" /> + </Localized> + </div> + <InstallButton + key={id} + addonId={id} + name={name} + handleAction={handleAction} + index={index} + /> + </div> + ) : null + )} + </div> + ); +}; diff --git a/browser/components/aboutwelcome/content-src/components/CTAParagraph.jsx b/browser/components/aboutwelcome/content-src/components/CTAParagraph.jsx new file mode 100644 index 0000000000..41726626a4 --- /dev/null +++ b/browser/components/aboutwelcome/content-src/components/CTAParagraph.jsx @@ -0,0 +1,45 @@ +/* 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 CTAParagraph = props => { + const { content, handleAction } = props; + + if (!content?.text) { + return null; + } + + return ( + <h2 className="cta-paragraph"> + <Localized text={content.text}> + {content.text.string_name && typeof handleAction === "function" ? ( + <span + data-l10n-id={content.text.string_id} + onClick={handleAction} + onKeyUp={event => + ["Enter", " "].includes(event.key) ? handleAction(event) : null + } + value="cta_paragraph" + role="button" + tabIndex="0" + > + {" "} + {/* <a> is valid here because of click and keyup handling. */} + {/* <button> cannot be used due to fluent integration. <a> content is provided by fluent */} + {/* eslint-disable jsx-a11y/anchor-is-valid */} + <a + role="button" + tabIndex="0" + data-l10n-name={content.text.string_name} + > + {" "} + </a> + </span> + ) : null} + </Localized> + </h2> + ); +}; diff --git a/browser/components/aboutwelcome/content-src/components/EmbeddedMigrationWizard.jsx b/browser/components/aboutwelcome/content-src/components/EmbeddedMigrationWizard.jsx new file mode 100644 index 0000000000..2fff85abd9 --- /dev/null +++ b/browser/components/aboutwelcome/content-src/components/EmbeddedMigrationWizard.jsx @@ -0,0 +1,40 @@ +/* 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, useRef } from "react"; + +export const EmbeddedMigrationWizard = ({ handleAction }) => { + const ref = useRef(); + useEffect(() => { + const handleBeginMigration = () => { + handleAction({ + currentTarget: { value: "migrate_start" }, + source: "primary_button", + }); + }; + const handleClose = () => { + handleAction({ currentTarget: { value: "migrate_close" } }); + }; + const { current } = ref; + current?.addEventListener( + "MigrationWizard:BeginMigration", + handleBeginMigration + ); + current?.addEventListener("MigrationWizard:Close", handleClose); + return () => { + current?.removeEventListener( + "MigrationWizard:BeginMigration", + handleBeginMigration + ); + current?.removeEventListener("MigrationWizard:Close", handleClose); + }; + }, []); // eslint-disable-line react-hooks/exhaustive-deps + return ( + <migration-wizard + force-show-import-all="false" + auto-request-state="" + ref={ref} + ></migration-wizard> + ); +}; diff --git a/browser/components/aboutwelcome/content-src/components/HelpText.jsx b/browser/components/aboutwelcome/content-src/components/HelpText.jsx new file mode 100644 index 0000000000..f7cb91df24 --- /dev/null +++ b/browser/components/aboutwelcome/content-src/components/HelpText.jsx @@ -0,0 +1,54 @@ +/* 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"; +import { AboutWelcomeUtils } from "../lib/aboutwelcome-utils.mjs"; +const MS_STRING_PROP = "string_id"; + +export const HelpText = props => { + if (!props.text) { + return null; + } + + if (props.hasImg) { + if (typeof props.text === "object" && props.text[MS_STRING_PROP]) { + return ( + <Localized text={props.text}> + <p className={`helptext ${props.position}`}> + <img + data-l10n-name="help-img" + className={`helptext-img ${props.position}`} + src={props.hasImg.src} + loading={AboutWelcomeUtils.getLoadingStrategyFor( + props.hasImg.src + )} + alt="" + ></img> + </p> + </Localized> + ); + } else if (typeof props.text === "string") { + // Add the img at the end of the props.text + return ( + <p className={`helptext ${props.position}`}> + {props.text} + <img + className={`helptext-img ${props.position} end`} + src={props.hasImg.src} + loading={AboutWelcomeUtils.getLoadingStrategyFor(props.hasImg.src)} + alt="" + /> + </p> + ); + } + } else { + return ( + <Localized text={props.text}> + <p className={`helptext ${props.position}`} /> + </Localized> + ); + } + return null; +}; diff --git a/browser/components/aboutwelcome/content-src/components/HeroImage.jsx b/browser/components/aboutwelcome/content-src/components/HeroImage.jsx new file mode 100644 index 0000000000..9ca89179fa --- /dev/null +++ b/browser/components/aboutwelcome/content-src/components/HeroImage.jsx @@ -0,0 +1,26 @@ +/* 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 } from "../lib/aboutwelcome-utils.mjs"; + +export const HeroImage = props => { + const { height, url, alt } = props; + + if (!url) { + return null; + } + + return ( + <div className="hero-image"> + <img + style={height ? { height } : null} + src={url} + loading={AboutWelcomeUtils.getLoadingStrategyFor(url)} + alt={alt || ""} + role={alt ? null : "presentation"} + /> + </div> + ); +}; diff --git a/browser/components/aboutwelcome/content-src/components/LanguageSwitcher.jsx b/browser/components/aboutwelcome/content-src/components/LanguageSwitcher.jsx new file mode 100644 index 0000000000..b5ebc69909 --- /dev/null +++ b/browser/components/aboutwelcome/content-src/components/LanguageSwitcher.jsx @@ -0,0 +1,308 @@ +/* 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 } from "react"; +import { Localized } from "./MSLocalized"; +import { AboutWelcomeUtils } from "../lib/aboutwelcome-utils.mjs"; + +/** + * The language switcher implements a hook that should be placed at a higher level + * than the actual language switcher component, as it needs to preemptively fetch + * and install langpacks for the user if there is a language mismatch screen. + */ +export function useLanguageSwitcher( + appAndSystemLocaleInfo, + screens, + screenIndex, + setScreenIndex +) { + const languageMismatchScreenIndex = screens.findIndex( + ({ id }) => id === "AW_LANGUAGE_MISMATCH" + ); + const screen = screens[languageMismatchScreenIndex]; + + // Ensure fluent messages have the negotiatedLanguage args set, as they are rendered + // before the negotiatedLanguage is known. If the arg isn't present then Firefox will + // crash in development mode. + useEffect(() => { + if (screen?.content?.languageSwitcher) { + for (const text of Object.values(screen.content.languageSwitcher)) { + if (text?.args && text.args.negotiatedLanguage === undefined) { + text.args.negotiatedLanguage = ""; + } + } + } + }, [screen]); + + // If there is a mismatch, then Firefox can negotiate a better langpack to offer + // the user. + const [negotiatedLanguage, setNegotiatedLanguage] = useState(null); + useEffect( + function getNegotiatedLanguage() { + if (!appAndSystemLocaleInfo) { + return; + } + if (appAndSystemLocaleInfo.matchType !== "language-mismatch") { + // There is no language mismatch, so there is no need to negotiate a langpack. + return; + } + + (async () => { + const { langPack, langPackDisplayName } = + await window.AWNegotiateLangPackForLanguageMismatch( + appAndSystemLocaleInfo + ); + if (langPack) { + setNegotiatedLanguage({ + langPackDisplayName, + appDisplayName: appAndSystemLocaleInfo.displayNames.appLanguage, + langPack, + requestSystemLocales: [ + langPack.target_locale, + appAndSystemLocaleInfo.appLocaleRaw, + ], + originalAppLocales: [appAndSystemLocaleInfo.appLocaleRaw], + }); + } else { + setNegotiatedLanguage({ + langPackDisplayName: null, + appDisplayName: null, + langPack: null, + requestSystemLocales: null, + }); + } + })(); + }, + [appAndSystemLocaleInfo] + ); + + /** + * @type { + * "before-installation" + * | "installing" + * | "installed" + * | "installation-error" + * | "none-available" + * } + */ + const [langPackInstallPhase, setLangPackInstallPhase] = useState( + "before-installation" + ); + useEffect( + function ensureLangPackInstalled() { + if (!negotiatedLanguage) { + // There are no negotiated languages to download yet. + return; + } + setLangPackInstallPhase("installing"); + window + .AWEnsureLangPackInstalled(negotiatedLanguage, screen?.content) + .then( + content => { + // Update screen content with strings that might have changed. + screen.content = content; + setLangPackInstallPhase("installed"); + }, + error => { + console.error(error); + setLangPackInstallPhase("installation-error"); + } + ); + }, + [negotiatedLanguage, screen] + ); + + const [languageFilteredScreens, setLanguageFilteredScreens] = + useState(screens); + useEffect( + function filterScreen() { + // Remove the language screen if it exists (already removed for no live + // reload) and we either don't-need-to or can't switch. + if ( + screen && + (appAndSystemLocaleInfo?.matchType !== "language-mismatch" || + negotiatedLanguage?.langPack === null) + ) { + if (screenIndex > languageMismatchScreenIndex) { + setScreenIndex(screenIndex - 1); + } + setLanguageFilteredScreens( + screens.filter(s => s.id !== "AW_LANGUAGE_MISMATCH") + ); + } else { + setLanguageFilteredScreens(screens); + } + }, + // Removing screenIndex as a dependency as it's causing infinite re-renders (1873019) + // eslint-disable-next-line react-hooks/exhaustive-deps + [ + appAndSystemLocaleInfo?.matchType, + languageMismatchScreenIndex, + negotiatedLanguage, + screen, + screens, + setScreenIndex, + ] + ); + + return { + negotiatedLanguage, + langPackInstallPhase, + languageFilteredScreens, + }; +} + +/** + * The language switcher is a separate component as it needs to perform some asynchronous + * network actions such as retrieving the list of langpacks available, and downloading + * a new langpack. On a fast connection, this won't be noticeable, but on slow or unreliable + * internet this may fail for a user. + */ +export function LanguageSwitcher(props) { + const { + content, + handleAction, + negotiatedLanguage, + langPackInstallPhase, + messageId, + } = props; + + const [isAwaitingLangpack, setIsAwaitingLangpack] = useState(false); + + // Determine the status of the langpack installation. + useEffect(() => { + if (isAwaitingLangpack && langPackInstallPhase !== "installing") { + window.AWSetRequestedLocales(negotiatedLanguage.requestSystemLocales); + requestAnimationFrame(() => { + handleAction( + // Simulate the click event. + { currentTarget: { value: "download_complete" } } + ); + }); + } + }, [ + handleAction, + isAwaitingLangpack, + langPackInstallPhase, + negotiatedLanguage?.requestSystemLocales, + ]); + + let showWaitingScreen = false; + let showPreloadingScreen = false; + let showReadyScreen = false; + + if (isAwaitingLangpack && langPackInstallPhase !== "installed") { + showWaitingScreen = true; + } else if (langPackInstallPhase === "before-installation") { + showPreloadingScreen = true; + } else { + showReadyScreen = true; + } + + // Use {display: "none"} rather than if statements to prevent layout thrashing with + // the localized text elements rendering as blank, then filling in the text. + return ( + <div className="action-buttons language-switcher-container"> + {/* Pre-loading screen */} + <div style={{ display: showPreloadingScreen ? "block" : "none" }}> + <button + className="primary" + value="primary_button" + disabled={true} + type="button" + > + <img + className="language-loader" + src="chrome://browser/skin/tabbrowser/tab-connecting.png" + alt="" + /> + <Localized text={content.languageSwitcher.waiting} /> + </button> + <div className="secondary-cta"> + <Localized text={content.languageSwitcher.skip}> + <button + value="decline_waiting" + type="button" + className="secondary text-link arrow-icon" + onClick={handleAction} + /> + </Localized> + </div> + </div> + {/* Waiting to download the language screen. */} + <div style={{ display: showWaitingScreen ? "block" : "none" }}> + <button + className="primary" + value="primary_button" + disabled={true} + type="button" + > + <img + className="language-loader" + src="chrome://browser/skin/tabbrowser/tab-connecting.png" + alt="" + /> + <Localized text={content.languageSwitcher.downloading} /> + </button> + <div className="secondary-cta"> + <Localized text={content.languageSwitcher.cancel}> + <button + type="button" + className="secondary text-link" + onClick={() => { + setIsAwaitingLangpack(false); + handleAction({ + currentTarget: { value: "cancel_waiting" }, + }); + }} + /> + </Localized> + </div> + </div> + {/* The typical ready screen. */} + <div style={{ display: showReadyScreen ? "block" : "none" }}> + <div> + <button + className="primary" + value="primary_button" + onClick={() => { + AboutWelcomeUtils.sendActionTelemetry( + messageId, + "download_langpack" + ); + setIsAwaitingLangpack(true); + }} + > + {content.languageSwitcher.switch ? ( + <Localized text={content.languageSwitcher.switch} /> + ) : ( + // This is the localized name from the Intl.DisplayNames API. + negotiatedLanguage?.langPackDisplayName + )} + </button> + </div> + <div> + <button + type="button" + className="primary" + value="decline" + onClick={event => { + window.AWSetRequestedLocales( + negotiatedLanguage.originalAppLocales + ); + handleAction(event); + }} + > + {content.languageSwitcher.continue ? ( + <Localized text={content.languageSwitcher.continue} /> + ) : ( + // This is the localized name from the Intl.DisplayNames API. + negotiatedLanguage?.appDisplayName + )} + </button> + </div> + </div> + </div> + ); +} diff --git a/browser/components/aboutwelcome/content-src/components/LinkParagraph.jsx b/browser/components/aboutwelcome/content-src/components/LinkParagraph.jsx new file mode 100644 index 0000000000..14de368b2a --- /dev/null +++ b/browser/components/aboutwelcome/content-src/components/LinkParagraph.jsx @@ -0,0 +1,59 @@ +/* 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, { useCallback } from "react"; +import { Localized } from "./MSLocalized"; + +export const LinkParagraph = props => { + const { text_content, handleAction } = props; + + const handleParagraphAction = useCallback( + event => { + if (event.target.closest("a")) { + handleAction({ ...event, currentTarget: event.target }); + } + }, + [handleAction] + ); + + const onKeyPress = useCallback( + event => { + if (event.key === "Enter" && !event.repeat) { + handleParagraphAction(event); + } + }, + [handleParagraphAction] + ); + + return ( + <Localized text={text_content.text}> + {/* eslint-disable jsx-a11y/no-noninteractive-element-interactions */} + <p + className={ + text_content.font_styles === "legal" + ? "legal-paragraph" + : "link-paragraph" + } + onClick={handleParagraphAction} + value="link_paragraph" + onKeyPress={onKeyPress} + > + {/* eslint-disable jsx-a11y/anchor-is-valid */} + {text_content.link_keys?.map(link => ( + <a + key={link} + value={link} + role="link" + className="text-link" + data-l10n-name={link} + // must pass in tabIndex when no href is provided + tabIndex="0" + > + {" "} + </a> + ))} + </p> + </Localized> + ); +}; diff --git a/browser/components/aboutwelcome/content-src/components/MRColorways.jsx b/browser/components/aboutwelcome/content-src/components/MRColorways.jsx new file mode 100644 index 0000000000..758e6ddc4a --- /dev/null +++ b/browser/components/aboutwelcome/content-src/components/MRColorways.jsx @@ -0,0 +1,200 @@ +/* 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 } from "react"; +import { Localized } from "./MSLocalized"; + +export const ColorwayDescription = props => { + const { colorway } = props; + if (!colorway) { + return null; + } + const { label, description } = colorway; + return ( + <Localized text={description}> + <div + className="colorway-text" + data-l10n-args={JSON.stringify({ + colorwayName: label, + })} + /> + </Localized> + ); +}; + +// Return colorway as "default" for default theme variations Automatic, Light, Dark, +// Alpenglow theme and legacy colorways which is not supported in Colorway picker. +// For themes other then default, theme names exist in +// format colorway-variationId inside LIGHT_WEIGHT_THEMES in AboutWelcomeParent +export function computeColorWay(themeName, systemVariations) { + return !themeName || + themeName === "alpenglow" || + systemVariations.includes(themeName) + ? "default" + : themeName.split("-")[0]; +} + +// Set variationIndex based off activetheme value e.g. 'light', 'expressionist-soft' +export function computeVariationIndex( + themeName, + systemVariations, + variations, + defaultVariationIndex +) { + // Check if themeName is in systemVariations, if yes choose variationIndex by themeName + let index = systemVariations.findIndex(theme => theme === themeName); + if (index >= 0) { + return index; + } + + // If themeName is one of the colorways, select variation index from colorways + let variation = themeName?.split("-")[1]; + index = variations.findIndex(element => element === variation); + if (index >= 0) { + return index; + } + return defaultVariationIndex; +} + +export function Colorways(props) { + let { + colorways, + darkVariation, + defaultVariationIndex, + systemVariations, + variations, + } = props.content.tiles; + let hasReverted = false; + + // Active theme id from JSON e.g. "expressionist" + const activeId = computeColorWay(props.activeTheme, systemVariations); + const [colorwayId, setState] = useState(activeId); + const [variationIndex, setVariationIndex] = useState(defaultVariationIndex); + + function revertToDefaultTheme() { + if (hasReverted) { + return; + } + + // Spoofing an event with current target value of "navigate_away" + // helps the handleAction method to read the colorways theme as "revert" + // which causes the initial theme to be activated. + // The "navigate_away" action is set in content in the colorways screen JSON config. + // Any value in the JSON for theme will work, provided it is not `<event>`. + const event = { + currentTarget: { + value: "navigate_away", + }, + }; + props.handleAction(event); + hasReverted = true; + } + + // Revert to default theme if the user navigates away from the page or spotlight modal + // before clicking on the primary button to officially set theme. + useEffect(() => { + addEventListener("beforeunload", revertToDefaultTheme); + addEventListener("pagehide", revertToDefaultTheme); + + return () => { + removeEventListener("beforeunload", revertToDefaultTheme); + removeEventListener("pagehide", revertToDefaultTheme); + }; + }); + // Update state any time activeTheme changes. + useEffect(() => { + setState(computeColorWay(props.activeTheme, systemVariations)); + setVariationIndex( + computeVariationIndex( + props.activeTheme, + systemVariations, + variations, + defaultVariationIndex + ) + ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [props.activeTheme]); + + //select a random colorway + useEffect(() => { + //We don't want the default theme to be selected + const randomIndex = Math.floor(Math.random() * (colorways.length - 1)) + 1; + const randomColorwayId = colorways[randomIndex].id; + + // Change the variation to be the dark variation if configured and dark. + // Additional colorway changes will remain dark while system is unchanged. + if ( + darkVariation !== undefined && + window.matchMedia("(prefers-color-scheme: dark)").matches + ) { + variations[variationIndex] = variations[darkVariation]; + } + const value = `${randomColorwayId}-${variations[variationIndex]}`; + props.handleAction({ currentTarget: { value } }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + <div className="tiles-theme-container"> + <div> + <fieldset className="tiles-theme-section"> + <Localized text={props.content.subtitle}> + <legend className="sr-only" /> + </Localized> + {colorways.map(({ id, label, tooltip }) => ( + <Localized + key={id + label} + text={typeof tooltip === "object" ? tooltip : {}} + > + <label + className="theme" + title={label} + data-l10n-args={JSON.stringify({ + colorwayName: label, + })} + > + <Localized text={typeof tooltip === "object" ? tooltip : {}}> + <span + className="sr-only colorway label" + id={`${id}-label`} + data-l10n-args={JSON.stringify({ + colorwayName: tooltip, + })} + /> + </Localized> + <Localized text={typeof label === "object" ? label : {}}> + <input + type="radio" + data-colorway={id} + name="theme" + value={ + id === "default" + ? systemVariations[variationIndex] + : `${id}-${variations[variationIndex]}` + } + checked={colorwayId === id} + className="sr-only input" + onClick={props.handleAction} + data-l10n-args={JSON.stringify({ + colorwayName: label, + })} + aria-labelledby={`${id}-label`} + /> + </Localized> + <div + className={`icon colorway ${ + colorwayId === id ? "selected" : "" + } ${id}`} + /> + </label> + </Localized> + ))} + </fieldset> + </div> + <ColorwayDescription + colorway={colorways.find(colorway => colorway.id === activeId)} + /> + </div> + ); +} diff --git a/browser/components/aboutwelcome/content-src/components/MSLocalized.jsx b/browser/components/aboutwelcome/content-src/components/MSLocalized.jsx new file mode 100644 index 0000000000..42fb071475 --- /dev/null +++ b/browser/components/aboutwelcome/content-src/components/MSLocalized.jsx @@ -0,0 +1,114 @@ +/* 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"; +export const CONFIGURABLE_STYLES = [ + "color", + "fontSize", + "fontWeight", + "letterSpacing", + "lineHeight", + "marginBlock", + "marginInline", + "paddingBlock", + "paddingInline", + "whiteSpace", +]; +const ZAP_SIZE_THRESHOLD = 160; + +/** + * 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. + * Allows configuring of some styles including zap underline and color. + * + * 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> + * <Localized text={{raw: "Welcome"}}><h1 /></Localized> + * output: + * <h1>Welcome</h1> + */ + +export const Localized = ({ text, children }) => { + // Dynamically determine the size of the zap style. + const zapRef = React.createRef(); + useEffect(() => { + const { current } = zapRef; + if (current) { + requestAnimationFrame(() => + current?.classList.replace( + "short", + current.getBoundingClientRect().width > ZAP_SIZE_THRESHOLD + ? "long" + : "short" + ) + ); + } + }); + + // Skip rendering of children with no text. + if (!text) { + return null; + } + + // Allow augmenting existing child container properties. + const props = { children: [], className: "", style: {}, ...children?.props }; + // Support nested Localized by starting with their children. + const textNodes = Array.isArray(props.children) + ? props.children + : [props.children]; + + // Pick desired fluent or raw/plain text to render. + if (text.string_id) { + // Set the key so React knows not to reuse when switching to plain text. + props.key = text.string_id; + props["data-l10n-id"] = text.string_id; + if (text.args) { + props["data-l10n-args"] = JSON.stringify(text.args); + } + } else if (text.raw) { + textNodes.push(text.raw); + } else if (typeof text === "string") { + textNodes.push(text); + } + + // Add zap style and content in a way that allows fluent to insert too. + if (text.zap) { + props.className += " welcomeZap"; + textNodes.push( + <span className="short zap" data-l10n-name="zap" ref={zapRef}> + {text.zap} + </span> + ); + } + + if (text.aria_label) { + props["aria-label"] = text.aria_label; + } + + // Apply certain configurable styles. + CONFIGURABLE_STYLES.forEach(style => { + if (text[style] !== undefined) { + props.style[style] = text[style]; + } + }); + + return React.cloneElement( + // Provide a default container for the text if necessary. + children ?? <span />, + props, + // Conditionally pass in as void elements can't accept empty array. + textNodes.length ? textNodes : null + ); +}; diff --git a/browser/components/aboutwelcome/content-src/components/MobileDownloads.jsx b/browser/components/aboutwelcome/content-src/components/MobileDownloads.jsx new file mode 100644 index 0000000000..fbd0940805 --- /dev/null +++ b/browser/components/aboutwelcome/content-src/components/MobileDownloads.jsx @@ -0,0 +1,73 @@ +/* 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"; +import { AboutWelcomeUtils } from "../lib/aboutwelcome-utils.mjs"; + +export const MarketplaceButtons = props => { + return ( + <ul className="mobile-download-buttons"> + {props.buttons.includes("ios") ? ( + <li className="ios"> + <button + data-l10n-id={"spotlight-ios-marketplace-button"} + value="ios" + onClick={props.handleAction} + ></button> + </li> + ) : null} + {props.buttons.includes("android") ? ( + <li className="android"> + <button + data-l10n-id={"spotlight-android-marketplace-button"} + value="android" + onClick={props.handleAction} + ></button> + </li> + ) : null} + </ul> + ); +}; + +export const MobileDownloads = props => { + const { QR_code: QRCode } = props.data; + const showEmailLink = + props.data.email && window.AWSendToDeviceEmailsSupported(); + + return ( + <div className="mobile-downloads"> + {/* Avoid use of Localized element to set alt text here as a plain string value + results in a React error due to "dangerouslySetInnerHTML" */} + {QRCode ? ( + <img + data-l10n-id={ + QRCode.alt_text.string_id ? QRCode.alt_text.string_id : null + } + className="qr-code-image" + alt={typeof QRCode.alt_text === "string" ? QRCode.alt_text : ""} + src={QRCode.image_url} + loading={AboutWelcomeUtils.getLoadingStrategyFor(QRCode.image_url)} + /> + ) : null} + {showEmailLink ? ( + <div> + <Localized text={props.data.email.link_text}> + <button + className="email-link" + value="email_link" + onClick={props.handleAction} + /> + </Localized> + </div> + ) : null} + {props.data.marketplace_buttons ? ( + <MarketplaceButtons + buttons={props.data.marketplace_buttons} + handleAction={props.handleAction} + /> + ) : null} + </div> + ); +}; diff --git a/browser/components/aboutwelcome/content-src/components/MultiSelect.jsx b/browser/components/aboutwelcome/content-src/components/MultiSelect.jsx new file mode 100644 index 0000000000..f65665a7b2 --- /dev/null +++ b/browser/components/aboutwelcome/content-src/components/MultiSelect.jsx @@ -0,0 +1,158 @@ +/* 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, useCallback, useMemo, useRef } from "react"; +import { Localized, CONFIGURABLE_STYLES } from "./MSLocalized"; + +const MULTI_SELECT_STYLES = [ + ...CONFIGURABLE_STYLES, + "flexDirection", + "flexWrap", + "flexFlow", + "flexGrow", + "flexShrink", + "justifyContent", + "alignItems", + "gap", +]; + +const MULTI_SELECT_ICON_STYLES = [ + ...CONFIGURABLE_STYLES, + "width", + "height", + "background", + "backgroundColor", + "backgroundImage", + "backgroundSize", + "backgroundPosition", + "backgroundRepeat", + "backgroundOrigin", + "backgroundClip", + "border", + "borderRadius", + "appearance", + "fill", + "stroke", + "outline", + "outlineOffset", + "boxShadow", +]; + +function getValidStyle(style, validStyles, allowVars) { + if (!style) { + return null; + } + return Object.keys(style) + .filter( + key => validStyles.includes(key) || (allowVars && key.startsWith("--")) + ) + .reduce((obj, key) => { + obj[key] = style[key]; + return obj; + }, {}); +} + +export const MultiSelect = ({ + content, + screenMultiSelects, + setScreenMultiSelects, + activeMultiSelect, + setActiveMultiSelect, +}) => { + const { data } = content.tiles; + + const refs = useRef({}); + + const handleChange = useCallback(() => { + const newActiveMultiSelect = []; + Object.keys(refs.current).forEach(key => { + if (refs.current[key]?.checked) { + newActiveMultiSelect.push(key); + } + }); + setActiveMultiSelect(newActiveMultiSelect); + }, [setActiveMultiSelect]); + + const items = useMemo( + () => { + function getOrderedIds() { + if (screenMultiSelects) { + return screenMultiSelects; + } + let orderedIds = data + .map(item => ({ + id: item.id, + rank: item.randomize ? Math.random() : NaN, + })) + .sort((a, b) => b.rank - a.rank) + .map(({ id }) => id); + setScreenMultiSelects(orderedIds); + return orderedIds; + } + return getOrderedIds().map(id => data.find(item => item.id === id)); + }, + [] // eslint-disable-line react-hooks/exhaustive-deps + ); + + const containerStyle = useMemo( + () => getValidStyle(content.tiles.style, MULTI_SELECT_STYLES, true), + [content.tiles.style] + ); + + // When screen renders for first time, update state + // with checkbox ids that has defaultvalue true + useEffect(() => { + if (!activeMultiSelect) { + let newActiveMultiSelect = []; + items.forEach(({ id, defaultValue }) => { + if (defaultValue && id) { + newActiveMultiSelect.push(id); + } + }); + setActiveMultiSelect(newActiveMultiSelect); + } + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + return ( + <div + className="multi-select-container" + style={containerStyle} + role={ + items.some(({ type, group }) => type === "radio" && group) + ? "radiogroup" + : "group" + } + aria-labelledby="multi-stage-multi-select-label" + > + {content.tiles.label ? ( + <Localized text={content.tiles.label}> + <h2 id="multi-stage-multi-select-label" /> + </Localized> + ) : null} + {items.map(({ id, label, icon, type = "checkbox", group, style }) => ( + <div + key={id + label} + className="checkbox-container multi-select-item" + style={getValidStyle(style, MULTI_SELECT_STYLES)} + > + <input + type={type} // checkbox or radio + id={id} + value={id} + name={group} + checked={activeMultiSelect?.includes(id)} + style={getValidStyle(icon?.style, MULTI_SELECT_ICON_STYLES)} + onChange={handleChange} + ref={el => (refs.current[id] = el)} + /> + {label ? ( + <Localized text={label}> + <label htmlFor={id}></label> + </Localized> + ) : null} + </div> + ))} + </div> + ); +}; diff --git a/browser/components/aboutwelcome/content-src/components/MultiStageAboutWelcome.jsx b/browser/components/aboutwelcome/content-src/components/MultiStageAboutWelcome.jsx new file mode 100644 index 0000000000..034055bf3d --- /dev/null +++ b/browser/components/aboutwelcome/content-src/components/MultiStageAboutWelcome.jsx @@ -0,0 +1,568 @@ +/* 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 { AboutWelcomeUtils } from "../lib/aboutwelcome-utils.mjs"; +import { MultiStageProtonScreen } from "./MultiStageProtonScreen"; +import { useLanguageSwitcher } from "./LanguageSwitcher"; +import { SubmenuButton } from "./SubmenuButton"; +import { BASE_PARAMS, addUtmParams } from "../lib/addUtmParams.mjs"; + +// Amount of milliseconds for all transitions to complete (including delays). +const TRANSITION_OUT_TIME = 1000; +const LANGUAGE_MISMATCH_SCREEN_ID = "AW_LANGUAGE_MISMATCH"; + +export const MultiStageAboutWelcome = props => { + let { defaultScreens } = props; + const didFilter = useRef(false); + const [didMount, setDidMount] = useState(false); + const [screens, setScreens] = useState(defaultScreens); + + const [index, setScreenIndex] = useState(props.startScreen); + const [previousOrder, setPreviousOrder] = useState(props.startScreen - 1); + + useEffect(() => { + (async () => { + // If we want to load index from history state, we don't want to send impression yet + if (!didMount) { + return; + } + // On about:welcome first load, screensVisited should be empty + let screensVisited = didFilter.current ? screens.slice(0, index) : []; + let upcomingScreens = defaultScreens + .filter(s => !screensVisited.find(v => v.id === s.id)) + // Filter out Language Mismatch screen from upcoming + // screens if screens set from useLanguageSwitcher hook + // has filtered language screen + .filter( + upcomingScreen => + !( + !screens.find(s => s.id === LANGUAGE_MISMATCH_SCREEN_ID) && + upcomingScreen.id === LANGUAGE_MISMATCH_SCREEN_ID + ) + ); + + let filteredScreens = screensVisited.concat( + (await window.AWEvaluateScreenTargeting(upcomingScreens)) ?? + upcomingScreens + ); + + // Use existing screen for the filtered screen to carry over any modification + // e.g. if AW_LANGUAGE_MISMATCH exists, use it from existing screens + setScreens( + filteredScreens.map( + filtered => screens.find(s => s.id === filtered.id) ?? filtered + ) + ); + + didFilter.current = true; + + const screenInitials = filteredScreens + .map(({ id }) => id?.split("_")[1]?.[0]) + .join(""); + // Send impression ping when respective screen first renders + filteredScreens.forEach((screen, order) => { + if (index === order) { + const messageId = `${props.message_id}_${order}_${screen.id}_${screenInitials}`; + + AboutWelcomeUtils.sendImpressionTelemetry(messageId, { + screen_family: props.message_id, + screen_index: order, + screen_id: screen.id, + screen_initials: screenInitials, + }); + + window.AWAddScreenImpression?.(screen); + } + }); + + // Remember that a new screen has loaded for browser navigation + if (props.updateHistory && index > window.history.state) { + window.history.pushState(index, ""); + } + + // Remember the previous screen index so we can animate the transition + setPreviousOrder(index); + })(); + }, [index, didMount]); // eslint-disable-line react-hooks/exhaustive-deps + + const [flowParams, setFlowParams] = useState(null); + const { metricsFlowUri } = props; + useEffect(() => { + (async () => { + if (metricsFlowUri) { + setFlowParams(await AboutWelcomeUtils.fetchFlowParams(metricsFlowUri)); + } + })(); + }, [metricsFlowUri]); + + // Allow "in" style to render to actually transition towards regular state, + // which also makes using browser back/forward navigation skip transitions. + const [transition, setTransition] = useState(props.transitions ? "in" : ""); + useEffect(() => { + if (transition === "in") { + requestAnimationFrame(() => + requestAnimationFrame(() => setTransition("")) + ); + } + }, [transition]); + + // Transition to next screen, opening about:home on last screen button CTA + const handleTransition = () => { + // Only handle transitioning out from a screen once. + if (transition === "out") { + return; + } + + // Start transitioning things "out" immediately when moving forwards. + setTransition(props.transitions ? "out" : ""); + + // Actually move forwards after all transitions finish. + setTimeout( + () => { + if (index < screens.length - 1) { + setTransition(props.transitions ? "in" : ""); + setScreenIndex(prevState => prevState + 1); + } else { + window.AWFinish(); + } + }, + props.transitions ? TRANSITION_OUT_TIME : 0 + ); + }; + + useEffect(() => { + // When about:welcome loads (on refresh or pressing back button + // from about:home), ensure history state usEffect runs before + // useEffect hook that send impression telemetry + setDidMount(true); + + if (props.updateHistory) { + // Switch to the screen tracked in state (null for initial state) + // or last screen index if a user navigates by pressing back + // button from about:home + const handler = ({ state }) => { + if (transition === "out") { + return; + } + setTransition(props.transitions ? "out" : ""); + setTimeout( + () => { + setTransition(props.transitions ? "in" : ""); + setScreenIndex(Math.min(state, screens.length - 1)); + }, + props.transitions ? TRANSITION_OUT_TIME : 0 + ); + }; + + // Handle page load, e.g., going back to about:welcome from about:home + const { state } = window.history; + if (state) { + setScreenIndex(Math.min(state, screens.length - 1)); + setPreviousOrder(Math.min(state, screens.length - 1)); + } + + // Watch for browser back/forward button navigation events + window.addEventListener("popstate", handler); + return () => window.removeEventListener("popstate", handler); + } + return false; + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + const [multiSelects, setMultiSelects] = useState({}); + + // Save the active multi select state for each screen as an object keyed by + // screen id. Each screen id has an array containing checkbox ids used in + // handleAction to update MULTI_ACTION data. This allows us to remember the + // state of each screen's multi select checkboxes when navigating back and + // forth between screens, while also allowing a message to have more than one + // multi select screen. + const [activeMultiSelects, setActiveMultiSelects] = useState({}); + + // 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 { negotiatedLanguage, langPackInstallPhase, languageFilteredScreens } = + useLanguageSwitcher( + props.appAndSystemLocaleInfo, + screens, + index, + setScreenIndex + ); + + useEffect(() => { + setScreens(languageFilteredScreens); + }, [languageFilteredScreens]); + + return ( + <React.Fragment> + <div + className={`outer-wrapper onboardingContainer proton transition-${transition}`} + style={props.backdrop ? { background: props.backdrop } : {}} + > + {screens.map((screen, order) => { + const isFirstScreen = screen === screens[0]; + const isLastScreen = screen === screens[screens.length - 1]; + const totalNumberOfScreens = screens.length; + const isSingleScreen = totalNumberOfScreens === 1; + + const setActiveMultiSelect = valueOrFn => + setActiveMultiSelects(prevState => ({ + ...prevState, + [screen.id]: + typeof valueOrFn === "function" + ? valueOrFn(prevState[screen.id]) + : valueOrFn, + })); + const setScreenMultiSelects = valueOrFn => + setMultiSelects(prevState => ({ + ...prevState, + [screen.id]: + typeof valueOrFn === "function" + ? valueOrFn(prevState[screen.id]) + : valueOrFn, + })); + + return index === order ? ( + <WelcomeScreen + key={screen.id + order} + id={screen.id} + totalNumberOfScreens={totalNumberOfScreens} + isFirstScreen={isFirstScreen} + isLastScreen={isLastScreen} + isSingleScreen={isSingleScreen} + order={order} + previousOrder={previousOrder} + content={screen.content} + navigate={handleTransition} + messageId={`${props.message_id}_${order}_${screen.id}`} + UTMTerm={props.utm_term} + flowParams={flowParams} + activeTheme={activeTheme} + initialTheme={initialTheme} + setActiveTheme={setActiveTheme} + setInitialTheme={setInitialTheme} + screenMultiSelects={multiSelects[screen.id]} + setScreenMultiSelects={setScreenMultiSelects} + activeMultiSelect={activeMultiSelects[screen.id]} + setActiveMultiSelect={setActiveMultiSelect} + autoAdvance={screen.auto_advance} + negotiatedLanguage={negotiatedLanguage} + langPackInstallPhase={langPackInstallPhase} + forceHideStepsIndicator={screen.force_hide_steps_indicator} + ariaRole={props.ariaRole} + aboveButtonStepsIndicator={screen.above_button_steps_indicator} + /> + ) : null; + })} + </div> + </React.Fragment> + ); +}; + +export const SecondaryCTA = props => { + const targetElement = props.position + ? `secondary_button_${props.position}` + : `secondary_button`; + let buttonStyling = props.content.secondary_button?.has_arrow_icon + ? `secondary arrow-icon` + : `secondary`; + const isPrimary = props.content.secondary_button?.style === "primary"; + const isTextLink = + !["split", "callout"].includes(props.content.position) && + props.content.tiles?.type !== "addons-picker" && + !isPrimary; + const isSplitButton = + props.content.submenu_button?.attached_to === targetElement; + let className = "secondary-cta"; + if (props.position) { + className += ` ${props.position}`; + } + if (isSplitButton) { + className += " split-button-container"; + } + const isDisabled = React.useCallback( + disabledValue => + disabledValue === "hasActiveMultiSelect" + ? !(props.activeMultiSelect?.length > 0) + : disabledValue, + [props.activeMultiSelect?.length] + ); + + if (isTextLink) { + buttonStyling += " text-link"; + } + + if (isPrimary) { + buttonStyling = props.content.secondary_button?.has_arrow_icon + ? `primary arrow-icon` + : `primary`; + } + + return ( + <div className={className}> + <Localized text={props.content[targetElement].text}> + <span /> + </Localized> + <Localized text={props.content[targetElement].label}> + <button + className={buttonStyling} + value={targetElement} + disabled={isDisabled(props.content.secondary_button?.disabled)} + onClick={props.handleAction} + /> + </Localized> + {isSplitButton ? ( + <SubmenuButton + content={props.content} + handleAction={props.handleAction} + /> + ) : null} + </div> + ); +}; + +export const StepsIndicator = props => { + let steps = []; + for (let i = 0; i < props.totalNumberOfScreens; i++) { + let className = `${i === props.order ? "current" : ""} ${ + i < props.order ? "complete" : "" + }`; + steps.push( + <div key={i} className={`indicator ${className}`} role="presentation" /> + ); + } + return steps; +}; + +export const ProgressBar = ({ step, previousStep, totalNumberOfScreens }) => { + const [progress, setProgress] = React.useState( + previousStep / totalNumberOfScreens + ); + useEffect(() => { + // We don't need to hook any dependencies because any time the step changes, + // the screen's entire DOM tree will be re-rendered. + setProgress(step / totalNumberOfScreens); + }, []); // eslint-disable-line react-hooks/exhaustive-deps + return ( + <div + className="indicator" + role="presentation" + style={{ "--progress-bar-progress": `${progress * 100}%` }} + /> + ); +}; + +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: `${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, `${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() }; + } + return AboutWelcomeUtils.handleUserAction({ type, data }); + } + + async handleAction(event) { + let { props } = this; + const value = + event.currentTarget.value ?? event.currentTarget.getAttribute("value"); + const source = event.source || value; + let targetContent = + props.content[value] || + props.content.tiles || + props.content.languageSwitcher; + + if (value === "submenu_button" && event.action) { + targetContent = { action: event.action }; + } + + if (!(targetContent && targetContent.action)) { + return; + } + // Send telemetry before waiting on actions + AboutWelcomeUtils.sendActionTelemetry(props.messageId, source, event.name); + + // Send additional telemetry if a messaging surface like feature callout is + // dismissed via the dismiss button. Other causes of dismissal will be + // handled separately by the messaging surface's own code. + if (value === "dismiss_button" && !event.name) { + AboutWelcomeUtils.sendDismissTelemetry(props.messageId, source); + } + + let { action } = targetContent; + action = JSON.parse(JSON.stringify(action)); + + if (action.collectSelect) { + this.setMultiSelectActions(action); + } + + let actionResult; + if (["OPEN_URL", "SHOW_FIREFOX_ACCOUNTS"].includes(action.type)) { + actionResult = await this.handleOpenURL( + action, + props.flowParams, + props.UTMTerm + ); + } else if (action.type) { + actionResult = await AboutWelcomeUtils.handleUserAction(action); + if (action.type === "FXA_SIGNIN_FLOW") { + AboutWelcomeUtils.sendActionTelemetry( + props.messageId, + actionResult ? "sign_in" : "sign_in_cancel", + "FXA_SIGNIN_FLOW" + ); + } + // Wait until migration closes to complete the action + const hasMigrate = a => + a.type === "SHOW_MIGRATION_WIZARD" || + (a.type === "MULTI_ACTION" && a.data?.actions?.some(hasMigrate)); + if (hasMigrate(action)) { + 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 the action has persistActiveTheme: true, we set the initial theme to the currently active theme + // so that it can be reverted to in the event that the user navigates away from the screen + if (action.persistActiveTheme) { + this.props.setInitialTheme(this.props.activeTheme); + } + + // `navigate` and `dismiss` can be true/false/undefined, or they can be a + // string "actionResult" in which case we should use the actionResult + // (boolean resolved by handleUserAction) + const shouldDoBehavior = behavior => + behavior === "actionResult" ? actionResult : behavior; + + if (shouldDoBehavior(action.navigate)) { + props.navigate(); + } + + if (shouldDoBehavior(action.dismiss)) { + window.AWFinish(); + } + } + + setMultiSelectActions(action) { + let { props } = this; + // Populate MULTI_ACTION data actions property with selected checkbox + // actions from tiles data + if (action.type !== "MULTI_ACTION") { + console.error( + "collectSelect is only supported for MULTI_ACTION type actions" + ); + action.type = "MULTI_ACTION"; + } + if (!Array.isArray(action.data?.actions)) { + console.error( + "collectSelect is only supported for MULTI_ACTION type actions with an array of actions" + ); + action.data = { actions: [] }; + } + + // Prepend the multi-select actions to the CTA's actions array, but keep + // the actions in the same order they appear in. This way the CTA action + // can go last, after the multi-select actions are processed. For example, + // 1. checkbox action 1 + // 2. checkbox action 2 + // 3. radio action + // 4. CTA action (which perhaps depends on the radio action) + let multiSelectActions = []; + for (const checkbox of props.content?.tiles?.data ?? []) { + let checkboxAction; + if (props.activeMultiSelect?.includes(checkbox.id)) { + checkboxAction = checkbox.checkedAction ?? checkbox.action; + } else { + checkboxAction = checkbox.uncheckedAction; + } + + if (checkboxAction) { + multiSelectActions.push(checkboxAction); + } + } + action.data.actions.unshift(...multiSelectActions); + + // Send telemetry with selected checkbox ids + AboutWelcomeUtils.sendActionTelemetry( + props.messageId, + props.activeMultiSelect, + "SELECT_CHECKBOX" + ); + } + + render() { + return ( + <MultiStageProtonScreen + content={this.props.content} + id={this.props.id} + order={this.props.order} + previousOrder={this.props.previousOrder} + activeTheme={this.props.activeTheme} + screenMultiSelects={this.props.screenMultiSelects} + setScreenMultiSelects={this.props.setScreenMultiSelects} + activeMultiSelect={this.props.activeMultiSelect} + setActiveMultiSelect={this.props.setActiveMultiSelect} + totalNumberOfScreens={this.props.totalNumberOfScreens} + appAndSystemLocaleInfo={this.props.appAndSystemLocaleInfo} + negotiatedLanguage={this.props.negotiatedLanguage} + langPackInstallPhase={this.props.langPackInstallPhase} + handleAction={this.handleAction} + messageId={this.props.messageId} + isFirstScreen={this.props.isFirstScreen} + isLastScreen={this.props.isLastScreen} + isSingleScreen={this.props.isSingleScreen} + startsWithCorner={this.props.startsWithCorner} + autoAdvance={this.props.autoAdvance} + forceHideStepsIndicator={this.props.forceHideStepsIndicator} + ariaRole={this.props.ariaRole} + aboveButtonStepsIndicator={this.props.aboveButtonStepsIndicator} + /> + ); + } +} diff --git a/browser/components/aboutwelcome/content-src/components/MultiStageProtonScreen.jsx b/browser/components/aboutwelcome/content-src/components/MultiStageProtonScreen.jsx new file mode 100644 index 0000000000..ffe64f05f1 --- /dev/null +++ b/browser/components/aboutwelcome/content-src/components/MultiStageProtonScreen.jsx @@ -0,0 +1,620 @@ +/* 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, useState } from "react"; +import { Localized } from "./MSLocalized"; +import { AboutWelcomeUtils } from "../lib/aboutwelcome-utils.mjs"; +import { MobileDownloads } from "./MobileDownloads"; +import { MultiSelect } from "./MultiSelect"; +import { Themes } from "./Themes"; +import { + SecondaryCTA, + StepsIndicator, + ProgressBar, +} from "./MultiStageAboutWelcome"; +import { LanguageSwitcher } from "./LanguageSwitcher"; +import { CTAParagraph } from "./CTAParagraph"; +import { HeroImage } from "./HeroImage"; +import { OnboardingVideo } from "./OnboardingVideo"; +import { AdditionalCTA } from "./AdditionalCTA"; +import { EmbeddedMigrationWizard } from "./EmbeddedMigrationWizard"; +import { AddonsPicker } from "./AddonsPicker"; +import { LinkParagraph } from "./LinkParagraph"; + +export const MultiStageProtonScreen = props => { + const { autoAdvance, handleAction, order } = props; + useEffect(() => { + if (autoAdvance) { + const timer = setTimeout(() => { + handleAction({ + currentTarget: { + value: autoAdvance, + }, + name: "AUTO_ADVANCE", + }); + }, 20000); + return () => clearTimeout(timer); + } + return () => {}; + }, [autoAdvance, handleAction, order]); + + return ( + <ProtonScreen + content={props.content} + id={props.id} + order={props.order} + activeTheme={props.activeTheme} + screenMultiSelects={props.screenMultiSelects} + setScreenMultiSelects={props.setScreenMultiSelects} + activeMultiSelect={props.activeMultiSelect} + setActiveMultiSelect={props.setActiveMultiSelect} + totalNumberOfScreens={props.totalNumberOfScreens} + handleAction={props.handleAction} + isFirstScreen={props.isFirstScreen} + isLastScreen={props.isLastScreen} + isSingleScreen={props.isSingleScreen} + previousOrder={props.previousOrder} + autoAdvance={props.autoAdvance} + isRtamo={props.isRtamo} + addonName={props.addonName} + isTheme={props.isTheme} + iconURL={props.iconURL} + messageId={props.messageId} + negotiatedLanguage={props.negotiatedLanguage} + langPackInstallPhase={props.langPackInstallPhase} + forceHideStepsIndicator={props.forceHideStepsIndicator} + ariaRole={props.ariaRole} + aboveButtonStepsIndicator={props.aboveButtonStepsIndicator} + /> + ); +}; + +export const ProtonScreenActionButtons = props => { + const { content, addonName, activeMultiSelect } = props; + const defaultValue = content.checkbox?.defaultValue; + + const [isChecked, setIsChecked] = useState(defaultValue || false); + const buttonRef = React.useRef(null); + + const shouldFocusButton = content?.primary_button?.should_focus_button; + + useEffect(() => { + if (shouldFocusButton) { + buttonRef.current?.focus(); + } + }, [shouldFocusButton]); + + if ( + !content.primary_button && + !content.secondary_button && + !content.additional_button + ) { + return null; + } + + // If we have a multi-select screen, we want to disable the primary button + // until the user has selected at least one item. + const isPrimaryDisabled = primaryDisabledValue => + primaryDisabledValue === "hasActiveMultiSelect" + ? !(activeMultiSelect?.length > 0) + : primaryDisabledValue; + + return ( + <div + className={`action-buttons ${ + content.additional_button ? "additional-cta-container" : "" + }`} + flow={content.additional_button?.flow} + alignment={content.additional_button?.alignment} + > + <Localized text={content.primary_button?.label}> + <button + ref={buttonRef} + className={`${content.primary_button?.style ?? "primary"}${ + content.primary_button?.has_arrow_icon ? " arrow-icon" : "" + }`} + // Whether or not the checkbox is checked determines which action + // should be handled. By setting value here, we indicate to + // this.handleAction() where in the content tree it should take + // the action to execute from. + value={isChecked ? "checkbox" : "primary_button"} + disabled={isPrimaryDisabled(content.primary_button?.disabled)} + onClick={props.handleAction} + data-l10n-args={ + addonName + ? JSON.stringify({ + "addon-name": addonName, + }) + : "" + } + /> + </Localized> + {content.additional_button ? ( + <AdditionalCTA content={content} handleAction={props.handleAction} /> + ) : null} + {content.checkbox ? ( + <div className="checkbox-container"> + <input + type="checkbox" + id="action-checkbox" + checked={isChecked} + onChange={() => { + setIsChecked(!isChecked); + }} + ></input> + <Localized text={content.checkbox.label}> + <label htmlFor="action-checkbox"></label> + </Localized> + </div> + ) : null} + {content.secondary_button ? ( + <SecondaryCTA + content={content} + handleAction={props.handleAction} + activeMultiSelect={activeMultiSelect} + /> + ) : null} + </div> + ); +}; + +export class ProtonScreen extends React.PureComponent { + componentDidMount() { + this.mainContentHeader.focus(); + } + + getScreenClassName( + isFirstScreen, + isLastScreen, + includeNoodles, + isVideoOnboarding, + isAddonsPicker + ) { + const screenClass = `screen-${this.props.order % 2 !== 0 ? 1 : 2}`; + + if (isVideoOnboarding) { + return "with-video"; + } + + if (isAddonsPicker) { + return "addons-picker"; + } + + return `${isFirstScreen ? `dialog-initial` : ``} ${ + isLastScreen ? `dialog-last` : `` + } ${includeNoodles ? `with-noodles` : ``} ${screenClass}`; + } + + renderTitle({ title, title_logo }) { + if (title_logo) { + const { alignment, ...rest } = title_logo; + return ( + <div + className="inline-icon-container" + alignment={alignment ?? "center"} + > + {this.renderPicture({ ...rest })} + <Localized text={title}> + <h1 id="mainContentHeader" /> + </Localized> + </div> + ); + } + return ( + <Localized text={title}> + <h1 id="mainContentHeader" /> + </Localized> + ); + } + + renderPicture({ + imageURL = "chrome://branding/content/about-logo.svg", + darkModeImageURL, + reducedMotionImageURL, + darkModeReducedMotionImageURL, + alt = "", + width, + height, + marginBlock, + marginInline, + className = "logo-container", + }) { + function getLoadingStrategy() { + for (let url of [ + imageURL, + darkModeImageURL, + reducedMotionImageURL, + darkModeReducedMotionImageURL, + ]) { + if (AboutWelcomeUtils.getLoadingStrategyFor(url) === "lazy") { + return "lazy"; + } + } + return "eager"; + } + + return ( + <picture className={className} style={{ marginInline, marginBlock }}> + {darkModeReducedMotionImageURL ? ( + <source + srcSet={darkModeReducedMotionImageURL} + media="(prefers-color-scheme: dark) and (prefers-reduced-motion: reduce)" + /> + ) : null} + {darkModeImageURL ? ( + <source + srcSet={darkModeImageURL} + media="(prefers-color-scheme: dark)" + /> + ) : null} + {reducedMotionImageURL ? ( + <source + srcSet={reducedMotionImageURL} + media="(prefers-reduced-motion: reduce)" + /> + ) : null} + <Localized text={alt}> + <div className="sr-only logo-alt" /> + </Localized> + <img + className="brand-logo" + style={{ height, width }} + src={imageURL} + alt="" + loading={getLoadingStrategy()} + role={alt ? null : "presentation"} + /> + </picture> + ); + } + + renderContentTiles() { + const { content } = this.props; + return ( + <React.Fragment> + {content.tiles && + content.tiles.type === "addons-picker" && + content.tiles.data ? ( + <AddonsPicker + content={content} + message_id={this.props.messageId} + handleAction={this.props.handleAction} + /> + ) : null} + {content.tiles && + content.tiles.type === "theme" && + content.tiles.data ? ( + <Themes + content={content} + activeTheme={this.props.activeTheme} + handleAction={this.props.handleAction} + /> + ) : null} + {content.tiles && + content.tiles.type === "mobile_downloads" && + content.tiles.data ? ( + <MobileDownloads + data={content.tiles.data} + handleAction={this.props.handleAction} + /> + ) : null} + {content.tiles && + content.tiles.type === "multiselect" && + content.tiles.data ? ( + <MultiSelect + content={content} + screenMultiSelects={this.props.screenMultiSelects} + setScreenMultiSelects={this.props.setScreenMultiSelects} + activeMultiSelect={this.props.activeMultiSelect} + setActiveMultiSelect={this.props.setActiveMultiSelect} + /> + ) : null} + {content.tiles && content.tiles.type === "migration-wizard" ? ( + <EmbeddedMigrationWizard handleAction={this.props.handleAction} /> + ) : null} + </React.Fragment> + ); + } + + renderNoodles() { + return ( + <React.Fragment> + <div className={"noodle orange-L"} /> + <div className={"noodle purple-C"} /> + <div className={"noodle solid-L"} /> + <div className={"noodle outline-L"} /> + <div className={"noodle yellow-circle"} /> + </React.Fragment> + ); + } + + renderLanguageSwitcher() { + return this.props.content.languageSwitcher ? ( + <LanguageSwitcher + content={this.props.content} + handleAction={this.props.handleAction} + negotiatedLanguage={this.props.negotiatedLanguage} + langPackInstallPhase={this.props.langPackInstallPhase} + messageId={this.props.messageId} + /> + ) : null; + } + + renderDismissButton() { + const { size, marginBlock, marginInline, label } = + this.props.content.dismiss_button; + return ( + <button + className="dismiss-button" + onClick={this.props.handleAction} + value="dismiss_button" + data-l10n-id={label?.string_id || "spotlight-dialog-close-button"} + button-size={size} + style={{ marginBlock, marginInline }} + ></button> + ); + } + + renderStepsIndicator() { + const currentStep = (this.props.order ?? 0) + 1; + const previousStep = (this.props.previousOrder ?? -1) + 1; + const { content, totalNumberOfScreens: total } = this.props; + return ( + <div + id="steps" + className={`steps${content.progress_bar ? " progress-bar" : ""}`} + data-l10n-id={ + content.steps_indicator?.string_id || + "onboarding-welcome-steps-indicator-label" + } + data-l10n-args={JSON.stringify({ + current: currentStep, + total: total ?? 0, + })} + data-l10n-attrs="aria-label" + role="progressbar" + aria-valuenow={currentStep} + aria-valuemin={1} + aria-valuemax={total} + > + {content.progress_bar ? ( + <ProgressBar + step={currentStep} + previousStep={previousStep} + totalNumberOfScreens={total} + /> + ) : ( + <StepsIndicator + order={this.props.order} + totalNumberOfScreens={total} + /> + )} + </div> + ); + } + + renderSecondarySection(content) { + return ( + <div + className={`section-secondary ${ + content.hide_secondary_section ? "with-secondary-section-hidden" : "" + }`} + style={ + content.background + ? { + background: content.background, + "--mr-secondary-background-position-y": + content.split_narrow_bkg_position, + } + : {} + } + > + <Localized text={content.image_alt_text}> + <div className="sr-only image-alt" role="img" /> + </Localized> + {content.hero_image ? ( + <HeroImage url={content.hero_image.url} /> + ) : ( + <React.Fragment> + <div className="message-text"> + <div className="spacer-top" /> + <Localized text={content.hero_text}> + <h1 /> + </Localized> + <div className="spacer-bottom" /> + </div> + </React.Fragment> + )} + </div> + ); + } + + renderOrderedContent(content) { + const elements = []; + for (const item of content) { + switch (item.type) { + case "text": + elements.push( + <LinkParagraph + text_content={item} + handleAction={this.props.handleAction} + /> + ); + break; + case "image": + elements.push( + this.renderPicture({ + imageURL: item.url, + darkModeImageURL: item.darkModeImageURL, + height: item.height, + width: item.width, + alt: item.alt_text, + marginInline: item.marginInline, + className: "inline-image", + }) + ); + } + } + return <>{elements}</>; + } + + render() { + const { + autoAdvance, + content, + isRtamo, + isTheme, + isFirstScreen, + isLastScreen, + isSingleScreen, + forceHideStepsIndicator, + ariaRole, + aboveButtonStepsIndicator, + } = this.props; + const includeNoodles = content.has_noodles; + // The default screen position is "center" + const isCenterPosition = content.position === "center" || !content.position; + const hideStepsIndicator = + autoAdvance || + content?.video_container || + isSingleScreen || + forceHideStepsIndicator; + const textColorClass = content.text_color + ? `${content.text_color}-text` + : ""; + // Assign proton screen style 'screen-1' or 'screen-2' to centered screens + // by checking if screen order is even or odd. + const screenClassName = isCenterPosition + ? this.getScreenClassName( + isFirstScreen, + isLastScreen, + includeNoodles, + content?.video_container, + content.tiles?.type === "addons-picker" + ) + : ""; + const isEmbeddedMigration = content.tiles?.type === "migration-wizard"; + + return ( + <main + className={`screen ${this.props.id || ""} + ${screenClassName} ${textColorClass}`} + role={ariaRole ?? "alertdialog"} + layout={content.layout} + pos={content.position || "center"} + tabIndex="-1" + aria-labelledby="mainContentHeader" + ref={input => { + this.mainContentHeader = input; + }} + > + {isCenterPosition ? null : this.renderSecondarySection(content)} + <div + className={`section-main ${ + isEmbeddedMigration ? "embedded-migration" : "" + }`} + hide-secondary-section={ + content.hide_secondary_section + ? String(content.hide_secondary_section) + : null + } + role="document" + > + {content.secondary_button_top ? ( + <SecondaryCTA + content={content} + handleAction={this.props.handleAction} + position="top" + /> + ) : null} + {includeNoodles ? this.renderNoodles() : null} + {content.dismiss_button ? this.renderDismissButton() : null} + <div + className={`main-content ${hideStepsIndicator ? "no-steps" : ""}`} + style={{ + background: + content.background && isCenterPosition + ? content.background + : null, + width: + content.width && content.position !== "split" + ? content.width + : null, + }} + > + {content.logo ? this.renderPicture(content.logo) : null} + + {isRtamo ? ( + <div className="rtamo-icon"> + <img + className={`${isTheme ? "rtamo-theme-icon" : "brand-logo"}`} + src={this.props.iconURL} + loading={AboutWelcomeUtils.getLoadingStrategyFor( + this.props.iconURL + )} + alt="" + role="presentation" + /> + </div> + ) : null} + + <div className="main-content-inner"> + <div className={`welcome-text ${content.title_style || ""}`}> + {content.title ? this.renderTitle(content) : null} + + {content.subtitle ? ( + <Localized text={content.subtitle}> + <h2 + data-l10n-args={JSON.stringify({ + "addon-name": this.props.addonName, + ...this.props.appAndSystemLocaleInfo?.displayNames, + })} + aria-flowto={ + this.props.messageId?.includes("FEATURE_TOUR") + ? "steps" + : "" + } + /> + </Localized> + ) : null} + {content.cta_paragraph ? ( + <CTAParagraph + content={content.cta_paragraph} + handleAction={this.props.handleAction} + /> + ) : null} + </div> + {content.video_container ? ( + <OnboardingVideo + content={content.video_container} + handleAction={this.props.handleAction} + /> + ) : null} + {content.above_button_content + ? this.renderOrderedContent(content.above_button_content) + : null} + {this.renderContentTiles()} + {this.renderLanguageSwitcher()} + {!hideStepsIndicator && aboveButtonStepsIndicator + ? this.renderStepsIndicator() + : null} + <ProtonScreenActionButtons + content={content} + addonName={this.props.addonName} + handleAction={this.props.handleAction} + activeMultiSelect={this.props.activeMultiSelect} + /> + </div> + {!hideStepsIndicator && !aboveButtonStepsIndicator + ? this.renderStepsIndicator() + : null} + </div> + </div> + <Localized text={content.info_text}> + <span className="info-text" /> + </Localized> + </main> + ); + } +} diff --git a/browser/components/aboutwelcome/content-src/components/OnboardingVideo.jsx b/browser/components/aboutwelcome/content-src/components/OnboardingVideo.jsx new file mode 100644 index 0000000000..629a409a59 --- /dev/null +++ b/browser/components/aboutwelcome/content-src/components/OnboardingVideo.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"; + +export const OnboardingVideo = props => { + const vidUrl = props.content.video_url; + const autoplay = props.content.autoPlay; + + const handleVideoAction = event => { + props.handleAction({ + currentTarget: { + value: event, + }, + }); + }; + + return ( + <div> + <video // eslint-disable-line jsx-a11y/media-has-caption + controls={true} + autoPlay={autoplay} + src={vidUrl} + width="604px" + height="340px" + onPlay={() => handleVideoAction("video_start")} + onEnded={() => handleVideoAction("video_end")} + > + <source src={vidUrl}></source> + </video> + </div> + ); +}; diff --git a/browser/components/aboutwelcome/content-src/components/ReturnToAMO.jsx b/browser/components/aboutwelcome/content-src/components/ReturnToAMO.jsx new file mode 100644 index 0000000000..e262e3d92a --- /dev/null +++ b/browser/components/aboutwelcome/content-src/components/ReturnToAMO.jsx @@ -0,0 +1,105 @@ +/* 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.mjs"; +import { MultiStageProtonScreen } from "./MultiStageProtonScreen"; +import { BASE_PARAMS } from "../lib/addUtmParams.mjs"; + +export class ReturnToAMO extends React.PureComponent { + constructor(props) { + super(props); + this.fetchFlowParams = this.fetchFlowParams.bind(this); + this.handleAction = this.handleAction.bind(this); + } + + async fetchFlowParams() { + if (this.props.metricsFlowUri) { + this.setState({ + flowParams: await AboutWelcomeUtils.fetchFlowParams( + this.props.metricsFlowUri + ), + }); + } + } + + componentDidUpdate() { + this.fetchFlowParams(); + } + + handleAction(event) { + const { content, message_id, url, utm_term } = this.props; + let { action, source_id } = content[event.currentTarget.value]; + let { type, data } = action; + + if (type === "INSTALL_ADDON_FROM_URL") { + if (!data) { + return; + } + // Set add-on url in action.data.url property from JSON + data = { ...data, url }; + } else if (type === "SHOW_FIREFOX_ACCOUNTS") { + let params = { + ...BASE_PARAMS, + utm_term: `aboutwelcome-${utm_term}-screen`, + }; + if (action.addFlowParams && this.state.flowParams) { + params = { + ...params, + ...this.state.flowParams, + }; + } + data = { ...data, extraParams: params }; + } + + AboutWelcomeUtils.handleUserAction({ type, data }); + AboutWelcomeUtils.sendActionTelemetry(message_id, source_id); + } + + render() { + const { content, type } = this.props; + + if (!content) { + return null; + } + + if (content?.primary_button.label) { + content.primary_button.label.string_id = type.includes("theme") + ? "return-to-amo-add-theme-label" + : "mr1-return-to-amo-add-extension-label"; + } + + // 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 proton"} + style={content.backdrop ? { background: content.backdrop } : {}} + > + <MultiStageProtonScreen + content={content} + isRtamo={true} + isTheme={type.includes("theme")} + id={this.props.message_id} + order={this.props.order || 0} + totalNumberOfScreens={1} + isSingleScreen={true} + autoAdvance={this.props.auto_advance} + iconURL={ + type.includes("theme") + ? this.props.themeScreenshots[0]?.url + : this.props.iconURL + } + addonName={this.props.name} + handleAction={this.handleAction} + /> + </div> + ); + } +} + +ReturnToAMO.defaultProps = DEFAULT_RTAMO_CONTENT; diff --git a/browser/components/aboutwelcome/content-src/components/SubmenuButton.jsx b/browser/components/aboutwelcome/content-src/components/SubmenuButton.jsx new file mode 100644 index 0000000000..e0c8144e73 --- /dev/null +++ b/browser/components/aboutwelcome/content-src/components/SubmenuButton.jsx @@ -0,0 +1,149 @@ +/* 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, useRef, useCallback } from "react"; +import { Localized } from "./MSLocalized"; + +export const SubmenuButton = props => { + return document.createXULElement ? <SubmenuButtonInner {...props} /> : null; +}; + +function translateMenuitem(item, element) { + let { label } = item; + if (!label) { + return; + } + if (label.raw) { + element.setAttribute("label", label.raw); + } + if (label.access_key) { + element.setAttribute("accesskey", label.access_key); + } + if (label.aria_label) { + element.setAttribute("aria-label", label.aria_label); + } + if (label.tooltip_text) { + element.setAttribute("tooltiptext", label.tooltip_text); + } + if (label.string_id) { + element.setAttribute("data-l10n-id", label.string_id); + if (label.args) { + element.setAttribute("data-l10n-args", JSON.stringify(label.args)); + } + } +} + +function addMenuitems(items, popup) { + for (let item of items) { + switch (item.type) { + case "separator": + popup.appendChild(document.createXULElement("menuseparator")); + break; + case "menu": + let menu = document.createXULElement("menu"); + menu.className = "fxms-multi-stage-menu"; + translateMenuitem(item, menu); + if (item.id) { + menu.value = item.id; + } + if (item.icon) { + menu.classList.add("menu-iconic"); + menu.setAttribute("image", item.icon); + } + popup.appendChild(menu); + let submenuPopup = document.createXULElement("menupopup"); + menu.appendChild(submenuPopup); + addMenuitems(item.submenu, submenuPopup); + break; + case "action": + let menuitem = document.createXULElement("menuitem"); + translateMenuitem(item, menuitem); + menuitem.config = item; + if (item.id) { + menuitem.value = item.id; + } + if (item.icon) { + menuitem.classList.add("menuitem-iconic"); + menuitem.setAttribute("image", item.icon); + } + popup.appendChild(menuitem); + break; + } + } +} + +const SubmenuButtonInner = ({ content, handleAction }) => { + const ref = useRef(null); + const isPrimary = content.submenu_button?.style === "primary"; + const onCommand = useCallback( + event => { + let { config } = event.target; + let mockEvent = { + currentTarget: ref.current, + source: config.id, + name: "command", + action: config.action, + }; + handleAction(mockEvent); + }, + [handleAction] + ); + const onClick = useCallback(() => { + let button = ref.current; + let submenu = button?.querySelector(".fxms-multi-stage-submenu"); + if (submenu && !button.hasAttribute("open")) { + submenu.openPopup(button, { position: "after_end" }); + } + }, []); + useEffect(() => { + let button = ref.current; + if (!button || button.querySelector(".fxms-multi-stage-submenu")) { + return null; + } + let menupopup = document.createXULElement("menupopup"); + menupopup.className = "fxms-multi-stage-submenu"; + addMenuitems(content.submenu_button.submenu, menupopup); + button.appendChild(menupopup); + let stylesheet; + if ( + !document.head.querySelector( + `link[href="chrome://global/content/widgets.css"], link[href="chrome://global/skin/global.css"]` + ) + ) { + stylesheet = document.createElement("link"); + stylesheet.rel = "stylesheet"; + stylesheet.href = "chrome://global/content/widgets.css"; + document.head.appendChild(stylesheet); + } + if (!menupopup.listenersRegistered) { + menupopup.addEventListener("command", onCommand); + menupopup.addEventListener("popupshowing", event => { + if (event.target === menupopup && event.target.anchorNode) { + event.target.anchorNode.toggleAttribute("open", true); + } + }); + menupopup.addEventListener("popuphiding", event => { + if (event.target === menupopup && event.target.anchorNode) { + event.target.anchorNode.toggleAttribute("open", false); + } + }); + menupopup.listenersRegistered = true; + } + return () => { + menupopup?.remove(); + stylesheet?.remove(); + }; + }, [onCommand]); // eslint-disable-line react-hooks/exhaustive-deps + + return ( + <Localized text={content.submenu_button.label ?? {}}> + <button + className={`submenu-button ${isPrimary ? "primary" : "secondary"}`} + value="submenu_button" + onClick={onClick} + ref={ref} + /> + </Localized> + ); +}; diff --git a/browser/components/aboutwelcome/content-src/components/Themes.jsx b/browser/components/aboutwelcome/content-src/components/Themes.jsx new file mode 100644 index 0000000000..0ee986f982 --- /dev/null +++ b/browser/components/aboutwelcome/content-src/components/Themes.jsx @@ -0,0 +1,52 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import React from "react"; +import { Localized } from "./MSLocalized"; + +export const Themes = props => { + return ( + <div className="tiles-theme-container"> + <div> + <fieldset className="tiles-theme-section"> + <Localized text={props.content.subtitle}> + <legend className="sr-only" /> + </Localized> + {props.content.tiles.data.map( + ({ theme, label, tooltip, description }) => ( + <Localized + key={theme + label} + text={typeof tooltip === "object" ? tooltip : {}} + > + {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */} + <label className="theme" title={theme + label}> + <Localized + text={typeof description === "object" ? description : {}} + > + <input + type="radio" + value={theme} + name="theme" + checked={theme === props.activeTheme} + className="sr-only input" + onClick={props.handleAction} + /> + </Localized> + <div + className={`icon ${ + theme === props.activeTheme ? " selected" : "" + } ${theme}`} + /> + <Localized text={label}> + <div className="text" /> + </Localized> + </label> + </Localized> + ) + )} + </fieldset> + </div> + </div> + ); +}; diff --git a/browser/components/aboutwelcome/content-src/components/Zap.jsx b/browser/components/aboutwelcome/content-src/components/Zap.jsx new file mode 100644 index 0000000000..a067c4d7fe --- /dev/null +++ b/browser/components/aboutwelcome/content-src/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; +}; diff --git a/browser/components/aboutwelcome/content-src/lib/aboutwelcome-utils.mjs b/browser/components/aboutwelcome/content-src/lib/aboutwelcome-utils.mjs new file mode 100644 index 0000000000..4cbb888e28 --- /dev/null +++ b/browser/components/aboutwelcome/content-src/lib/aboutwelcome-utils.mjs @@ -0,0 +1,122 @@ +/* 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/. */ + +// If the container has a "page" data attribute, then this is +// a Spotlight modal or Feature Callout. Otherwise, this is +// about:welcome and we should return the current page. +const page = + document.querySelector( + "#multi-stage-message-root.onboardingContainer[data-page]" + )?.dataset.page || document.location.href; + +export const AboutWelcomeUtils = { + handleUserAction(action) { + return window.AWSendToParent("SPECIAL_ACTION", action); + }, + sendImpressionTelemetry(messageId, context) { + window.AWSendEventTelemetry?.({ + event: "IMPRESSION", + event_context: { + ...context, + page, + }, + message_id: messageId, + }); + }, + sendActionTelemetry(messageId, elementId, eventName = "CLICK_BUTTON") { + const ping = { + event: eventName, + event_context: { + source: elementId, + page, + }, + message_id: messageId, + }; + window.AWSendEventTelemetry?.(ping); + }, + sendDismissTelemetry(messageId, elementId) { + // Don't send DISMISS telemetry in spotlight modals since they already send + // their own equivalent telemetry. + if (page !== "spotlight") { + this.sendActionTelemetry(messageId, elementId, "DISMISS"); + } + }, + async fetchFlowParams(metricsFlowUri) { + let flowParams; + try { + const response = await fetch(metricsFlowUri, { + credentials: "omit", + }); + if (response.status === 200) { + const { deviceId, flowId, flowBeginTime } = await response.json(); + flowParams = { deviceId, flowId, flowBeginTime }; + } else { + console.error("Non-200 response", response); + } + } catch (e) { + flowParams = null; + } + return flowParams; + }, + sendEvent(type, detail) { + document.dispatchEvent( + new CustomEvent(`AWPage:${type}`, { + bubbles: true, + detail, + }) + ); + }, + getLoadingStrategyFor(url) { + return url?.startsWith("http") ? "lazy" : "eager"; + }, +}; + +export const DEFAULT_RTAMO_CONTENT = { + template: "return_to_amo", + utm_term: "rtamo", + content: { + position: "split", + title: { string_id: "mr1-return-to-amo-subtitle" }, + has_noodles: false, + subtitle: { + string_id: "mr1-return-to-amo-addon-title", + }, + backdrop: + "var(--mr-welcome-background-color) var(--mr-welcome-background-gradient)", + background: + "url('chrome://activity-stream/content/data/content/assets/mr-rtamo-background-image.svg') no-repeat center", + progress_bar: true, + primary_button: { + label: { string_id: "mr1-return-to-amo-add-extension-label" }, + source_id: "ADD_EXTENSION_BUTTON", + action: { + type: "INSTALL_ADDON_FROM_URL", + data: { url: null, telemetrySource: "rtamo" }, + }, + }, + secondary_button: { + label: { + string_id: "onboarding-not-now-button-label", + }, + source_id: "RTAMO_START_BROWSING_BUTTON", + action: { + type: "OPEN_AWESOME_BAR", + }, + }, + secondary_button_top: { + label: { + string_id: "mr1-onboarding-sign-in-button-label", + }, + source_id: "RTAMO_FXA_SIGNIN_BUTTON", + action: { + data: { + entrypoint: "activity-stream-firstrun", + where: "tab", + }, + type: "SHOW_FIREFOX_ACCOUNTS", + addFlowParams: true, + }, + }, + }, +}; diff --git a/browser/components/aboutwelcome/content-src/lib/addUtmParams.mjs b/browser/components/aboutwelcome/content-src/lib/addUtmParams.mjs new file mode 100644 index 0000000000..6fc4d2283a --- /dev/null +++ b/browser/components/aboutwelcome/content-src/lib/addUtmParams.mjs @@ -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; +} |