summaryrefslogtreecommitdiffstats
path: root/browser/components/aboutwelcome/content-src
diff options
context:
space:
mode:
Diffstat (limited to 'browser/components/aboutwelcome/content-src')
-rw-r--r--browser/components/aboutwelcome/content-src/aboutwelcome.jsx144
-rw-r--r--browser/components/aboutwelcome/content-src/aboutwelcome.scss1940
-rw-r--r--browser/components/aboutwelcome/content-src/components/AdditionalCTA.jsx42
-rw-r--r--browser/components/aboutwelcome/content-src/components/AddonsPicker.jsx116
-rw-r--r--browser/components/aboutwelcome/content-src/components/CTAParagraph.jsx45
-rw-r--r--browser/components/aboutwelcome/content-src/components/EmbeddedMigrationWizard.jsx40
-rw-r--r--browser/components/aboutwelcome/content-src/components/HelpText.jsx54
-rw-r--r--browser/components/aboutwelcome/content-src/components/HeroImage.jsx26
-rw-r--r--browser/components/aboutwelcome/content-src/components/LanguageSwitcher.jsx308
-rw-r--r--browser/components/aboutwelcome/content-src/components/LinkParagraph.jsx59
-rw-r--r--browser/components/aboutwelcome/content-src/components/MRColorways.jsx200
-rw-r--r--browser/components/aboutwelcome/content-src/components/MSLocalized.jsx114
-rw-r--r--browser/components/aboutwelcome/content-src/components/MobileDownloads.jsx73
-rw-r--r--browser/components/aboutwelcome/content-src/components/MultiSelect.jsx158
-rw-r--r--browser/components/aboutwelcome/content-src/components/MultiStageAboutWelcome.jsx568
-rw-r--r--browser/components/aboutwelcome/content-src/components/MultiStageProtonScreen.jsx620
-rw-r--r--browser/components/aboutwelcome/content-src/components/OnboardingVideo.jsx34
-rw-r--r--browser/components/aboutwelcome/content-src/components/ReturnToAMO.jsx105
-rw-r--r--browser/components/aboutwelcome/content-src/components/SubmenuButton.jsx149
-rw-r--r--browser/components/aboutwelcome/content-src/components/Themes.jsx52
-rw-r--r--browser/components/aboutwelcome/content-src/components/Zap.jsx60
-rw-r--r--browser/components/aboutwelcome/content-src/lib/aboutwelcome-utils.mjs122
-rw-r--r--browser/components/aboutwelcome/content-src/lib/addUtmParams.mjs32
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;
+}