summaryrefslogtreecommitdiffstats
path: root/browser/components/shopping
diff options
context:
space:
mode:
Diffstat (limited to 'browser/components/shopping')
-rw-r--r--browser/components/shopping/ShoppingSidebarChild.sys.mjs517
-rw-r--r--browser/components/shopping/ShoppingSidebarParent.sys.mjs430
-rw-r--r--browser/components/shopping/ShoppingUtils.sys.mjs308
-rw-r--r--browser/components/shopping/content/adjusted-rating.mjs53
-rw-r--r--browser/components/shopping/content/analysis-explainer.css39
-rw-r--r--browser/components/shopping/content/analysis-explainer.mjs158
-rw-r--r--browser/components/shopping/content/assets/competitiveness.svg6
-rw-r--r--browser/components/shopping/content/assets/optInDark.avifbin0 -> 9746 bytes
-rw-r--r--browser/components/shopping/content/assets/optInLight.avifbin0 -> 9651 bytes
-rw-r--r--browser/components/shopping/content/assets/packaging.svg6
-rw-r--r--browser/components/shopping/content/assets/price.svg7
-rw-r--r--browser/components/shopping/content/assets/priceTagButtonCallout.svg41
-rw-r--r--browser/components/shopping/content/assets/quality.svg7
-rw-r--r--browser/components/shopping/content/assets/ratingDark.avifbin0 -> 14230 bytes
-rw-r--r--browser/components/shopping/content/assets/ratingLight.avifbin0 -> 14071 bytes
-rw-r--r--browser/components/shopping/content/assets/reviewsVisualCallout.svg77
-rw-r--r--browser/components/shopping/content/assets/shipping.svg6
-rw-r--r--browser/components/shopping/content/assets/shopping.svg6
-rw-r--r--browser/components/shopping/content/assets/unanalyzedDark.avifbin0 -> 11485 bytes
-rw-r--r--browser/components/shopping/content/assets/unanalyzedLight.avifbin0 -> 11070 bytes
-rw-r--r--browser/components/shopping/content/highlight-item.css66
-rw-r--r--browser/components/shopping/content/highlight-item.mjs57
-rw-r--r--browser/components/shopping/content/highlights.mjs124
-rw-r--r--browser/components/shopping/content/letter-grade.css132
-rw-r--r--browser/components/shopping/content/letter-grade.mjs78
-rw-r--r--browser/components/shopping/content/onboarding.mjs69
-rw-r--r--browser/components/shopping/content/recommended-ad.css59
-rw-r--r--browser/components/shopping/content/recommended-ad.mjs150
-rw-r--r--browser/components/shopping/content/reliability.mjs48
-rw-r--r--browser/components/shopping/content/settings.css69
-rw-r--r--browser/components/shopping/content/settings.mjs210
-rw-r--r--browser/components/shopping/content/shopping-card.css201
-rw-r--r--browser/components/shopping/content/shopping-card.mjs204
-rw-r--r--browser/components/shopping/content/shopping-container.css152
-rw-r--r--browser/components/shopping/content/shopping-container.mjs471
-rw-r--r--browser/components/shopping/content/shopping-message-bar.css78
-rw-r--r--browser/components/shopping/content/shopping-message-bar.mjs278
-rw-r--r--browser/components/shopping/content/shopping-page.css28
-rw-r--r--browser/components/shopping/content/shopping-sidebar.js80
-rw-r--r--browser/components/shopping/content/shopping.ftl7
-rw-r--r--browser/components/shopping/content/shopping.html53
-rw-r--r--browser/components/shopping/content/unanalyzed.css41
-rw-r--r--browser/components/shopping/content/unanalyzed.mjs61
-rw-r--r--browser/components/shopping/jar.mn31
-rw-r--r--browser/components/shopping/metrics.yaml738
-rw-r--r--browser/components/shopping/moz.build21
-rw-r--r--browser/components/shopping/tests/browser/browser.toml79
-rw-r--r--browser/components/shopping/tests/browser/browser_adjusted_rating.js115
-rw-r--r--browser/components/shopping/tests/browser/browser_ads_exposure_telemetry.js213
-rw-r--r--browser/components/shopping/tests/browser/browser_analysis_explainer.js43
-rw-r--r--browser/components/shopping/tests/browser/browser_auto_open.js90
-rw-r--r--browser/components/shopping/tests/browser/browser_exposure_telemetry.js123
-rw-r--r--browser/components/shopping/tests/browser/browser_inprogress_analysis.js151
-rw-r--r--browser/components/shopping/tests/browser/browser_keep_close_message_bar.js530
-rw-r--r--browser/components/shopping/tests/browser/browser_network_offline.js33
-rw-r--r--browser/components/shopping/tests/browser/browser_not_enough_reviews.js80
-rw-r--r--browser/components/shopping/tests/browser/browser_page_not_supported.js36
-rw-r--r--browser/components/shopping/tests/browser/browser_private_mode.js35
-rw-r--r--browser/components/shopping/tests/browser/browser_recommended_ad_test.js71
-rw-r--r--browser/components/shopping/tests/browser/browser_review_highlights.js194
-rw-r--r--browser/components/shopping/tests/browser/browser_settings_telemetry.js102
-rw-r--r--browser/components/shopping/tests/browser/browser_shopping_card.js50
-rw-r--r--browser/components/shopping/tests/browser/browser_shopping_container.js41
-rw-r--r--browser/components/shopping/tests/browser/browser_shopping_message_triggers.js315
-rw-r--r--browser/components/shopping/tests/browser/browser_shopping_onboarding.js661
-rw-r--r--browser/components/shopping/tests/browser/browser_shopping_settings.js642
-rw-r--r--browser/components/shopping/tests/browser/browser_shopping_sidebar.js66
-rw-r--r--browser/components/shopping/tests/browser/browser_shopping_survey.js337
-rw-r--r--browser/components/shopping/tests/browser/browser_shopping_urlbar.js427
-rw-r--r--browser/components/shopping/tests/browser/browser_stale_product.js36
-rw-r--r--browser/components/shopping/tests/browser/browser_ui_telemetry.js762
-rw-r--r--browser/components/shopping/tests/browser/browser_unanalyzed_product.js86
-rw-r--r--browser/components/shopping/tests/browser/browser_unavailable_product.js102
-rw-r--r--browser/components/shopping/tests/browser/head.js225
74 files changed, 10812 insertions, 0 deletions
diff --git a/browser/components/shopping/ShoppingSidebarChild.sys.mjs b/browser/components/shopping/ShoppingSidebarChild.sys.mjs
new file mode 100644
index 0000000000..ff80086d5d
--- /dev/null
+++ b/browser/components/shopping/ShoppingSidebarChild.sys.mjs
@@ -0,0 +1,517 @@
+/* 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 { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+import { RemotePageChild } from "resource://gre/actors/RemotePageChild.sys.mjs";
+
+import { ShoppingProduct } from "chrome://global/content/shopping/ShoppingProduct.mjs";
+
+let lazy = {};
+
+let gAllActors = new Set();
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "optedIn",
+ "browser.shopping.experience2023.optedIn",
+ null,
+ function optedInStateChanged() {
+ for (let actor of gAllActors) {
+ actor.optedInStateChanged();
+ }
+ }
+);
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "adsEnabled",
+ "browser.shopping.experience2023.ads.enabled",
+ true
+);
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "adsEnabledByUser",
+ "browser.shopping.experience2023.ads.userEnabled",
+ true,
+ function adsEnabledByUserChanged() {
+ for (let actor of gAllActors) {
+ actor.adsEnabledByUserChanged();
+ }
+ }
+);
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "adsExposure",
+ "browser.shopping.experience2023.ads.exposure",
+ false
+);
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "autoOpenEnabled",
+ "browser.shopping.experience2023.autoOpen.enabled",
+ true
+);
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "autoOpenEnabledByUser",
+ "browser.shopping.experience2023.autoOpen.userEnabled",
+ true,
+ function autoOpenEnabledByUserChanged() {
+ for (let actor of gAllActors) {
+ actor.autoOpenEnabledByUserChanged();
+ }
+ }
+);
+
+export class ShoppingSidebarChild extends RemotePageChild {
+ constructor() {
+ super();
+ }
+
+ actorCreated() {
+ super.actorCreated();
+ gAllActors.add(this);
+ }
+
+ didDestroy() {
+ this._destroyed = true;
+ super.didDestroy?.();
+ gAllActors.delete(this);
+ this.#product?.off("analysis-progress", this.#onAnalysisProgress);
+ this.#product?.uninit();
+ }
+
+ #productURI = null;
+ #product = null;
+
+ receiveMessage(message) {
+ switch (message.name) {
+ case "ShoppingSidebar:UpdateProductURL":
+ let { url, isReload } = message.data;
+ let uri = url ? Services.io.newURI(url) : null;
+ // If we're going from null to null, bail out:
+ if (!this.#productURI && !uri) {
+ return null;
+ }
+
+ // If we haven't reloaded, check if the URIs represent the same product
+ // as sites might change the URI after they have loaded (Bug 1852099).
+ if (!isReload && this.isSameProduct(uri, this.#productURI)) {
+ return null;
+ }
+
+ this.#productURI = uri;
+ this.updateContent({ haveUpdatedURI: true });
+ break;
+ case "ShoppingSidebar:ShowKeepClosedMessage":
+ this.sendToContent("ShowKeepClosedMessage");
+ break;
+ case "ShoppingSidebar:HideKeepClosedMessage":
+ this.sendToContent("HideKeepClosedMessage");
+ break;
+ case "ShoppingSidebar:IsKeepClosedMessageShowing":
+ return !!this.document.querySelector("shopping-container")
+ ?.wrappedJSObject.showingKeepClosedMessage;
+ }
+ return null;
+ }
+
+ isSameProduct(newURI, currentURI) {
+ if (!newURI || !currentURI) {
+ return false;
+ }
+
+ // Check if the URIs are equal:
+ if (currentURI.equalsExceptRef(newURI)) {
+ return true;
+ }
+
+ if (!this.#product) {
+ return false;
+ }
+
+ // If the current ShoppingProduct has product info set,
+ // check if the product ids are the same:
+ let currentProduct = this.#product.product;
+ if (currentProduct) {
+ let newProduct = ShoppingProduct.fromURL(URL.fromURI(newURI));
+ if (newProduct.id === currentProduct.id) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ handleEvent(event) {
+ let aid;
+ switch (event.type) {
+ case "ContentReady":
+ this.updateContent();
+ break;
+ case "PolledRequestMade":
+ this.updateContent({ isPolledRequest: true });
+ break;
+ case "ReportProductAvailable":
+ this.reportProductAvailable();
+ break;
+ case "AdClicked":
+ aid = event.detail.aid;
+ ShoppingProduct.sendAttributionEvent("click", aid);
+ Glean.shopping.surfaceAdsClicked.record();
+ break;
+ case "AdImpression":
+ aid = event.detail.aid;
+ ShoppingProduct.sendAttributionEvent("impression", aid);
+ Glean.shopping.surfaceAdsImpression.record();
+ break;
+ case "DisableShopping":
+ this.sendAsyncMessage("DisableShopping");
+ break;
+ }
+ }
+
+ // Exposed for testing. Assumes uri is a nsURI.
+ set productURI(uri) {
+ if (!(uri instanceof Ci.nsIURI)) {
+ throw new Error("productURI setter expects an nsIURI");
+ }
+ this.#productURI = uri;
+ }
+
+ // Exposed for testing. Assumes product is a ShoppingProduct.
+ set product(product) {
+ if (!(product instanceof ShoppingProduct)) {
+ throw new Error("product setter expects an instance of ShoppingProduct");
+ }
+ this.#product = product;
+ }
+
+ get canFetchAndShowData() {
+ return lazy.optedIn === 1;
+ }
+
+ get adsEnabled() {
+ return lazy.adsEnabled;
+ }
+
+ get adsEnabledByUser() {
+ return lazy.adsEnabledByUser;
+ }
+
+ get canFetchAndShowAd() {
+ return this.adsEnabled && this.adsEnabledByUser;
+ }
+
+ get autoOpenEnabled() {
+ return lazy.autoOpenEnabled;
+ }
+
+ get autoOpenEnabledByUser() {
+ return lazy.autoOpenEnabledByUser;
+ }
+
+ optedInStateChanged() {
+ // Force re-fetching things if needed by clearing the last product URI:
+ this.#productURI = null;
+ // Then let content know.
+ this.updateContent({ focusCloseButton: true });
+ }
+
+ adsEnabledByUserChanged() {
+ this.sendToContent("adsEnabledByUserChanged", {
+ adsEnabledByUser: this.adsEnabledByUser,
+ });
+
+ this.requestRecommendations(this.#productURI);
+ }
+
+ autoOpenEnabledByUserChanged() {
+ this.sendToContent("autoOpenEnabledByUserChanged", {
+ autoOpenEnabledByUser: this.autoOpenEnabledByUser,
+ });
+ }
+
+ getProductURI() {
+ return this.#productURI;
+ }
+
+ /**
+ * This callback is invoked whenever something changes that requires
+ * re-rendering content. The expected cases for this are:
+ * - page navigations (both to new products and away from a product once
+ * the sidebar has been created)
+ * - opt in state changes.
+ *
+ * @param {object?} options
+ * Optional parameter object.
+ * @param {bool} options.haveUpdatedURI = false
+ * Whether we've got an up-to-date URI already. If true, we avoid
+ * fetching the URI from the parent, and assume `this.#productURI`
+ * is current. Defaults to false.
+ * @param {bool} options.isPolledRequest = false
+ *
+ */
+ async updateContent({
+ haveUpdatedURI = false,
+ isPolledRequest = false,
+ focusCloseButton = false,
+ } = {}) {
+ // updateContent is an async function, and when we're off making requests or doing
+ // other things asynchronously, the actor can be destroyed, the user
+ // might navigate to a new page, the user might disable the feature ... -
+ // all kinds of things can change. So we need to repeatedly check
+ // whether we can keep going with our async processes. This helper takes
+ // care of these checks.
+ let canContinue = (currentURI, checkURI = true) => {
+ if (this._destroyed || !this.canFetchAndShowData) {
+ return false;
+ }
+ if (!checkURI) {
+ return true;
+ }
+ return currentURI && currentURI == this.#productURI;
+ };
+ this.#product?.off("analysis-progress", this.#onAnalysisProgress);
+ this.#product?.uninit();
+ // We are called either because the URL has changed or because the opt-in
+ // state has changed. In both cases, we want to clear out content
+ // immediately, without waiting for potentially async operations like
+ // obtaining product information.
+ // Do not clear data however if an analysis was requested via a call-to-action.
+ if (!isPolledRequest) {
+ this.sendToContent("Update", {
+ adsEnabled: this.adsEnabled,
+ adsEnabledByUser: this.adsEnabledByUser,
+ autoOpenEnabled: this.autoOpenEnabled,
+ autoOpenEnabledByUser: this.autoOpenEnabledByUser,
+ showOnboarding: !this.canFetchAndShowData,
+ data: null,
+ recommendationData: null,
+ focusCloseButton,
+ });
+ }
+ if (this.canFetchAndShowData) {
+ if (!this.#productURI) {
+ // If we already have a URI and it's just null, bail immediately.
+ if (haveUpdatedURI) {
+ return;
+ }
+ let url = await this.sendQuery("GetProductURL");
+
+ // Bail out if we opted out in the meantime, or don't have a URI.
+ if (!canContinue(null, false)) {
+ return;
+ }
+
+ this.#productURI = Services.io.newURI(url);
+ }
+
+ let uri = this.#productURI;
+ this.#product = new ShoppingProduct(uri);
+ this.#product.on(
+ "analysis-progress",
+ this.#onAnalysisProgress.bind(this)
+ );
+
+ let data;
+ let isAnalysisInProgress;
+
+ try {
+ let analysisStatusResponse;
+ if (isPolledRequest) {
+ // Request a new analysis.
+ analysisStatusResponse = await this.#product.requestCreateAnalysis();
+ } else {
+ // Check if there is an analysis in progress.
+ analysisStatusResponse =
+ await this.#product.requestAnalysisCreationStatus();
+ }
+ let analysisStatus = analysisStatusResponse?.status;
+
+ isAnalysisInProgress =
+ analysisStatus &&
+ (analysisStatus == "pending" || analysisStatus == "in_progress");
+ if (isAnalysisInProgress) {
+ // Only clear the existing data if the update wasn't
+ // triggered by a Polled Request event as re-analysis should
+ // keep any stale data visible while processing.
+ if (!isPolledRequest) {
+ this.sendToContent("Update", {
+ isAnalysisInProgress,
+ });
+ }
+ analysisStatusResponse = await this.#product.pollForAnalysisCompleted(
+ {
+ pollInitialWait: analysisStatus == "in_progress" ? 0 : undefined,
+ }
+ );
+ analysisStatus = analysisStatusResponse?.status;
+ isAnalysisInProgress = false;
+ }
+
+ // Use the analysis status instead of re-requesting unnecessarily,
+ // or throw if the status from the last analysis was an error.
+ switch (analysisStatus) {
+ case "not_analyzable":
+ case "page_not_supported":
+ data = { page_not_supported: true };
+ break;
+ case "not_enough_reviews":
+ data = { not_enough_reviews: true };
+ break;
+ case "unprocessable":
+ case "stale":
+ throw new Error(analysisStatus, { cause: analysisStatus });
+ default:
+ // Status is "completed" or "not_found" (no analysis status),
+ // so we should request the analysis data.
+ }
+
+ if (!data) {
+ data = await this.#product.requestAnalysis();
+ if (!data) {
+ throw new Error("request failed");
+ }
+ }
+ } catch (err) {
+ console.error("Failed to fetch product analysis data", err);
+ data = { error: err };
+ }
+ // Check if we got nuked from orbit, or the product URI or opt in changed while we waited.
+ if (!canContinue(uri)) {
+ return;
+ }
+
+ this.sendToContent("Update", {
+ adsEnabled: this.adsEnabled,
+ adsEnabledByUser: this.adsEnabledByUser,
+ autoOpenEnabled: this.autoOpenEnabled,
+ autoOpenEnabledByUser: this.autoOpenEnabledByUser,
+ showOnboarding: false,
+ data,
+ productUrl: this.#productURI.spec,
+ isAnalysisInProgress,
+ });
+
+ if (!data || data.error) {
+ return;
+ }
+
+ if (!isPolledRequest && !data.grade) {
+ Glean.shopping.surfaceNoReviewReliabilityAvailable.record();
+ }
+
+ this.requestRecommendations(uri);
+ } else {
+ // Don't bother continuing if the user has opted out.
+ if (lazy.optedIn == 2) {
+ return;
+ }
+ let url = await this.sendQuery("GetProductURL");
+
+ // Similar to canContinue() above, check to see if things
+ // have changed while we were waiting. Bail out if the user
+ // opted in, or if the actor doesn't exist.
+ if (this._destroyed || this.canFetchAndShowData) {
+ return;
+ }
+
+ this.#productURI = Services.io.newURI(url);
+ // Send the productURI to content for Onboarding's dynamic text
+ this.sendToContent("Update", {
+ showOnboarding: true,
+ data: null,
+ productUrl: this.#productURI.spec,
+ });
+ }
+ }
+
+ /**
+ * Utility function to determine if we should request ads.
+ */
+ canFetchAds(uri) {
+ return (
+ uri.equalsExceptRef(this.#productURI) &&
+ this.canFetchAndShowData &&
+ (lazy.adsExposure || this.canFetchAndShowAd)
+ );
+ }
+
+ /**
+ * Utility function to determine if we should display ads. This is different
+ * from fetching ads, because of ads exposure telemetry (bug 1858470).
+ */
+ canShowAds(uri) {
+ return (
+ uri.equalsExceptRef(this.#productURI) &&
+ this.canFetchAndShowData &&
+ this.canFetchAndShowAd
+ );
+ }
+
+ /**
+ * Request recommended products for a given uri and send the recommendations
+ * to the content if recommendations are enabled.
+ *
+ * @param {nsIURI} uri The uri of the current product page
+ */
+ async requestRecommendations(uri) {
+ if (!this.canFetchAds(uri)) {
+ return;
+ }
+
+ let recommendationData = await this.#product.requestRecommendations();
+
+ // Note: this needs to be separate from the inverse conditional check below
+ // because here we want to know if an ad exists for the product, regardless
+ // of whether ads are enabled, while for the surfaceNoAdsAvailable Glean
+ // probe, we want to know if ads would have been shown, but one wasn't
+ // available.
+ if (recommendationData.length) {
+ Glean.shopping.adsExposure.record();
+ }
+
+ // Check if the product URI or opt in changed while we waited.
+ if (!this.canShowAds(uri)) {
+ return;
+ }
+
+ if (!recommendationData.length) {
+ // We tried to fetch an ad, but didn't get one.
+ Glean.shopping.surfaceNoAdsAvailable.record();
+ } else {
+ ShoppingProduct.sendAttributionEvent(
+ "placement",
+ recommendationData[0].aid
+ );
+ Glean.shopping.surfaceAdsPlacement.record();
+ }
+
+ this.sendToContent("UpdateRecommendations", {
+ recommendationData,
+ });
+ }
+
+ sendToContent(eventName, detail) {
+ if (this._destroyed) {
+ return;
+ }
+ let win = this.contentWindow;
+ let evt = new win.CustomEvent(eventName, {
+ bubbles: true,
+ detail: Cu.cloneInto(detail, win),
+ });
+ win.document.dispatchEvent(evt);
+ }
+
+ async reportProductAvailable() {
+ await this.#product.sendReport();
+ }
+
+ #onAnalysisProgress(eventName, progress) {
+ this.sendToContent("UpdateAnalysisProgress", {
+ progress,
+ });
+ }
+}
diff --git a/browser/components/shopping/ShoppingSidebarParent.sys.mjs b/browser/components/shopping/ShoppingSidebarParent.sys.mjs
new file mode 100644
index 0000000000..a7733c9a28
--- /dev/null
+++ b/browser/components/shopping/ShoppingSidebarParent.sys.mjs
@@ -0,0 +1,430 @@
+/* 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/. */
+
+const lazy = {};
+ChromeUtils.defineESModuleGetters(lazy, {
+ BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.sys.mjs",
+ EveryWindow: "resource:///modules/EveryWindow.sys.mjs",
+ isProductURL: "chrome://global/content/shopping/ShoppingProduct.mjs",
+ NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs",
+ PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
+ ShoppingUtils: "resource:///modules/ShoppingUtils.sys.mjs",
+});
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "AUTO_OPEN_SIDEBAR_ENABLED",
+ "browser.shopping.experience2023.autoOpen.enabled",
+ true
+);
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "AUTO_OPEN_SIDEBAR_USER_ENABLED",
+ "browser.shopping.experience2023.autoOpen.userEnabled",
+ true
+);
+
+export class ShoppingSidebarParent extends JSWindowActorParent {
+ static SHOPPING_ACTIVE_PREF = "browser.shopping.experience2023.active";
+ static SHOPPING_OPTED_IN_PREF = "browser.shopping.experience2023.optedIn";
+ static SIDEBAR_CLOSED_COUNT_PREF =
+ "browser.shopping.experience2023.sidebarClosedCount";
+ static SHOW_KEEP_SIDEBAR_CLOSED_MESSAGE_PREF =
+ "browser.shopping.experience2023.showKeepSidebarClosedMessage";
+
+ updateProductURL(uri, flags) {
+ this.sendAsyncMessage("ShoppingSidebar:UpdateProductURL", {
+ url: uri?.spec ?? null,
+ isReload: !!(flags & Ci.nsIWebProgressListener.LOCATION_CHANGE_RELOAD),
+ });
+ }
+
+ async receiveMessage(message) {
+ switch (message.name) {
+ case "GetProductURL":
+ let sidebarBrowser = this.browsingContext.top.embedderElement;
+ let panel = sidebarBrowser.closest(".browserSidebarContainer");
+ let associatedTabbedBrowser = panel.querySelector(
+ "browser[messagemanagergroup=browsers]"
+ );
+ return associatedTabbedBrowser.currentURI?.spec ?? null;
+ case "DisableShopping":
+ Services.prefs.setBoolPref(
+ ShoppingSidebarParent.SHOPPING_ACTIVE_PREF,
+ false
+ );
+ Services.prefs.setIntPref(
+ ShoppingSidebarParent.SHOPPING_OPTED_IN_PREF,
+ 2
+ );
+ break;
+ }
+ return null;
+ }
+
+ /**
+ * Called when the user clicks the URL bar button.
+ */
+ static async urlbarButtonClick(event) {
+ if (event.button > 0) {
+ return;
+ }
+
+ if (
+ lazy.AUTO_OPEN_SIDEBAR_ENABLED &&
+ lazy.AUTO_OPEN_SIDEBAR_USER_ENABLED &&
+ event.target.getAttribute("shoppingsidebaropen") === "true"
+ ) {
+ let gBrowser = event.target.ownerGlobal.gBrowser;
+ let shoppingBrowser = gBrowser
+ .getPanel(gBrowser.selectedBrowser)
+ .querySelector(".shopping-sidebar");
+ let actor =
+ shoppingBrowser.browsingContext.currentWindowGlobal.getActor(
+ "ShoppingSidebar"
+ );
+
+ let isKeepClosedMessageShowing = await actor.sendQuery(
+ "ShoppingSidebar:IsKeepClosedMessageShowing"
+ );
+
+ let sidebarClosedCount = Services.prefs.getIntPref(
+ ShoppingSidebarParent.SIDEBAR_CLOSED_COUNT_PREF,
+ 0
+ );
+ if (
+ !isKeepClosedMessageShowing &&
+ sidebarClosedCount >= 4 &&
+ Services.prefs.getBoolPref(
+ ShoppingSidebarParent.SHOW_KEEP_SIDEBAR_CLOSED_MESSAGE_PREF,
+ true
+ )
+ ) {
+ actor.sendAsyncMessage("ShoppingSidebar:ShowKeepClosedMessage");
+ return;
+ }
+
+ actor.sendAsyncMessage("ShoppingSidebar:HideKeepClosedMessage");
+
+ if (sidebarClosedCount >= 6) {
+ Services.prefs.setBoolPref(
+ ShoppingSidebarParent.SHOW_KEEP_SIDEBAR_CLOSED_MESSAGE_PREF,
+ false
+ );
+ }
+
+ Services.prefs.setIntPref(
+ ShoppingSidebarParent.SIDEBAR_CLOSED_COUNT_PREF,
+ sidebarClosedCount + 1
+ );
+ }
+
+ this.toggleAllSidebars("urlBar");
+ }
+
+ /**
+ * Toggles opening or closing all Shopping sidebars.
+ * Sets the active pref value for all windows to respond to.
+ * params:
+ *
+ * @param {string?} source
+ * Optional value, describes where the call came from.
+ */
+ static toggleAllSidebars(source) {
+ let activeState = Services.prefs.getBoolPref(
+ ShoppingSidebarParent.SHOPPING_ACTIVE_PREF
+ );
+ Services.prefs.setBoolPref(
+ ShoppingSidebarParent.SHOPPING_ACTIVE_PREF,
+ !activeState
+ );
+
+ let optedIn = Services.prefs.getIntPref(
+ ShoppingSidebarParent.SHOPPING_OPTED_IN_PREF
+ );
+ // If the user was opted out, then clicked the button, reset the optedIn
+ // pref so they see onboarding.
+ if (optedIn == 2) {
+ Services.prefs.setIntPref(
+ ShoppingSidebarParent.SHOPPING_OPTED_IN_PREF,
+ 0
+ );
+ }
+ if (source == "urlBar") {
+ if (activeState) {
+ Glean.shopping.surfaceClosed.record({ source: "addressBarIcon" });
+ Glean.shopping.addressBarIconClicked.record({ action: "closed" });
+ } else {
+ Glean.shopping.addressBarIconClicked.record({ action: "opened" });
+ }
+ }
+ }
+}
+
+class ShoppingSidebarManagerClass {
+ #initialized = false;
+ #everyWindowCallbackId = `shopping-${Services.uuid.generateUUID()}`;
+
+ ensureInitialized() {
+ if (this.#initialized) {
+ return;
+ }
+
+ this.updateSidebarVisibility = this.updateSidebarVisibility.bind(this);
+ lazy.NimbusFeatures.shopping2023.onUpdate(this.updateSidebarVisibility);
+ XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "optedInPref",
+ "browser.shopping.experience2023.optedIn",
+ null,
+ this.updateSidebarVisibility
+ );
+ XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "isActive",
+ ShoppingSidebarParent.SHOPPING_ACTIVE_PREF,
+ true,
+ this.updateSidebarVisibility
+ );
+ this.updateSidebarVisibility();
+
+ lazy.EveryWindow.registerCallback(
+ this.#everyWindowCallbackId,
+ window => {
+ let isPBM = lazy.PrivateBrowsingUtils.isWindowPrivate(window);
+ if (isPBM) {
+ return;
+ }
+
+ window.gBrowser.tabContainer.addEventListener("TabSelect", this);
+ window.addEventListener("visibilitychange", this);
+ },
+ window => {
+ let isPBM = lazy.PrivateBrowsingUtils.isWindowPrivate(window);
+ if (isPBM) {
+ return;
+ }
+
+ window.gBrowser.tabContainer.removeEventListener("TabSelect", this);
+ window.removeEventListener("visibilitychange", this);
+ }
+ );
+
+ this.#initialized = true;
+ }
+
+ updateSidebarVisibility() {
+ this.enabled = lazy.NimbusFeatures.shopping2023.getVariable("enabled");
+
+ for (let window of lazy.BrowserWindowTracker.orderedWindows) {
+ this.updateSidebarVisibilityForWindow(window);
+ }
+ }
+
+ updateSidebarVisibilityForWindow(window) {
+ if (window.closed) {
+ return;
+ }
+
+ if (!window.gBrowser) {
+ return;
+ }
+
+ let document = window.document;
+
+ if (!this.isActive) {
+ document.querySelectorAll("shopping-sidebar").forEach(sidebar => {
+ sidebar.hidden = true;
+ });
+ }
+
+ this._maybeToggleButton(window.gBrowser);
+
+ if (!this.enabled) {
+ document.querySelectorAll("shopping-sidebar").forEach(sidebar => {
+ sidebar.remove();
+ });
+ return;
+ }
+
+ let { selectedBrowser, currentURI } = window.gBrowser;
+ this._maybeToggleSidebar(selectedBrowser, currentURI, 0, false);
+ }
+
+ _maybeToggleSidebar(aBrowser, aLocationURI, aFlags, aIsNavigation) {
+ let gBrowser = aBrowser.getTabBrowser();
+ let document = aBrowser.ownerDocument;
+ if (!this.enabled) {
+ return;
+ }
+
+ let browserPanel = gBrowser.getPanel(aBrowser);
+ let sidebar = browserPanel.querySelector("shopping-sidebar");
+ let actor;
+ if (sidebar) {
+ let { browsingContext } = sidebar.querySelector("browser");
+ let global = browsingContext.currentWindowGlobal;
+ actor = global.getExistingActor("ShoppingSidebar");
+ }
+ let isProduct = lazy.isProductURL(aLocationURI);
+ if (isProduct && this.isActive) {
+ if (!sidebar) {
+ sidebar = document.createXULElement("shopping-sidebar");
+ sidebar.hidden = false;
+ let splitter = document.createXULElement("splitter");
+ splitter.classList.add("sidebar-splitter");
+ browserPanel.appendChild(splitter);
+ browserPanel.appendChild(sidebar);
+ } else {
+ actor?.updateProductURL(aLocationURI, aFlags);
+ sidebar.hidden = false;
+ }
+ } else if (sidebar && !sidebar.hidden) {
+ actor?.updateProductURL(null);
+ sidebar.hidden = true;
+ }
+
+ this._updateBCActiveness(aBrowser);
+ this._setShoppingButtonState(aBrowser);
+
+ // Note: (bug 1868602) only record surface displayed telemetry if:
+ // - the foregrounded tab navigates to a product page with sidebar visible,
+ // - a product page tab loaded in the background is foregrounded, or
+ // - a foregrounded product page tab was loaded with the sidebar hidden and
+ // now the sidebar has been shown.
+ if (
+ this.enabled &&
+ lazy.ShoppingUtils.isProductPageNavigation(aLocationURI, aFlags)
+ ) {
+ if (
+ this.isActive &&
+ aBrowser === gBrowser.selectedBrowser &&
+ (aIsNavigation || aBrowser.isDistinctProductPageVisit)
+ ) {
+ Glean.shopping.surfaceDisplayed.record();
+ delete aBrowser.isDistinctProductPageVisit;
+ } else if (aIsNavigation) {
+ aBrowser.isDistinctProductPageVisit = true;
+ }
+ }
+
+ if (isProduct) {
+ // This is the auto-enable behavior that toggles the `active` pref. It
+ // must be at the end of this function, or 2 sidebars could be created.
+ lazy.ShoppingUtils.handleAutoActivateOnProduct();
+
+ if (!this.isActive) {
+ lazy.ShoppingUtils.sendTrigger({
+ browser: aBrowser,
+ id: "shoppingProductPageWithSidebarClosed",
+ context: { isSidebarClosing: !aIsNavigation && !!sidebar },
+ });
+ }
+ }
+ }
+
+ _maybeToggleButton(gBrowser) {
+ let optedOut = this.optedInPref === 2;
+ if (this.enabled && optedOut) {
+ this._setShoppingButtonState(gBrowser.selectedBrowser);
+ }
+ }
+
+ _updateBCActiveness(aBrowser) {
+ let gBrowser = aBrowser.getTabBrowser();
+ let document = aBrowser.ownerDocument;
+ let browserPanel = gBrowser.getPanel(aBrowser);
+ let sidebar = browserPanel.querySelector("shopping-sidebar");
+ if (!sidebar) {
+ return;
+ }
+ try {
+ // Tell Gecko when the sidebar visibility changes to avoid background
+ // sidebars taking more CPU / energy than needed.
+ sidebar.querySelector("browser").docShellIsActive =
+ !document.hidden &&
+ aBrowser == gBrowser.selectedBrowser &&
+ !sidebar.hidden;
+ } catch (ex) {
+ // The setter can throw and we do need to run the rest of this
+ // code in that case.
+ console.error(ex);
+ }
+ }
+
+ _setShoppingButtonState(aBrowser) {
+ let gBrowser = aBrowser.getTabBrowser();
+ let document = aBrowser.ownerDocument;
+ if (aBrowser !== gBrowser.selectedBrowser) {
+ return;
+ }
+
+ let button = document.getElementById("shopping-sidebar-button");
+
+ let isCurrentBrowserProduct = lazy.isProductURL(
+ gBrowser.selectedBrowser.currentURI
+ );
+
+ // Only record if the state of the icon will change from hidden to visible.
+ if (button.hidden && isCurrentBrowserProduct) {
+ Glean.shopping.addressBarIconDisplayed.record();
+ }
+
+ button.hidden = !isCurrentBrowserProduct;
+ button.setAttribute("shoppingsidebaropen", !!this.isActive);
+ let l10nId = this.isActive
+ ? "shopping-sidebar-close-button2"
+ : "shopping-sidebar-open-button2";
+ document.l10n.setAttributes(button, l10nId);
+ }
+
+ /**
+ * Called by TabsProgressListener whenever any browser navigates from one
+ * URL to another.
+ * Note that this includes hash changes / pushState navigations, because
+ * those can be significant for us.
+ */
+ onLocationChange(aBrowser, aLocationURI, aFlags) {
+ let isPBM = lazy.PrivateBrowsingUtils.isWindowPrivate(aBrowser.ownerGlobal);
+ if (isPBM) {
+ return;
+ }
+
+ lazy.ShoppingUtils.onLocationChange(aLocationURI, aFlags);
+
+ this._maybeToggleButton(aBrowser.getTabBrowser());
+ this._maybeToggleSidebar(aBrowser, aLocationURI, aFlags, true);
+ }
+
+ handleEvent(event) {
+ switch (event.type) {
+ case "TabSelect": {
+ if (!this.enabled) {
+ return;
+ }
+ this.updateSidebarVisibility();
+ if (event.detail?.previousTab.linkedBrowser) {
+ this._updateBCActiveness(event.detail.previousTab.linkedBrowser);
+ }
+ break;
+ }
+ case "visibilitychange": {
+ if (!this.enabled) {
+ return;
+ }
+ let { gBrowser } = event.target.ownerGlobal.top;
+ if (!gBrowser) {
+ return;
+ }
+ this.updateSidebarVisibilityForWindow(event.target.ownerGlobal.top);
+ this._updateBCActiveness(gBrowser.selectedBrowser);
+ }
+ }
+ }
+}
+
+const ShoppingSidebarManager = new ShoppingSidebarManagerClass();
+export { ShoppingSidebarManager };
diff --git a/browser/components/shopping/ShoppingUtils.sys.mjs b/browser/components/shopping/ShoppingUtils.sys.mjs
new file mode 100644
index 0000000000..bc61e5ce10
--- /dev/null
+++ b/browser/components/shopping/ShoppingUtils.sys.mjs
@@ -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 { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ ASRouter: "resource:///modules/asrouter/ASRouter.sys.mjs",
+ isProductURL: "chrome://global/content/shopping/ShoppingProduct.mjs",
+ NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs",
+ setTimeout: "resource://gre/modules/Timer.sys.mjs",
+});
+
+const OPTED_IN_PREF = "browser.shopping.experience2023.optedIn";
+const ACTIVE_PREF = "browser.shopping.experience2023.active";
+const LAST_AUTO_ACTIVATE_PREF =
+ "browser.shopping.experience2023.lastAutoActivate";
+const AUTO_ACTIVATE_COUNT_PREF =
+ "browser.shopping.experience2023.autoActivateCount";
+const ADS_USER_ENABLED_PREF = "browser.shopping.experience2023.ads.userEnabled";
+const AUTO_OPEN_ENABLED_PREF =
+ "browser.shopping.experience2023.autoOpen.enabled";
+const AUTO_OPEN_USER_ENABLED_PREF =
+ "browser.shopping.experience2023.autoOpen.userEnabled";
+const SIDEBAR_CLOSED_COUNT_PREF =
+ "browser.shopping.experience2023.sidebarClosedCount";
+
+const CFR_FEATURES_PREF =
+ "browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features";
+
+export const ShoppingUtils = {
+ initialized: false,
+ registered: false,
+ handledAutoActivate: false,
+ nimbusEnabled: false,
+ nimbusControl: false,
+
+ _updateNimbusVariables() {
+ this.nimbusEnabled =
+ lazy.NimbusFeatures.shopping2023.getVariable("enabled");
+ this.nimbusControl =
+ lazy.NimbusFeatures.shopping2023.getVariable("control");
+ },
+
+ onNimbusUpdate() {
+ this._updateNimbusVariables();
+ if (this.nimbusEnabled) {
+ ShoppingUtils.init();
+ Glean.shoppingSettings.nimbusDisabledShopping.set(false);
+ } else {
+ ShoppingUtils.uninit();
+ Glean.shoppingSettings.nimbusDisabledShopping.set(true);
+ }
+ },
+
+ // Runs once per session:
+ // * at application startup, with startup idle tasks,
+ // * or after the user is enrolled in the Nimbus experiment.
+ init() {
+ if (this.initialized) {
+ return;
+ }
+ this.onNimbusUpdate = this.onNimbusUpdate.bind(this);
+ this.onActiveUpdate = this.onActiveUpdate.bind(this);
+
+ if (!this.registered) {
+ // Note (bug 1855545): we must set `this.registered` before calling
+ // `onUpdate`, as it will immediately invoke `this.onNimbusUpdate`,
+ // which in turn calls `ShoppingUtils.init`, creating an infinite loop.
+ this.registered = true;
+ lazy.NimbusFeatures.shopping2023.onUpdate(this.onNimbusUpdate);
+ this._updateNimbusVariables();
+ }
+
+ if (!this.nimbusEnabled) {
+ return;
+ }
+
+ // Do startup-time stuff here, like recording startup-time glean events
+ // or adjusting onboarding-related prefs once per session.
+
+ this.setOnUpdate(undefined, undefined, this.optedIn);
+ this.recordUserAdsPreference();
+ this.recordUserAutoOpenPreference();
+
+ if (this._isAutoOpenEligible()) {
+ Services.prefs.setBoolPref(ACTIVE_PREF, true);
+ }
+ Services.prefs.addObserver(ACTIVE_PREF, this.onActiveUpdate);
+
+ Services.prefs.setIntPref(SIDEBAR_CLOSED_COUNT_PREF, 0);
+
+ this.initialized = true;
+ },
+
+ // Runs once per session:
+ // * when the user is unenrolled from the Nimbus experiment,
+ // * or at shutdown, after quit-application-granted.
+ uninit() {
+ if (!this.initialized) {
+ return;
+ }
+
+ // Do shutdown-time stuff here, like firing glean pings or modifying any
+ // prefs for onboarding.
+
+ Services.prefs.removeObserver(ACTIVE_PREF, this.onActiveUpdate);
+
+ this.initialized = false;
+ },
+
+ isProductPageNavigation(aLocationURI, aFlags) {
+ if (!lazy.isProductURL(aLocationURI)) {
+ return false;
+ }
+
+ // Ignore same-document navigation, except in the case of Walmart
+ // as they use pushState to navigate between pages.
+ let isWalmart = aLocationURI.host.includes("walmart");
+ let isNewDocument = !aFlags;
+
+ let isSameDocument =
+ aFlags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT;
+ let isReload = aFlags & Ci.nsIWebProgressListener.LOCATION_CHANGE_RELOAD;
+ let isSessionRestore =
+ aFlags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SESSION_STORE;
+
+ // Unfortunately, Walmart sometimes double-fires history manipulation
+ // events when navigating between product pages. To dedupe, cache the
+ // last visited Walmart URL just for a few milliseconds, so we can avoid
+ // double-counting such navigations.
+ if (isWalmart) {
+ if (
+ this.lastWalmartURI &&
+ aLocationURI.equalsExceptRef(this.lastWalmartURI)
+ ) {
+ return false;
+ }
+ this.lastWalmartURI = aLocationURI;
+ lazy.setTimeout(() => {
+ this.lastWalmartURI = null;
+ }, 100);
+ }
+
+ return (
+ // On initial visit to a product page, even from another domain, both a page
+ // load and a pushState will be triggered by Walmart, so this will
+ // capture only a single displayed event.
+ (!isWalmart && (isNewDocument || isReload || isSessionRestore)) ||
+ (isWalmart && isSameDocument)
+ );
+ },
+
+ // For users in either the nimbus control or treatment groups, increment a
+ // counter when they visit supported product pages.
+ recordExposure() {
+ if (this.nimbusEnabled || this.nimbusControl) {
+ Glean.shopping.productPageVisits.add(1);
+ }
+ },
+
+ setOnUpdate(_pref, _prev, current) {
+ Glean.shoppingSettings.componentOptedOut.set(current === 2);
+ Glean.shoppingSettings.hasOnboarded.set(current > 0);
+ },
+
+ recordUserAdsPreference() {
+ Glean.shoppingSettings.disabledAds.set(!ShoppingUtils.adsUserEnabled);
+ },
+
+ recordUserAutoOpenPreference() {
+ Glean.shoppingSettings.autoOpenUserDisabled.set(
+ !ShoppingUtils.autoOpenUserEnabled
+ );
+ },
+
+ /**
+ * If the user has not opted in, automatically set the sidebar to `active` if:
+ * 1. The sidebar has not already been automatically set to `active` twice.
+ * 2. It's been at least 24 hours since the user last saw the sidebar because
+ * of this auto-activation behavior.
+ * 3. This method has not already been called (handledAutoActivate is false)
+ */
+ handleAutoActivateOnProduct() {
+ if (!this.handledAutoActivate && !this.optedIn && this.cfrFeatures) {
+ let autoActivateCount = Services.prefs.getIntPref(
+ AUTO_ACTIVATE_COUNT_PREF,
+ 0
+ );
+ let lastAutoActivate = Services.prefs.getIntPref(
+ LAST_AUTO_ACTIVATE_PREF,
+ 0
+ );
+ let now = Date.now() / 1000;
+ // If we automatically set `active` to true in a previous session less
+ // than 24 hours ago, set it to false now. This is done to prevent the
+ // auto-activation state from persisting between sessions. Effectively,
+ // the auto-activation will persist until either 1) the sidebar is closed,
+ // or 2) Firefox restarts.
+ if (now - lastAutoActivate < 24 * 60 * 60) {
+ Services.prefs.setBoolPref(ACTIVE_PREF, false);
+ }
+ // Set active to true if we haven't done so recently nor more than twice.
+ else if (autoActivateCount < 2) {
+ Services.prefs.setBoolPref(ACTIVE_PREF, true);
+ Services.prefs.setIntPref(
+ AUTO_ACTIVATE_COUNT_PREF,
+ autoActivateCount + 1
+ );
+ Services.prefs.setIntPref(LAST_AUTO_ACTIVATE_PREF, now);
+ }
+ }
+ this.handledAutoActivate = true;
+ },
+
+ /**
+ * Send a Shopping-related trigger message to ASRouter.
+ *
+ * @param {object} trigger The trigger object to send to ASRouter.
+ * @param {object} trigger.context Additional trigger properties to pass to
+ * the targeting context.
+ * @param {string} trigger.id The id of the trigger.
+ * @param {MozBrowser} trigger.browser The browser to associate with the
+ * trigger. (This can determine the tab/window the message is shown in,
+ * depending on the message surface)
+ */
+ async sendTrigger(trigger) {
+ await lazy.ASRouter.waitForInitialized;
+ await lazy.ASRouter.sendTriggerMessage(trigger);
+ },
+
+ onActiveUpdate(subject, topic, data) {
+ if (data !== ACTIVE_PREF || topic !== "nsPref:changed") {
+ return;
+ }
+
+ let newValue = Services.prefs.getBoolPref(ACTIVE_PREF);
+ if (newValue === false) {
+ ShoppingUtils.resetActiveOnNextProductPage = true;
+ }
+ },
+
+ _isAutoOpenEligible() {
+ return (
+ this.optedIn === 1 && this.autoOpenEnabled && this.autoOpenUserEnabled
+ );
+ },
+
+ onLocationChange(aLocationURI, aFlags) {
+ let isProductPageNavigation = this.isProductPageNavigation(
+ aLocationURI,
+ aFlags
+ );
+
+ if (isProductPageNavigation) {
+ this.recordExposure(aLocationURI, aFlags);
+ }
+
+ if (
+ this._isAutoOpenEligible() &&
+ this.resetActiveOnNextProductPage &&
+ isProductPageNavigation
+ ) {
+ this.resetActiveOnNextProductPage = false;
+ Services.prefs.setBoolPref(ACTIVE_PREF, true);
+ }
+ },
+};
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ ShoppingUtils,
+ "optedIn",
+ OPTED_IN_PREF,
+ 0,
+ ShoppingUtils.setOnUpdate
+);
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ ShoppingUtils,
+ "cfrFeatures",
+ CFR_FEATURES_PREF,
+ true
+);
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ ShoppingUtils,
+ "adsUserEnabled",
+ ADS_USER_ENABLED_PREF,
+ false,
+ ShoppingUtils.recordUserAdsPreference
+);
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ ShoppingUtils,
+ "autoOpenEnabled",
+ AUTO_OPEN_ENABLED_PREF,
+ false
+);
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ ShoppingUtils,
+ "autoOpenUserEnabled",
+ AUTO_OPEN_USER_ENABLED_PREF,
+ false,
+ ShoppingUtils.recordUserAutoOpenPreference
+);
diff --git a/browser/components/shopping/content/adjusted-rating.mjs b/browser/components/shopping/content/adjusted-rating.mjs
new file mode 100644
index 0000000000..a49b821145
--- /dev/null
+++ b/browser/components/shopping/content/adjusted-rating.mjs
@@ -0,0 +1,53 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*-
+ * 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 { html } from "chrome://global/content/vendor/lit.all.mjs";
+import { MozLitElement } from "chrome://global/content/lit-utils.mjs";
+
+// eslint-disable-next-line import/no-unassigned-import
+import "chrome://global/content/elements/moz-five-star.mjs";
+
+/**
+ * Class for displaying the adjusted ratings for a given product.
+ */
+class AdjustedRating extends MozLitElement {
+ static properties = {
+ rating: { type: Number, reflect: true },
+ };
+
+ static get queries() {
+ return {
+ ratingEl: "moz-five-star",
+ };
+ }
+
+ render() {
+ if (!this.rating && this.rating !== 0) {
+ this.hidden = true;
+ return null;
+ }
+
+ this.hidden = false;
+
+ return html`
+ <shopping-card
+ data-l10n-id="shopping-adjusted-rating-label"
+ data-l10n-attrs="label"
+ >
+ <moz-five-star
+ slot="rating"
+ rating="${this.rating === 0 ? 0.5 : this.rating}"
+ ></moz-five-star>
+ <div slot="content">
+ <span
+ data-l10n-id="shopping-adjusted-rating-unreliable-reviews"
+ ></span>
+ </div>
+ </shopping-card>
+ `;
+ }
+}
+
+customElements.define("adjusted-rating", AdjustedRating);
diff --git a/browser/components/shopping/content/analysis-explainer.css b/browser/components/shopping/content/analysis-explainer.css
new file mode 100644
index 0000000000..ce7ebf55cc
--- /dev/null
+++ b/browser/components/shopping/content/analysis-explainer.css
@@ -0,0 +1,39 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+* License, v. 2.0. If a copy of the MPL was not distributed with this
+* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+@import url("chrome://global/skin/in-content/common.css");
+
+#analysis-explainer-wrapper p,
+.analysis-explainer-grading-scale-description {
+ line-height: 150%;
+}
+
+#analysis-explainer-grading-scale-wrapper {
+ margin-inline-start: 0.54em;
+}
+
+#analysis-explainer-grading-scale-list {
+ list-style: none;
+ padding: 0;
+}
+
+.analysis-explainer-grading-scale-entry {
+ display: flex;
+ align-items: flex-start;
+ align-self: stretch;
+ gap: 0.54rem;
+ padding: 0.54em;
+ padding-inline-start: 0;
+}
+
+.analysis-explainer-grading-scale-letters {
+ display: flex;
+ align-items: flex-start;
+ gap: 0.2rem;
+ width: 3.47em;
+}
+
+.analysis-explainer-grading-scale-description {
+ margin: 0;
+}
diff --git a/browser/components/shopping/content/analysis-explainer.mjs b/browser/components/shopping/content/analysis-explainer.mjs
new file mode 100644
index 0000000000..a32c64dfeb
--- /dev/null
+++ b/browser/components/shopping/content/analysis-explainer.mjs
@@ -0,0 +1,158 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*-
+ * 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 { html } from "chrome://global/content/vendor/lit.all.mjs";
+import { MozLitElement } from "chrome://global/content/lit-utils.mjs";
+
+// eslint-disable-next-line import/no-unassigned-import
+import "chrome://browser/content/shopping/shopping-card.mjs";
+// eslint-disable-next-line import/no-unassigned-import
+import "chrome://browser/content/shopping/letter-grade.mjs";
+// eslint-disable-next-line import/no-unassigned-import
+import "chrome://global/content/elements/moz-support-link.mjs";
+
+const VALID_EXPLAINER_L10N_IDS = new Map([
+ ["reliable", "shopping-analysis-explainer-review-grading-scale-reliable"],
+ ["mixed", "shopping-analysis-explainer-review-grading-scale-mixed"],
+ ["unreliable", "shopping-analysis-explainer-review-grading-scale-unreliable"],
+]);
+
+/**
+ * Class for displaying details about letter grades, adjusted rating, and highlights.
+ */
+class AnalysisExplainer extends MozLitElement {
+ static properties = {
+ productUrl: { type: String, reflect: true },
+ };
+
+ static get queries() {
+ return {
+ reviewQualityExplainerLink: "#review-quality-url",
+ };
+ }
+
+ getGradesDescriptionTemplate() {
+ return html`
+ <section id="analysis-explainer-grades-wrapper">
+ <p data-l10n-id="shopping-analysis-explainer-grades-intro"></p>
+ </section>
+ `;
+ }
+
+ createGradingScaleEntry(letters, descriptionL10nId) {
+ let letterGradesTemplate = [];
+ for (let letter of letters) {
+ letterGradesTemplate.push(
+ html`<letter-grade letter=${letter}></letter-grade>`
+ );
+ }
+ return html`
+ <div class="analysis-explainer-grading-scale-entry">
+ <dt class="analysis-explainer-grading-scale-term">
+ <span class="analysis-explainer-grading-scale-letters">
+ ${letterGradesTemplate}
+ </span>
+ </dt>
+ <dd
+ class="analysis-explainer-grading-scale-description"
+ data-l10n-id=${descriptionL10nId}
+ ></dd>
+ </div>
+ `;
+ }
+
+ getGradingScaleListTemplate() {
+ return html`
+ <section id="analysis-explainer-grading-scale-wrapper">
+ <dl id="analysis-explainer-grading-scale-list">
+ ${this.createGradingScaleEntry(
+ ["A", "B"],
+ VALID_EXPLAINER_L10N_IDS.get("reliable")
+ )}
+ ${this.createGradingScaleEntry(
+ ["C"],
+ VALID_EXPLAINER_L10N_IDS.get("mixed")
+ )}
+ ${this.createGradingScaleEntry(
+ ["D", "F"],
+ VALID_EXPLAINER_L10N_IDS.get("unreliable")
+ )}
+ </dl>
+ </section>
+ `;
+ }
+
+ // It turns out we must always return a non-empty string: if not, the fluent
+ // resolver will complain that the variable value is missing. We use the
+ // placeholder "retailer", which should never be visible to users.
+ getRetailerDisplayName() {
+ let defaultName = "retailer";
+ if (!this.productUrl) {
+ return defaultName;
+ }
+ let url = new URL(this.productUrl);
+ let hostname = url.hostname;
+ let displayNames = {
+ "www.amazon.com": "Amazon",
+ "www.bestbuy.com": "Best Buy",
+ "www.walmart.com": "Walmart",
+ };
+ return displayNames[hostname] ?? defaultName;
+ }
+
+ handleReviewQualityUrlClicked(e) {
+ if (e.target.localName == "a" && e.button == 0) {
+ Glean.shopping.surfaceShowQualityExplainerUrlClicked.record();
+ }
+ }
+
+ // Bug 1857620: rather than manually set the utm parameters on the SUMO link,
+ // we should instead update moz-support-link to allow arbitrary utm parameters.
+ render() {
+ return html`
+ <link
+ rel="stylesheet"
+ href="chrome://browser/content/shopping/analysis-explainer.css"
+ />
+ <shopping-card
+ data-l10n-id="shopping-analysis-explainer-label"
+ data-l10n-attrs="label"
+ type="accordion"
+ >
+ <div slot="content">
+ <div id="analysis-explainer-wrapper">
+ <p data-l10n-id="shopping-analysis-explainer-intro2"></p>
+ ${this.getGradesDescriptionTemplate()}
+ ${this.getGradingScaleListTemplate()}
+ <p
+ data-l10n-id="shopping-analysis-explainer-adjusted-rating-description"
+ ></p>
+ <p
+ data-l10n-id="shopping-analysis-explainer-highlights-description"
+ data-l10n-args="${JSON.stringify({
+ retailer: this.getRetailerDisplayName(),
+ })}"
+ ></p>
+ <p
+ data-l10n-id="shopping-analysis-explainer-learn-more2"
+ @click=${this.handleReviewQualityUrlClicked}
+ >
+ <a
+ id="review-quality-url"
+ data-l10n-name="review-quality-url"
+ target="_blank"
+ href="${window.RPMGetFormatURLPref(
+ "app.support.baseURL"
+ )}review-checker-review-quality?as=u&utm_source=inproduct&utm_campaign=learn-more&utm_term=core-sidebar"
+ ></a>
+ </p>
+ </div>
+ </div>
+ </shopping-card>
+ `;
+ }
+}
+
+customElements.define("analysis-explainer", AnalysisExplainer);
diff --git a/browser/components/shopping/content/assets/competitiveness.svg b/browser/components/shopping/content/assets/competitiveness.svg
new file mode 100644
index 0000000000..a192205fc2
--- /dev/null
+++ b/browser/components/shopping/content/assets/competitiveness.svg
@@ -0,0 +1,6 @@
+<!-- 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/. -->
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="context-fill" fill-opacity="context-fill-opacity">
+<path fill-rule="evenodd" clip-rule="evenodd" d="M3 1.75A.75.75 0 0 1 3.75 1h8.5a.75.75 0 0 1 .75.75V3h2.25a.75.75 0 0 1 .75.75v2.5a3.75 3.75 0 0 1-3.41 3.735 5.007 5.007 0 0 1-3.84 2.96V14.5H12V16H4v-1.5h3.25v-1.556a5.007 5.007 0 0 1-3.84-2.96A3.75 3.75 0 0 1 0 6.25v-2.5A.75.75 0 0 1 .75 3H3V1.75zm9.986 6.627A2.25 2.25 0 0 0 14.5 6.25V4.5H13V8c0 .127-.005.252-.014.377zM3 8c0 .127.005.252.014.377A2.25 2.25 0 0 1 1.5 6.25V4.5H3V8zm1.5-5.5V8a3.5 3.5 0 0 0 7 0V2.5h-7z"/>
+</svg>
diff --git a/browser/components/shopping/content/assets/optInDark.avif b/browser/components/shopping/content/assets/optInDark.avif
new file mode 100644
index 0000000000..fb662e63dd
--- /dev/null
+++ b/browser/components/shopping/content/assets/optInDark.avif
Binary files differ
diff --git a/browser/components/shopping/content/assets/optInLight.avif b/browser/components/shopping/content/assets/optInLight.avif
new file mode 100644
index 0000000000..e148580976
--- /dev/null
+++ b/browser/components/shopping/content/assets/optInLight.avif
Binary files differ
diff --git a/browser/components/shopping/content/assets/packaging.svg b/browser/components/shopping/content/assets/packaging.svg
new file mode 100644
index 0000000000..fddf88c878
--- /dev/null
+++ b/browser/components/shopping/content/assets/packaging.svg
@@ -0,0 +1,6 @@
+<!-- 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/. -->
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="context-fill" fill-opacity="context-fill-opacity">
+<path fill-rule="evenodd" d="M3 1a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3a2 2 0 0 0-2-2H3Zm-.5 2a.5.5 0 0 1 .5-.5h2v5a.75.75 0 0 0 1.114.656L8 7.108l1.886 1.048A.75.75 0 0 0 11 7.5v-5h2a.5.5 0 0 1 .5.5v10a.5.5 0 0 1-.5.5H3a.5.5 0 0 1-.5-.5V3Zm7-.5h-3v3.725l1.136-.63a.75.75 0 0 1 .728 0l1.136.63V2.5Z"/>
+</svg>
diff --git a/browser/components/shopping/content/assets/price.svg b/browser/components/shopping/content/assets/price.svg
new file mode 100644
index 0000000000..e5b8ba7a22
--- /dev/null
+++ b/browser/components/shopping/content/assets/price.svg
@@ -0,0 +1,7 @@
+<!-- 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/. -->
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="context-fill" fill-opacity="context-fill-opacity">
+<path d="M0 .75A.75.75 0 0 1 .75 0h6.717a.75.75 0 0 1 .53.22l6.418 6.417a2 2 0 0 1 0 2.828l-4.95 4.95a2 2 0 0 1-2.828 0L.22 7.998a.75.75 0 0 1-.22-.53V.75Zm1.5.75v5.657l6.197 6.198a.5.5 0 0 0 .708 0l4.95-4.95a.5.5 0 0 0 0-.708L7.156 1.5H1.5Z"/>
+<path d="m4.58 5.47 1.061-1.06-1.06-1.061-1.061 1.06L4.58 5.47Z"/>
+</svg>
diff --git a/browser/components/shopping/content/assets/priceTagButtonCallout.svg b/browser/components/shopping/content/assets/priceTagButtonCallout.svg
new file mode 100644
index 0000000000..26431804cb
--- /dev/null
+++ b/browser/components/shopping/content/assets/priceTagButtonCallout.svg
@@ -0,0 +1,41 @@
+<!-- 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 https://mozilla.org/MPL/2.0/. -->
+<svg width="352" height="214" viewBox="0 0 352 214" fill="none" xmlns="http://www.w3.org/2000/svg">
+<g clip-path="url(#clip0_860_36488)">
+<rect x="-5.65186" y="-7.44189" width="357.558" height="250" fill="hsla(252, 88%, 70%, 22%)"/>
+<g filter="url(#filter0_d_860_36488)">
+<rect x="-40.5356" y="52.4418" width="340.116" height="207.558" rx="18.6047" fill="#F9F9FB"/>
+</g>
+<path d="M-23.6753 52.4418H282.139C291.772 52.4418 299.581 60.2508 299.581 69.8836V173.953H-23.6753V52.4418Z" fill="#E0E0E6"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M273.703 137.919H258.599C258.366 137.919 258.143 137.826 257.978 137.662C257.813 137.497 257.721 137.274 257.721 137.041C257.721 136.808 257.813 136.585 257.978 136.42C258.143 136.255 258.366 136.163 258.599 136.163H273.703C273.936 136.163 274.159 136.255 274.324 136.42C274.489 136.585 274.581 136.808 274.581 137.041C274.581 137.274 274.489 137.497 274.324 137.662C274.159 137.826 273.936 137.919 273.703 137.919ZM273.703 143.539H258.599C258.366 143.539 258.143 143.447 257.978 143.282C257.813 143.118 257.721 142.894 257.721 142.661C257.721 142.428 257.813 142.205 257.978 142.04C258.143 141.876 258.366 141.783 258.599 141.783H273.703C273.936 141.783 274.159 141.876 274.324 142.04C274.489 142.205 274.581 142.428 274.581 142.661C274.581 142.894 274.489 143.118 274.324 143.282C274.159 143.447 273.936 143.539 273.703 143.539ZM258.599 149.159H273.703C273.936 149.159 274.159 149.067 274.324 148.902C274.489 148.737 274.581 148.514 274.581 148.281C274.581 148.048 274.489 147.825 274.324 147.66C274.159 147.495 273.936 147.403 273.703 147.403H258.599C258.366 147.403 258.143 147.495 257.978 147.66C257.813 147.825 257.721 148.048 257.721 148.281C257.721 148.514 257.813 148.737 257.978 148.902C258.143 149.067 258.366 149.159 258.599 149.159Z" fill="#8F8F9D"/>
+<path d="M-23.6753 52.4418H282.139C291.772 52.4418 299.581 60.2508 299.581 69.8836V111.163H-23.6753V52.4418Z" fill="#CFCFD8"/>
+<path d="M180.51 82.0315V83.363H167.208V82.0315H180.51Z" fill="#8F8F9D"/>
+<path d="M226.871 76.7108V90.0127H213.57V76.7108H226.871ZM225.54 78.0423H214.901V88.6812H225.54V78.0423Z" fill="#8F8F9D"/>
+<path d="M267.524 83.3619L273.233 89.071L272.291 90.0128L266.582 84.3037L260.873 90.0128L259.931 89.071L265.64 83.3619L259.931 77.6527L260.873 76.7109L266.582 82.4201L272.291 76.7109L273.233 77.6527L267.524 83.3619Z" fill="#8F8F9D"/>
+<rect x="-50.75" y="120.134" width="220.43" height="46.0116" rx="4.40116" fill="white" stroke="#5B5B66" stroke-width="0.5"/>
+<rect x="84.1739" y="125.988" width="34.8837" height="34.8837" rx="5.52326" stroke="#0060DF" stroke-width="1.74419"/>
+<rect x="-19.605" y="173.953" width="319.186" height="76.7442" fill="#F9F9FB"/>
+<path d="M103.624 132.093H94.9371V134.13H103.2L110.926 141.88L112.372 140.443L104.346 132.393C104.155 132.201 103.895 132.093 103.624 132.093Z" fill="#15141A"/>
+<path d="M98.3554 144.177L100.004 142.53L98.3554 140.883L96.7069 142.53L98.3554 144.177Z" fill="#15141A"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M92.0229 137.187C92.0229 136.624 92.4796 136.168 93.0429 136.168H101.511C101.782 136.168 102.041 136.275 102.233 136.466L110.083 144.308C111.222 145.445 111.222 147.287 110.083 148.424L104.004 154.496C102.866 155.633 101.022 155.633 99.8833 154.496L92.3217 146.943C92.1304 146.752 92.0229 146.493 92.0229 146.223V137.187ZM94.0628 138.205V145.801L101.326 153.055C101.668 153.397 102.22 153.397 102.562 153.055L108.641 146.983C108.983 146.642 108.983 146.09 108.641 145.749L101.089 138.205H94.0628Z" fill="#15141A"/>
+<path d="M138.723 154.174C138.205 154.174 137.676 154.012 137.24 153.698C136.392 153.093 136.016 152.058 136.275 151.058L137.523 146.232L133.638 143.046C132.837 142.384 132.519 141.325 132.849 140.349C133.167 139.372 134.05 138.686 135.097 138.628L140.136 138.302L141.996 133.674C142.385 132.721 143.303 132.093 144.351 132.093C145.398 132.093 146.317 132.709 146.705 133.674L148.565 138.302L153.604 138.628C154.64 138.698 155.523 139.372 155.852 140.349C156.17 141.325 155.864 142.395 155.064 143.046L151.179 146.232L152.427 151.058C152.686 152.058 152.309 153.093 151.461 153.698C150.614 154.302 149.495 154.337 148.612 153.791L144.351 151.128L140.089 153.791C139.677 154.046 139.206 154.186 138.735 154.186L138.723 154.174ZM144.339 134.116C144.221 134.116 143.997 134.151 143.903 134.407L141.796 139.639C141.655 140 141.302 140.256 140.901 140.279L135.215 140.639C134.933 140.663 134.827 140.849 134.791 140.965C134.756 141.058 134.721 141.291 134.944 141.477L139.324 145.07C139.63 145.325 139.759 145.721 139.665 146.105L138.264 151.558C138.194 151.825 138.347 151.988 138.441 152.058C138.535 152.128 138.735 152.221 138.971 152.07L143.786 149.058C144.127 148.849 144.551 148.849 144.88 149.058L149.695 152.07C149.931 152.221 150.131 152.128 150.225 152.058C150.319 151.988 150.472 151.825 150.402 151.558L148.989 146.105C148.895 145.721 149.024 145.325 149.33 145.07L153.71 141.477C153.933 141.291 153.886 141.058 153.863 140.977C153.827 140.86 153.722 140.674 153.439 140.651L147.753 140.291C147.353 140.267 147.011 140.012 146.858 139.651L144.751 134.418C144.645 134.163 144.433 134.128 144.315 134.128L144.339 134.116Z" fill="#8F8F9D"/>
+<line x1="299.581" y1="111.413" x2="-19.605" y2="111.413" stroke="#5B5B66" stroke-width="0.5"/>
+<line x1="299.581" y1="174.203" x2="-35.3027" y2="174.203" stroke="#5B5B66" stroke-width="0.5"/>
+<path d="M-30.6519 52.4418H280.976C291.251 52.4418 299.581 60.7714 299.581 71.0464V242.558" stroke="#5B5B66" stroke-width="0.5"/>
+</g>
+<defs>
+<filter id="filter0_d_860_36488" x="-49.2566" y="40.2325" width="361.046" height="228.488" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
+<feFlood flood-opacity="0" result="BackgroundImageFix"/>
+<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
+<feOffset dx="1.74419" dy="-1.74419"/>
+<feGaussianBlur stdDeviation="5.23256"/>
+<feColorMatrix type="matrix" values="0 0 0 0 0.227451 0 0 0 0 0.223529 0 0 0 0 0.266667 0 0 0 0.2 0"/>
+<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_860_36488"/>
+<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_860_36488" result="shape"/>
+</filter>
+<clipPath id="clip0_860_36488">
+<rect width="352" height="214" fill="white"/>
+</clipPath>
+</defs>
+</svg>
diff --git a/browser/components/shopping/content/assets/quality.svg b/browser/components/shopping/content/assets/quality.svg
new file mode 100644
index 0000000000..cdc092d1ea
--- /dev/null
+++ b/browser/components/shopping/content/assets/quality.svg
@@ -0,0 +1,7 @@
+<!-- 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/. -->
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="context-fill" fill-opacity="context-fill-opacity">
+<path d="M8.035 9.713 11 6.748l-1.061-1.06-2.434 2.434L6.06 6.677 5 7.737l1.975 1.976a.75.75 0 0 0 1.06 0Z"/>
+<path d="M10.586 1.723 9.414.552a2 2 0 0 0-2.828 0L5.414 1.723H3.757a2 2 0 0 0-2 2v1.656L.585 6.552a2 2 0 0 0 0 2.828l1.172 1.172v1.657a2 2 0 0 0 2 2h1.657l1.172 1.171a2 2 0 0 0 2.828 0l1.172-1.171h1.657a2 2 0 0 0 2-2v-1.657l1.172-1.172a2 2 0 0 0 0-2.828l-1.172-1.173V3.723a2 2 0 0 0-2-2h-1.657Zm-2.94-.11a.5.5 0 0 1 .708 0l1.392 1.39c.14.141.331.22.53.22h1.967a.5.5 0 0 1 .5.5V5.69c0 .199.079.39.22.53l1.39 1.392a.5.5 0 0 1 0 .708l-1.39 1.39a.75.75 0 0 0-.22.531v1.968a.5.5 0 0 1-.5.5h-1.968a.75.75 0 0 0-.53.22l-1.391 1.39a.5.5 0 0 1-.708 0l-1.39-1.39a.75.75 0 0 0-.531-.22H3.757a.5.5 0 0 1-.5-.5v-1.968a.75.75 0 0 0-.22-.53L1.647 8.32a.5.5 0 0 1 0-.708l1.39-1.392a.75.75 0 0 0 .22-.53V3.723a.5.5 0 0 1 .5-.5h1.968a.75.75 0 0 0 .53-.22l1.391-1.39Z"/>
+</svg>
diff --git a/browser/components/shopping/content/assets/ratingDark.avif b/browser/components/shopping/content/assets/ratingDark.avif
new file mode 100644
index 0000000000..240c2d6238
--- /dev/null
+++ b/browser/components/shopping/content/assets/ratingDark.avif
Binary files differ
diff --git a/browser/components/shopping/content/assets/ratingLight.avif b/browser/components/shopping/content/assets/ratingLight.avif
new file mode 100644
index 0000000000..a6eb3a0316
--- /dev/null
+++ b/browser/components/shopping/content/assets/ratingLight.avif
Binary files differ
diff --git a/browser/components/shopping/content/assets/reviewsVisualCallout.svg b/browser/components/shopping/content/assets/reviewsVisualCallout.svg
new file mode 100644
index 0000000000..9a9b93ba06
--- /dev/null
+++ b/browser/components/shopping/content/assets/reviewsVisualCallout.svg
@@ -0,0 +1,77 @@
+<!-- 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 https://mozilla.org/MPL/2.0/. -->
+<svg width="352" height="214" viewBox="0 0 352 214" fill="none" xmlns="http://www.w3.org/2000/svg">
+<g clip-path="url(#clip0_860_36523)">
+<rect width="352" height="214" fill="hsla(252, 88%, 70%, 22%)"/>
+<g filter="url(#filter0_d_860_36523)">
+<path d="M301.2 27.6845C301.2 22.8882 297.312 19 292.515 19H58.6845C53.8882 19 50 22.8882 50 27.6845V75.1155C50 79.9118 53.8882 83.8 58.6845 83.8H292.515C297.312 83.8 301.2 79.9118 301.2 75.1155V27.6845Z" fill="white"/>
+</g>
+<path d="M66.8272 36.3504L64.3962 34.3317C64.2369 34.2002 64.1209 34.0236 64.0636 33.825C64.0063 33.6265 64.0102 33.4153 64.0749 33.219C64.1379 33.0226 64.2588 32.8497 64.4217 32.723C64.5846 32.5964 64.7819 32.5219 64.9878 32.5093L68.1303 32.3061L68.5561 31.9874L69.7155 29.0744C69.8736 28.68 70.2493 28.425 70.6735 28.425C71.0976 28.425 71.4733 28.68 71.6314 29.0736V29.0744L72.7908 31.9874L73.2167 32.3061L76.3591 32.5093C76.7824 32.5365 77.1411 32.8153 77.272 33.219C77.4038 33.6236 77.2771 34.0605 76.9507 34.3317L74.524 36.347L74.371 36.8502L75.1445 39.889C75.249 40.3012 75.0943 40.7271 74.7509 40.9761C74.5842 41.0974 74.3849 41.1658 74.1788 41.1726C73.9727 41.1795 73.7694 41.1243 73.5949 41.0144L70.937 39.3339H70.4091L67.752 41.0144C67.3899 41.2405 66.936 41.2243 66.5952 40.9761C66.4277 40.8557 66.3006 40.6874 66.2304 40.4934C66.1601 40.2994 66.1501 40.0888 66.2016 39.889L66.9734 36.8579L66.8272 36.3504Z" fill="#FFA436"/>
+<path d="M83.8272 36.3504L81.3962 34.3317C81.2369 34.2002 81.1209 34.0236 81.0636 33.825C81.0063 33.6265 81.0102 33.4153 81.0749 33.219C81.1379 33.0226 81.2588 32.8497 81.4217 32.723C81.5846 32.5964 81.7819 32.5219 81.9878 32.5093L85.1303 32.3061L85.5561 31.9874L86.7155 29.0744C86.8736 28.68 87.2493 28.425 87.6735 28.425C88.0976 28.425 88.4733 28.68 88.6314 29.0736V29.0744L89.7908 31.9874L90.2167 32.3061L93.3591 32.5093C93.7824 32.5365 94.1411 32.8153 94.272 33.219C94.4038 33.6236 94.2771 34.0605 93.9507 34.3317L91.524 36.347L91.371 36.8502L92.1445 39.889C92.249 40.3012 92.0943 40.7271 91.7509 40.9761C91.5842 41.0974 91.3849 41.1658 91.1788 41.1726C90.9727 41.1795 90.7694 41.1243 90.5949 41.0144L87.937 39.3339H87.4091L84.752 41.0144C84.3899 41.2405 83.936 41.2243 83.5952 40.9761C83.4277 40.8557 83.3006 40.6874 83.2304 40.4934C83.1601 40.2994 83.1501 40.0888 83.2016 39.889L83.9734 36.8579L83.8272 36.3504Z" fill="#FFA436"/>
+<path d="M100.827 36.3504L98.3962 34.3317C98.2369 34.2002 98.1209 34.0236 98.0636 33.825C98.0063 33.6265 98.0102 33.4153 98.0749 33.219C98.1379 33.0226 98.2588 32.8497 98.4217 32.723C98.5846 32.5964 98.7819 32.5219 98.9878 32.5093L102.13 32.3061L102.556 31.9874L103.716 29.0744C103.874 28.68 104.249 28.425 104.673 28.425C105.098 28.425 105.473 28.68 105.631 29.0736V29.0744L106.791 31.9874L107.217 32.3061L110.359 32.5093C110.782 32.5365 111.141 32.8153 111.272 33.219C111.404 33.6236 111.277 34.0605 110.951 34.3317L108.524 36.347L108.371 36.8502L109.144 39.889C109.249 40.3012 109.094 40.7271 108.751 40.9761C108.584 41.0974 108.385 41.1658 108.179 41.1726C107.973 41.1795 107.769 41.1243 107.595 41.0144L104.937 39.3339H104.409L101.752 41.0144C101.39 41.2405 100.936 41.2243 100.595 40.9761C100.428 40.8557 100.301 40.6874 100.23 40.4934C100.16 40.2994 100.15 40.0888 100.202 39.889L100.973 36.8579L100.827 36.3504Z" fill="#FFA436"/>
+<path d="M117.827 36.3504L115.396 34.3317C115.237 34.2002 115.121 34.0236 115.064 33.825C115.006 33.6265 115.01 33.4153 115.075 33.219C115.138 33.0226 115.259 32.8497 115.422 32.723C115.585 32.5964 115.782 32.5219 115.988 32.5093L119.13 32.3061L119.556 31.9874L120.716 29.0744C120.874 28.68 121.249 28.425 121.673 28.425C122.098 28.425 122.473 28.68 122.631 29.0736V29.0744L123.791 31.9874L124.217 32.3061L127.359 32.5093C127.782 32.5365 128.141 32.8153 128.272 33.219C128.404 33.6236 128.277 34.0605 127.951 34.3317L125.524 36.347L125.371 36.8502L126.144 39.889C126.249 40.3012 126.094 40.7271 125.751 40.9761C125.584 41.0974 125.385 41.1658 125.179 41.1726C124.973 41.1795 124.769 41.1243 124.595 41.0144L121.937 39.3339H121.409L118.752 41.0144C118.39 41.2405 117.936 41.2243 117.595 40.9761C117.428 40.8557 117.301 40.6874 117.23 40.4934C117.16 40.2994 117.15 40.0888 117.202 39.889L117.973 36.8579L117.827 36.3504Z" fill="#FFA436"/>
+<path d="M135.431 41.1742C135.145 41.1742 134.86 41.0858 134.617 40.9081C134.392 40.746 134.222 40.5196 134.127 40.2587C134.033 39.9979 134.02 39.7147 134.089 39.4461L134.82 36.5774L132.542 34.6853C132.329 34.5091 132.173 34.2724 132.096 34.0062C132.019 33.74 132.024 33.4568 132.111 33.1935C132.196 32.9295 132.358 32.6973 132.577 32.5271C132.796 32.3569 133.061 32.2568 133.338 32.2398L136.292 32.0486L137.387 29.2971C137.598 28.7676 138.103 28.425 138.674 28.425C139.244 28.425 139.749 28.7676 139.96 29.2971L141.055 32.0486L144.009 32.2398C144.286 32.2567 144.551 32.3568 144.77 32.527C144.989 32.6971 145.152 32.9295 145.236 33.1935C145.323 33.4571 145.328 33.7407 145.251 34.0072C145.174 34.2737 145.018 34.5106 144.804 34.687L142.526 36.5782L143.257 39.447C143.326 39.7155 143.313 39.9987 143.219 40.2596C143.125 40.5205 142.954 40.7468 142.729 40.909C142.505 41.0731 142.237 41.1657 141.96 41.1747C141.682 41.1836 141.409 41.1085 141.175 40.9591L138.674 39.3773L136.171 40.9591C135.95 41.0994 135.693 41.174 135.431 41.1742ZM138.674 29.4875C138.609 29.4859 138.545 29.5046 138.491 29.541C138.438 29.5775 138.397 29.6298 138.374 29.6907L137.155 32.7532L136.696 33.0873L133.406 33.3006C133.341 33.3033 133.279 33.326 133.228 33.3658C133.177 33.4056 133.139 33.4604 133.12 33.5225C133.099 33.5835 133.097 33.6497 133.115 33.7117C133.134 33.7738 133.171 33.8286 133.222 33.8684L135.758 35.9756L135.933 36.5153L135.119 39.7096C135.101 39.772 135.104 39.8382 135.126 39.8991C135.148 39.9599 135.189 40.0123 135.242 40.0488C135.296 40.0887 135.438 40.1669 135.604 40.0607L138.39 38.3003H138.958L141.745 40.0607C141.799 40.0969 141.863 40.1152 141.928 40.1131C141.993 40.111 142.055 40.0885 142.107 40.0488C142.16 40.0117 142.2 39.9591 142.222 39.8982C142.243 39.8373 142.246 39.7711 142.228 39.7088L141.414 36.5145L141.589 35.9747L144.125 33.8684C144.176 33.8285 144.214 33.7736 144.232 33.7113C144.25 33.6491 144.248 33.5828 144.227 33.5216C144.208 33.4596 144.17 33.4049 144.119 33.3651C144.068 33.3254 144.006 33.3025 143.941 33.2998L140.652 33.0864L140.192 32.7524L138.973 29.6898C138.95 29.6293 138.909 29.5772 138.855 29.541C138.802 29.5047 138.738 29.4861 138.674 29.4875Z" fill="#FFA436"/>
+<path d="M151.827 36.3504L149.396 34.3317C149.237 34.2002 149.121 34.0236 149.064 33.825C149.006 33.6265 149.01 33.4153 149.075 33.219C149.138 33.0226 149.259 32.8497 149.422 32.723C149.585 32.5964 149.782 32.5219 149.988 32.5093L153.13 32.3061L153.556 31.9874L154.716 29.0744C154.874 28.68 155.249 28.425 155.673 28.425C156.098 28.425 156.473 28.68 156.631 29.0736V29.0744L157.791 31.9874L158.217 32.3061L161.359 32.5093C161.782 32.5365 162.141 32.8153 162.272 33.219C162.404 33.6236 162.277 34.0605 161.951 34.3317L159.524 36.347L159.371 36.8502L160.144 39.889C160.249 40.3012 160.094 40.7271 159.751 40.9761C159.584 41.0974 159.385 41.1658 159.179 41.1726C158.973 41.1795 158.769 41.1243 158.595 41.0144L155.937 39.3339H155.409L152.752 41.0144C152.39 41.2405 151.936 41.2243 151.595 40.9761C151.428 40.8557 151.301 40.6874 151.23 40.4934C151.16 40.2994 151.15 40.0888 151.202 39.889L151.973 36.8579L151.827 36.3504Z" fill="white"/>
+<path d="M169.431 41.1742C169.145 41.1742 168.86 41.0858 168.617 40.9081C168.392 40.746 168.222 40.5196 168.127 40.2587C168.033 39.9979 168.02 39.7147 168.089 39.4461L168.82 36.5774L166.542 34.6853C166.329 34.5091 166.173 34.2724 166.096 34.0062C166.019 33.74 166.024 33.4568 166.111 33.1935C166.196 32.9295 166.358 32.6973 166.577 32.5271C166.796 32.3569 167.061 32.2568 167.338 32.2398L170.292 32.0486L171.387 29.2971C171.598 28.7676 172.103 28.425 172.674 28.425C173.244 28.425 173.749 28.7676 173.96 29.2971L175.055 32.0486L178.009 32.2398C178.286 32.2567 178.551 32.3568 178.77 32.527C178.989 32.6971 179.152 32.9295 179.236 33.1935C179.323 33.4571 179.328 33.7407 179.251 34.0072C179.174 34.2737 179.018 34.5106 178.804 34.687L176.526 36.5782L177.257 39.447C177.326 39.7155 177.313 39.9987 177.219 40.2596C177.125 40.5205 176.954 40.7468 176.729 40.909C176.505 41.0731 176.237 41.1657 175.96 41.1747C175.682 41.1836 175.409 41.1085 175.175 40.9591L172.674 39.3773L170.171 40.9591C169.95 41.0994 169.693 41.174 169.431 41.1742ZM172.674 29.4875C172.609 29.4859 172.545 29.5046 172.491 29.541C172.438 29.5775 172.397 29.6298 172.374 29.6907L171.155 32.7532L170.696 33.0873L167.406 33.3006C167.341 33.3033 167.279 33.326 167.228 33.3658C167.177 33.4056 167.139 33.4604 167.12 33.5225C167.099 33.5835 167.097 33.6497 167.115 33.7117C167.134 33.7738 167.171 33.8286 167.222 33.8684L169.758 35.9756L169.933 36.5153L169.119 39.7096C169.101 39.772 169.104 39.8382 169.126 39.8991C169.148 39.9599 169.189 40.0123 169.242 40.0488C169.296 40.0887 169.438 40.1669 169.604 40.0607L172.39 38.3003H172.958L175.745 40.0607C175.799 40.0969 175.863 40.1152 175.928 40.1131C175.993 40.111 176.055 40.0885 176.107 40.0488C176.16 40.0117 176.2 39.9591 176.222 39.8982C176.243 39.8373 176.246 39.7711 176.228 39.7088L175.414 36.5145L175.589 35.9747L178.125 33.8684C178.176 33.8285 178.214 33.7736 178.232 33.7113C178.25 33.6491 178.248 33.5828 178.227 33.5216C178.208 33.4596 178.17 33.4049 178.119 33.3651C178.068 33.3254 178.006 33.3025 177.941 33.2998L174.652 33.0864L174.192 32.7524L172.973 29.6898C172.95 29.6293 172.909 29.5772 172.855 29.541C172.802 29.5047 172.738 29.4861 172.674 29.4875Z" fill="white"/>
+<rect x="64.7253" y="53.4" width="168" height="4.8" rx="2.4" fill="#E0E0E6"/>
+<rect x="64.7251" y="67.0001" width="136.8" height="4.8" rx="2.4" fill="#E0E0E6"/>
+<g filter="url(#filter1_d_860_36523)">
+<path d="M301.2 102.085C301.2 97.2882 297.312 93.4 292.515 93.4H58.6845C53.8882 93.4 50 97.2882 50 102.085V149.515C50 154.312 53.8882 158.2 58.6845 158.2H292.515C297.312 158.2 301.2 154.312 301.2 149.515V102.085Z" fill="white"/>
+<path d="M292.515 94.2H58.6845V92.6H292.515V94.2ZM50.8 102.085V149.515H49.2V102.085H50.8ZM58.6845 157.4H292.515V159H58.6845V157.4ZM300.4 149.515V102.085H302V149.515H300.4ZM292.515 157.4C296.87 157.4 300.4 153.87 300.4 149.515H302C302 154.754 297.754 159 292.515 159V157.4ZM50.8 149.515C50.8 153.87 54.33 157.4 58.6845 157.4V159C53.4463 159 49.2 154.754 49.2 149.515H50.8ZM58.6845 94.2C54.33 94.2 50.8 97.7301 50.8 102.085H49.2C49.2 96.8464 53.4463 92.6 58.6845 92.6V94.2ZM292.515 92.6C297.754 92.6 302 96.8464 302 102.085H300.4C300.4 97.73 296.87 94.2 292.515 94.2V92.6Z" fill="#EE0B0B"/>
+</g>
+<path d="M66.8272 110.75L64.3962 108.732C64.2369 108.6 64.1209 108.423 64.0636 108.225C64.0063 108.026 64.0102 107.815 64.0749 107.619C64.1379 107.422 64.2588 107.25 64.4217 107.123C64.5846 106.996 64.7819 106.922 64.9878 106.909L68.1303 106.706L68.5561 106.387L69.7155 103.474C69.8736 103.08 70.2493 102.825 70.6735 102.825C71.0976 102.825 71.4733 103.08 71.6314 103.474V103.474L72.7908 106.387L73.2167 106.706L76.3591 106.909C76.7824 106.936 77.1411 107.215 77.272 107.619C77.4038 108.024 77.2771 108.46 76.9507 108.732L74.524 110.747L74.371 111.25L75.1445 114.289C75.249 114.701 75.0943 115.127 74.7509 115.376C74.5842 115.497 74.3849 115.566 74.1788 115.573C73.9727 115.579 73.7694 115.524 73.5949 115.414L70.937 113.734H70.4091L67.752 115.414C67.3899 115.64 66.936 115.624 66.5952 115.376C66.4277 115.256 66.3006 115.087 66.2304 114.893C66.1601 114.699 66.1501 114.489 66.2016 114.289L66.9734 111.258L66.8272 110.75Z" fill="#FFA436"/>
+<path d="M83.8272 110.75L81.3962 108.732C81.2369 108.6 81.1209 108.423 81.0636 108.225C81.0063 108.026 81.0102 107.815 81.0749 107.619C81.1379 107.422 81.2588 107.25 81.4217 107.123C81.5846 106.996 81.7819 106.922 81.9878 106.909L85.1303 106.706L85.5561 106.387L86.7155 103.474C86.8736 103.08 87.2493 102.825 87.6735 102.825C88.0976 102.825 88.4733 103.08 88.6314 103.474V103.474L89.7908 106.387L90.2167 106.706L93.3591 106.909C93.7824 106.936 94.1411 107.215 94.272 107.619C94.4038 108.024 94.2771 108.46 93.9507 108.732L91.524 110.747L91.371 111.25L92.1445 114.289C92.249 114.701 92.0943 115.127 91.7509 115.376C91.5842 115.497 91.3849 115.566 91.1788 115.573C90.9727 115.579 90.7694 115.524 90.5949 115.414L87.937 113.734H87.4091L84.752 115.414C84.3899 115.64 83.936 115.624 83.5952 115.376C83.4277 115.256 83.3006 115.087 83.2304 114.893C83.1601 114.699 83.1501 114.489 83.2016 114.289L83.9734 111.258L83.8272 110.75Z" fill="#FFA436"/>
+<path d="M100.827 110.75L98.3962 108.732C98.2369 108.6 98.1209 108.423 98.0636 108.225C98.0063 108.026 98.0102 107.815 98.0749 107.619C98.1379 107.422 98.2588 107.25 98.4217 107.123C98.5846 106.996 98.7819 106.922 98.9878 106.909L102.13 106.706L102.556 106.387L103.716 103.474C103.874 103.08 104.249 102.825 104.673 102.825C105.098 102.825 105.473 103.08 105.631 103.474V103.474L106.791 106.387L107.217 106.706L110.359 106.909C110.782 106.936 111.141 107.215 111.272 107.619C111.404 108.024 111.277 108.46 110.951 108.732L108.524 110.747L108.371 111.25L109.144 114.289C109.249 114.701 109.094 115.127 108.751 115.376C108.584 115.497 108.385 115.566 108.179 115.573C107.973 115.579 107.769 115.524 107.595 115.414L104.937 113.734H104.409L101.752 115.414C101.39 115.64 100.936 115.624 100.595 115.376C100.428 115.256 100.301 115.087 100.23 114.893C100.16 114.699 100.15 114.489 100.202 114.289L100.973 111.258L100.827 110.75Z" fill="#FFA436"/>
+<path d="M117.827 110.75L115.396 108.732C115.237 108.6 115.121 108.423 115.064 108.225C115.006 108.026 115.01 107.815 115.075 107.619C115.138 107.422 115.259 107.25 115.422 107.123C115.585 106.996 115.782 106.922 115.988 106.909L119.13 106.706L119.556 106.387L120.716 103.474C120.874 103.08 121.249 102.825 121.673 102.825C122.098 102.825 122.473 103.08 122.631 103.474V103.474L123.791 106.387L124.217 106.706L127.359 106.909C127.782 106.936 128.141 107.215 128.272 107.619C128.404 108.024 128.277 108.46 127.951 108.732L125.524 110.747L125.371 111.25L126.144 114.289C126.249 114.701 126.094 115.127 125.751 115.376C125.584 115.497 125.385 115.566 125.179 115.573C124.973 115.579 124.769 115.524 124.595 115.414L121.937 113.734H121.409L118.752 115.414C118.39 115.64 117.936 115.624 117.595 115.376C117.428 115.256 117.301 115.087 117.23 114.893C117.16 114.699 117.15 114.489 117.202 114.289L117.973 111.258L117.827 110.75Z" fill="#FFA436"/>
+<path d="M134.827 110.75L132.396 108.732C132.237 108.6 132.121 108.423 132.064 108.225C132.006 108.026 132.01 107.815 132.075 107.619C132.138 107.422 132.259 107.25 132.422 107.123C132.585 106.996 132.782 106.922 132.988 106.909L136.13 106.706L136.556 106.387L137.716 103.474C137.874 103.08 138.249 102.825 138.673 102.825C139.098 102.825 139.473 103.08 139.631 103.474V103.474L140.791 106.387L141.217 106.706L144.359 106.909C144.782 106.936 145.141 107.215 145.272 107.619C145.404 108.024 145.277 108.46 144.951 108.732L142.524 110.747L142.371 111.25L143.144 114.289C143.249 114.701 143.094 115.127 142.751 115.376C142.584 115.497 142.385 115.566 142.179 115.573C141.973 115.579 141.769 115.524 141.595 115.414L138.937 113.734H138.409L135.752 115.414C135.39 115.64 134.936 115.624 134.595 115.376C134.428 115.256 134.301 115.087 134.23 114.893C134.16 114.699 134.15 114.489 134.202 114.289L134.973 111.258L134.827 110.75Z" fill="#FFA436"/>
+<path d="M151.827 110.75L149.396 108.732C149.237 108.6 149.121 108.423 149.064 108.225C149.006 108.026 149.01 107.815 149.075 107.619C149.138 107.422 149.259 107.25 149.422 107.123C149.585 106.996 149.782 106.922 149.988 106.909L153.13 106.706L153.556 106.387L154.716 103.474C154.874 103.08 155.249 102.825 155.673 102.825C156.098 102.825 156.473 103.08 156.631 103.474V103.474L157.791 106.387L158.217 106.706L161.359 106.909C161.782 106.936 162.141 107.215 162.272 107.619C162.404 108.024 162.277 108.46 161.951 108.732L159.524 110.747L159.371 111.25L160.144 114.289C160.249 114.701 160.094 115.127 159.751 115.376C159.584 115.497 159.385 115.566 159.179 115.573C158.973 115.579 158.769 115.524 158.595 115.414L155.937 113.734H155.409L152.752 115.414C152.39 115.64 151.936 115.624 151.595 115.376C151.428 115.256 151.301 115.087 151.23 114.893C151.16 114.699 151.15 114.489 151.202 114.289L151.973 111.258L151.827 110.75Z" fill="white"/>
+<path d="M169.431 115.574C169.145 115.574 168.86 115.486 168.617 115.308C168.392 115.146 168.222 114.92 168.127 114.659C168.033 114.398 168.02 114.115 168.089 113.846L168.82 110.977L166.542 109.085C166.329 108.909 166.173 108.672 166.096 108.406C166.019 108.14 166.024 107.857 166.111 107.593C166.196 107.329 166.358 107.097 166.577 106.927C166.796 106.757 167.061 106.657 167.338 106.64L170.292 106.449L171.387 103.697C171.598 103.168 172.103 102.825 172.674 102.825C173.244 102.825 173.749 103.168 173.96 103.697L175.055 106.449L178.009 106.64C178.286 106.657 178.551 106.757 178.77 106.927C178.989 107.097 179.152 107.329 179.236 107.593C179.323 107.857 179.328 108.141 179.251 108.407C179.174 108.674 179.018 108.911 178.804 109.087L176.526 110.978L177.257 113.847C177.326 114.115 177.313 114.399 177.219 114.66C177.125 114.92 176.954 115.147 176.729 115.309C176.505 115.473 176.237 115.566 175.96 115.575C175.682 115.584 175.409 115.508 175.175 115.359L172.674 113.777L170.171 115.359C169.95 115.499 169.693 115.574 169.431 115.574ZM172.674 103.887C172.609 103.886 172.545 103.904 172.491 103.941C172.438 103.977 172.397 104.03 172.374 104.091L171.155 107.153L170.696 107.487L167.406 107.701C167.341 107.703 167.279 107.726 167.228 107.766C167.177 107.805 167.139 107.86 167.12 107.922C167.099 107.983 167.097 108.05 167.115 108.112C167.134 108.174 167.171 108.228 167.222 108.268L169.758 110.376L169.933 110.915L169.119 114.11C169.101 114.172 169.104 114.238 169.126 114.299C169.148 114.36 169.189 114.412 169.242 114.449C169.296 114.489 169.438 114.567 169.604 114.461L172.39 112.7H172.958L175.745 114.461C175.799 114.497 175.863 114.515 175.928 114.513C175.993 114.511 176.055 114.488 176.107 114.449C176.16 114.412 176.2 114.359 176.222 114.298C176.243 114.237 176.246 114.171 176.228 114.109L175.414 110.914L175.589 110.375L178.125 108.268C178.176 108.228 178.214 108.173 178.232 108.111C178.25 108.049 178.248 107.983 178.227 107.922C178.208 107.859 178.17 107.805 178.119 107.765C178.068 107.725 178.006 107.702 177.941 107.7L174.652 107.486L174.192 107.152L172.973 104.09C172.95 104.029 172.909 103.977 172.855 103.941C172.802 103.905 172.738 103.886 172.674 103.887Z" fill="white"/>
+<rect x="64.7253" y="127.8" width="168" height="4.8" rx="2.4" fill="#E0E0E6"/>
+<rect x="64.7251" y="141.4" width="136.8" height="4.8" rx="2.4" fill="#E0E0E6"/>
+<g filter="url(#filter2_d_860_36523)">
+<path d="M301.2 176.485C301.2 171.688 297.312 167.8 292.515 167.8H58.6845C53.8882 167.8 50 171.688 50 176.485V223.916C50 228.712 53.8882 232.6 58.6845 232.6H292.515C297.312 232.6 301.2 228.712 301.2 223.916V176.485Z" fill="white"/>
+</g>
+<path d="M66.8272 185.15L64.3962 183.132C64.2369 183 64.1209 182.824 64.0636 182.625C64.0063 182.426 64.0102 182.215 64.0749 182.019C64.1379 181.822 64.2588 181.65 64.4217 181.523C64.5846 181.396 64.7819 181.322 64.9878 181.309L68.1303 181.106L68.5561 180.787L69.7155 177.874C69.8736 177.48 70.2493 177.225 70.6735 177.225C71.0976 177.225 71.4733 177.48 71.6314 177.874V177.874L72.7908 180.787L73.2167 181.106L76.3591 181.309C76.7824 181.336 77.1411 181.615 77.272 182.019C77.4038 182.424 77.2771 182.86 76.9507 183.132L74.524 185.147L74.371 185.65L75.1445 188.689C75.249 189.101 75.0943 189.527 74.7509 189.776C74.5842 189.897 74.3849 189.966 74.1788 189.973C73.9727 189.979 73.7694 189.924 73.5949 189.814L70.937 188.134H70.4091L67.752 189.814C67.3899 190.04 66.936 190.024 66.5952 189.776C66.4277 189.656 66.3006 189.487 66.2304 189.293C66.1601 189.099 66.1501 188.889 66.2016 188.689L66.9734 185.658L66.8272 185.15Z" fill="#FFA436"/>
+<path d="M83.8272 185.15L81.3962 183.132C81.2369 183 81.1209 182.824 81.0636 182.625C81.0063 182.426 81.0102 182.215 81.0749 182.019C81.1379 181.822 81.2588 181.65 81.4217 181.523C81.5846 181.396 81.7819 181.322 81.9878 181.309L85.1303 181.106L85.5561 180.787L86.7155 177.874C86.8736 177.48 87.2493 177.225 87.6735 177.225C88.0976 177.225 88.4733 177.48 88.6314 177.874V177.874L89.7908 180.787L90.2167 181.106L93.3591 181.309C93.7824 181.336 94.1411 181.615 94.272 182.019C94.4038 182.424 94.2771 182.86 93.9507 183.132L91.524 185.147L91.371 185.65L92.1445 188.689C92.249 189.101 92.0943 189.527 91.7509 189.776C91.5842 189.897 91.3849 189.966 91.1788 189.973C90.9727 189.979 90.7694 189.924 90.5949 189.814L87.937 188.134H87.4091L84.752 189.814C84.3899 190.04 83.936 190.024 83.5952 189.776C83.4277 189.656 83.3006 189.487 83.2304 189.293C83.1601 189.099 83.1501 188.889 83.2016 188.689L83.9734 185.658L83.8272 185.15Z" fill="#FFA436"/>
+<path d="M100.827 185.15L98.3962 183.132C98.2369 183 98.1209 182.824 98.0636 182.625C98.0063 182.426 98.0102 182.215 98.0749 182.019C98.1379 181.822 98.2588 181.65 98.4217 181.523C98.5846 181.396 98.7819 181.322 98.9878 181.309L102.13 181.106L102.556 180.787L103.716 177.874C103.874 177.48 104.249 177.225 104.673 177.225C105.098 177.225 105.473 177.48 105.631 177.874V177.874L106.791 180.787L107.217 181.106L110.359 181.309C110.782 181.336 111.141 181.615 111.272 182.019C111.404 182.424 111.277 182.86 110.951 183.132L108.524 185.147L108.371 185.65L109.144 188.689C109.249 189.101 109.094 189.527 108.751 189.776C108.584 189.897 108.385 189.966 108.179 189.973C107.973 189.979 107.769 189.924 107.595 189.814L104.937 188.134H104.409L101.752 189.814C101.39 190.04 100.936 190.024 100.595 189.776C100.428 189.656 100.301 189.487 100.23 189.293C100.16 189.099 100.15 188.889 100.202 188.689L100.973 185.658L100.827 185.15Z" fill="#FFA436"/>
+<path d="M118.431 189.974C118.145 189.974 117.86 189.886 117.617 189.708C117.392 189.546 117.222 189.32 117.127 189.059C117.033 188.798 117.02 188.515 117.089 188.246L117.82 185.377L115.542 183.485C115.329 183.309 115.173 183.072 115.096 182.806C115.019 182.54 115.024 182.257 115.111 181.993C115.196 181.729 115.358 181.497 115.577 181.327C115.796 181.157 116.061 181.057 116.338 181.04L119.292 180.849L120.387 178.097C120.598 177.568 121.103 177.225 121.674 177.225C122.244 177.225 122.749 177.568 122.96 178.097L124.055 180.849L127.009 181.04C127.286 181.057 127.551 181.157 127.77 181.327C127.989 181.497 128.152 181.729 128.236 181.993C128.323 182.257 128.328 182.541 128.251 182.807C128.174 183.074 128.018 183.311 127.804 183.487L125.526 185.378L126.257 188.247C126.326 188.515 126.313 188.799 126.219 189.06C126.125 189.32 125.954 189.547 125.729 189.709C125.505 189.873 125.237 189.966 124.96 189.975C124.682 189.984 124.409 189.908 124.175 189.759L121.674 188.177L119.171 189.759C118.95 189.899 118.693 189.974 118.431 189.974ZM121.674 178.287C121.609 178.286 121.545 178.305 121.491 178.341C121.438 178.377 121.397 178.43 121.374 178.491L120.155 181.553L119.696 181.887L116.406 182.101C116.341 182.103 116.279 182.126 116.228 182.166C116.177 182.206 116.139 182.26 116.12 182.322C116.099 182.383 116.097 182.45 116.115 182.512C116.134 182.574 116.171 182.629 116.222 182.668L118.758 184.776L118.933 185.315L118.119 188.51C118.101 188.572 118.104 188.638 118.126 188.699C118.148 188.76 118.189 188.812 118.242 188.849C118.296 188.889 118.438 188.967 118.604 188.861L121.39 187.1H121.958L124.745 188.861C124.799 188.897 124.863 188.915 124.928 188.913C124.993 188.911 125.055 188.888 125.107 188.849C125.16 188.812 125.2 188.759 125.222 188.698C125.243 188.637 125.246 188.571 125.228 188.509L124.414 185.314L124.589 184.775L127.125 182.668C127.176 182.628 127.214 182.573 127.232 182.511C127.25 182.449 127.248 182.383 127.227 182.322C127.208 182.26 127.17 182.205 127.119 182.165C127.068 182.125 127.006 182.102 126.941 182.1L123.652 181.886L123.192 181.552L121.973 178.49C121.95 178.429 121.909 178.377 121.855 178.341C121.802 178.305 121.738 178.286 121.674 178.287Z" fill="#FFA436"/>
+<path d="M135.431 189.974C135.145 189.974 134.86 189.886 134.617 189.708C134.392 189.546 134.222 189.32 134.127 189.059C134.033 188.798 134.02 188.515 134.089 188.246L134.82 185.377L132.542 183.485C132.329 183.309 132.173 183.072 132.096 182.806C132.019 182.54 132.024 182.257 132.111 181.993C132.196 181.729 132.358 181.497 132.577 181.327C132.796 181.157 133.061 181.057 133.338 181.04L136.292 180.849L137.387 178.097C137.598 177.568 138.103 177.225 138.674 177.225C139.244 177.225 139.749 177.568 139.96 178.097L141.055 180.849L144.009 181.04C144.286 181.057 144.551 181.157 144.77 181.327C144.989 181.497 145.152 181.729 145.236 181.993C145.323 182.257 145.328 182.541 145.251 182.807C145.174 183.074 145.018 183.311 144.804 183.487L142.526 185.378L143.257 188.247C143.326 188.515 143.313 188.799 143.219 189.06C143.125 189.32 142.954 189.547 142.729 189.709C142.505 189.873 142.237 189.966 141.96 189.975C141.682 189.984 141.409 189.908 141.175 189.759L138.674 188.177L136.171 189.759C135.95 189.899 135.693 189.974 135.431 189.974ZM138.674 178.287C138.609 178.286 138.545 178.305 138.491 178.341C138.438 178.377 138.397 178.43 138.374 178.491L137.155 181.553L136.696 181.887L133.406 182.101C133.341 182.103 133.279 182.126 133.228 182.166C133.177 182.206 133.139 182.26 133.12 182.322C133.099 182.383 133.097 182.45 133.115 182.512C133.134 182.574 133.171 182.629 133.222 182.668L135.758 184.776L135.933 185.315L135.119 188.51C135.101 188.572 135.104 188.638 135.126 188.699C135.148 188.76 135.189 188.812 135.242 188.849C135.296 188.889 135.438 188.967 135.604 188.861L138.39 187.1H138.958L141.745 188.861C141.799 188.897 141.863 188.915 141.928 188.913C141.993 188.911 142.055 188.888 142.107 188.849C142.16 188.812 142.2 188.759 142.222 188.698C142.243 188.637 142.246 188.571 142.228 188.509L141.414 185.314L141.589 184.775L144.125 182.668C144.176 182.628 144.214 182.573 144.232 182.511C144.25 182.449 144.248 182.383 144.227 182.322C144.208 182.26 144.17 182.205 144.119 182.165C144.068 182.125 144.006 182.102 143.941 182.1L140.652 181.886L140.192 181.552L138.973 178.49C138.95 178.429 138.909 178.377 138.855 178.341C138.802 178.305 138.738 178.286 138.674 178.287Z" fill="#FFA436"/>
+<path d="M151.827 185.15L149.396 183.132C149.237 183 149.121 182.824 149.064 182.625C149.006 182.426 149.01 182.215 149.075 182.019C149.138 181.822 149.259 181.65 149.422 181.523C149.585 181.396 149.782 181.322 149.988 181.309L153.13 181.106L153.556 180.787L154.716 177.874C154.874 177.48 155.249 177.225 155.673 177.225C156.098 177.225 156.473 177.48 156.631 177.874V177.874L157.791 180.787L158.217 181.106L161.359 181.309C161.782 181.336 162.141 181.615 162.272 182.019C162.404 182.424 162.277 182.86 161.951 183.132L159.524 185.147L159.371 185.65L160.144 188.689C160.249 189.101 160.094 189.527 159.751 189.776C159.584 189.897 159.385 189.966 159.179 189.973C158.973 189.979 158.769 189.924 158.595 189.814L155.937 188.134H155.409L152.752 189.814C152.39 190.04 151.936 190.024 151.595 189.776C151.428 189.656 151.301 189.487 151.23 189.293C151.16 189.099 151.15 188.889 151.202 188.689L151.973 185.658L151.827 185.15Z" fill="white"/>
+<path d="M169.431 189.974C169.145 189.974 168.86 189.886 168.617 189.708C168.392 189.546 168.222 189.32 168.127 189.059C168.033 188.798 168.02 188.515 168.089 188.246L168.82 185.377L166.542 183.485C166.329 183.309 166.173 183.072 166.096 182.806C166.019 182.54 166.024 182.257 166.111 181.993C166.196 181.729 166.358 181.497 166.577 181.327C166.796 181.157 167.061 181.057 167.338 181.04L170.292 180.849L171.387 178.097C171.598 177.568 172.103 177.225 172.674 177.225C173.244 177.225 173.749 177.568 173.96 178.097L175.055 180.849L178.009 181.04C178.286 181.057 178.551 181.157 178.77 181.327C178.989 181.497 179.152 181.729 179.236 181.993C179.323 182.257 179.328 182.541 179.251 182.807C179.174 183.074 179.018 183.311 178.804 183.487L176.526 185.378L177.257 188.247C177.326 188.515 177.313 188.799 177.219 189.06C177.125 189.32 176.954 189.547 176.729 189.709C176.505 189.873 176.237 189.966 175.96 189.975C175.682 189.984 175.409 189.908 175.175 189.759L172.674 188.177L170.171 189.759C169.95 189.899 169.693 189.974 169.431 189.974ZM172.674 178.287C172.609 178.286 172.545 178.305 172.491 178.341C172.438 178.377 172.397 178.43 172.374 178.491L171.155 181.553L170.696 181.887L167.406 182.101C167.341 182.103 167.279 182.126 167.228 182.166C167.177 182.206 167.139 182.26 167.12 182.322C167.099 182.383 167.097 182.45 167.115 182.512C167.134 182.574 167.171 182.629 167.222 182.668L169.758 184.776L169.933 185.315L169.119 188.51C169.101 188.572 169.104 188.638 169.126 188.699C169.148 188.76 169.189 188.812 169.242 188.849C169.296 188.889 169.438 188.967 169.604 188.861L172.39 187.1H172.958L175.745 188.861C175.799 188.897 175.863 188.915 175.928 188.913C175.993 188.911 176.055 188.888 176.107 188.849C176.16 188.812 176.2 188.759 176.222 188.698C176.243 188.637 176.246 188.571 176.228 188.509L175.414 185.314L175.589 184.775L178.125 182.668C178.176 182.628 178.214 182.573 178.232 182.511C178.25 182.449 178.248 182.383 178.227 182.322C178.208 182.26 178.17 182.205 178.119 182.165C178.068 182.125 178.006 182.102 177.941 182.1L174.652 181.886L174.192 181.552L172.973 178.49C172.95 178.429 172.909 178.377 172.855 178.341C172.802 178.305 172.738 178.286 172.674 178.287Z" fill="white"/>
+<rect x="64.7253" y="202.2" width="168" height="4.8" rx="2.4" fill="#E0E0E6"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M37.4211 126C37.4211 121.357 33.6432 117.579 29 117.579C24.3568 117.579 20.5789 121.357 20.5789 126C20.5789 130.643 24.3568 134.421 29 134.421C33.6432 134.421 37.4211 130.643 37.4211 126ZM19 126C19 120.477 23.4768 116 29 116C34.5232 116 39 120.477 39 126C39 131.523 34.5232 136 29 136C23.4768 136 19 131.523 19 126ZM29.0001 120.737C28.7907 120.737 28.5899 120.82 28.4419 120.968C28.2938 121.116 28.2106 121.317 28.2106 121.526V127.316C28.2106 127.525 28.2938 127.726 28.4419 127.874C28.5899 128.022 28.7907 128.105 29.0001 128.105C29.2095 128.105 29.4103 128.022 29.5584 127.874C29.7064 127.726 29.7896 127.525 29.7896 127.316V121.526C29.7896 121.317 29.7064 121.116 29.5584 120.968C29.4103 120.82 29.2095 120.737 29.0001 120.737ZM28.5264 131.263L28.2106 130.947V130L28.5264 129.684H29.4738L29.7896 130V130.947L29.4738 131.263H28.5264Z" fill="#EE0B0B"/>
+</g>
+<defs>
+<filter id="filter0_d_860_36523" x="44.8842" y="15.5895" width="261.432" height="75.0316" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
+<feFlood flood-opacity="0" result="BackgroundImageFix"/>
+<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
+<feOffset dy="1.70526"/>
+<feGaussianBlur stdDeviation="2.55789"/>
+<feColorMatrix type="matrix" values="0 0 0 0 0.227451 0 0 0 0 0.223529 0 0 0 0 0.266667 0 0 0 0.2 0"/>
+<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_860_36523"/>
+<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_860_36523" result="shape"/>
+</filter>
+<filter id="filter1_d_860_36523" x="44.0842" y="89.1894" width="263.032" height="76.6316" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
+<feFlood flood-opacity="0" result="BackgroundImageFix"/>
+<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
+<feOffset dy="1.70526"/>
+<feGaussianBlur stdDeviation="2.55789"/>
+<feColorMatrix type="matrix" values="0 0 0 0 0.227451 0 0 0 0 0.223529 0 0 0 0 0.266667 0 0 0 0.2 0"/>
+<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_860_36523"/>
+<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_860_36523" result="shape"/>
+</filter>
+<filter id="filter2_d_860_36523" x="44.8842" y="164.39" width="261.432" height="75.0316" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
+<feFlood flood-opacity="0" result="BackgroundImageFix"/>
+<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
+<feOffset dy="1.70526"/>
+<feGaussianBlur stdDeviation="2.55789"/>
+<feColorMatrix type="matrix" values="0 0 0 0 0.227451 0 0 0 0 0.223529 0 0 0 0 0.266667 0 0 0 0.2 0"/>
+<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_860_36523"/>
+<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_860_36523" result="shape"/>
+</filter>
+<clipPath id="clip0_860_36523">
+<rect width="352" height="214" fill="white"/>
+</clipPath>
+</defs>
+</svg>
diff --git a/browser/components/shopping/content/assets/shipping.svg b/browser/components/shopping/content/assets/shipping.svg
new file mode 100644
index 0000000000..d9b01a0a96
--- /dev/null
+++ b/browser/components/shopping/content/assets/shipping.svg
@@ -0,0 +1,6 @@
+<!-- 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/. -->
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="context-fill" fill-opacity="context-fill-opacity">
+<path fill-rule="evenodd" d="M10.5 14c.456.607 1.182 1 2 1 .818 0 1.544-.393 2-1h.25c.69 0 1.25-.56 1.25-1.25V8.777a.75.75 0 0 0-.1-.375l-1.75-3.027A.75.75 0 0 0 13.5 5H11a2 2 0 0 0-2-2H2a2 2 0 0 0-2 2v7a2 2 0 0 0 1.443 1.921A2.497 2.497 0 0 0 3.5 15c.818 0 1.544-.393 2-1h5ZM2 4.5a.5.5 0 0 0-.5.5v6A2.5 2.5 0 0 1 6 12.5h3.5V5a.5.5 0 0 0-.5-.5H2ZM14.5 9v-.022L13.067 6.5H11V9h3.5Zm-12 3.5a1 1 0 1 1 2 0 1 1 0 0 1-2 0Zm9 0a1 1 0 1 1 2 0 1 1 0 0 1-2 0Z"/>
+</svg>
diff --git a/browser/components/shopping/content/assets/shopping.svg b/browser/components/shopping/content/assets/shopping.svg
new file mode 100644
index 0000000000..5b81219d48
--- /dev/null
+++ b/browser/components/shopping/content/assets/shopping.svg
@@ -0,0 +1,6 @@
+<!-- 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/. -->
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="context-fill" fill-opacity="context-fill-opacity">
+<path d="M9.879 0H4v1.5h5.567l5.37 5.393L16 5.835 10.41.22A.75.75 0 0 0 9.88 0ZM6.346 8.116l1.06-1.06-1.06-1.06-1.06 1.06 1.06 1.06Z"/><path d="M2.119 3.75a.75.75 0 0 1 .75-.75h6.01a.75.75 0 0 1 .53.22l5.057 5.056a2 2 0 0 1 0 2.828l-4.243 4.243a2 2 0 0 1-2.828 0l-5.056-5.056a.75.75 0 0 1-.22-.53V3.75Zm1.5.75v4.95l4.836 4.837a.5.5 0 0 0 .708 0l4.243-4.243a.5.5 0 0 0 0-.708L8.568 4.5H3.62Z"/>
+</svg>
diff --git a/browser/components/shopping/content/assets/unanalyzedDark.avif b/browser/components/shopping/content/assets/unanalyzedDark.avif
new file mode 100644
index 0000000000..66efc3bc6e
--- /dev/null
+++ b/browser/components/shopping/content/assets/unanalyzedDark.avif
Binary files differ
diff --git a/browser/components/shopping/content/assets/unanalyzedLight.avif b/browser/components/shopping/content/assets/unanalyzedLight.avif
new file mode 100644
index 0000000000..28cd1c26f1
--- /dev/null
+++ b/browser/components/shopping/content/assets/unanalyzedLight.avif
Binary files differ
diff --git a/browser/components/shopping/content/highlight-item.css b/browser/components/shopping/content/highlight-item.css
new file mode 100644
index 0000000000..2139363862
--- /dev/null
+++ b/browser/components/shopping/content/highlight-item.css
@@ -0,0 +1,66 @@
+/* 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/. */
+
+/* Separate logo, label and details into rows and columns */
+.highlight-item-wrapper {
+ align-items: center;
+ display: grid;
+ column-gap: 1em;
+ grid-template-columns: 1rem auto;
+ grid-template-rows: 1rem auto;
+}
+
+.highlight-icon {
+ content: url("chrome://global/skin/icons/defaultFavicon.svg");
+ color: var(--icon-color);
+ fill: currentColor;
+ -moz-context-properties: fill;
+ grid-row-start: 1;
+ grid-column-start: 1;
+
+ &.price {
+ content: url("chrome://browser/content/shopping/assets/price.svg");
+ }
+
+ &.quality {
+ content: url("chrome://browser/content/shopping/assets/quality.svg");
+ }
+
+ &.shipping {
+ content: url("chrome://browser/content/shopping/assets/shipping.svg");
+ }
+
+ &.competitiveness {
+ content: url("chrome://browser/content/shopping/assets/competitiveness.svg");
+ }
+
+ &.packaging\/appearance {
+ content: url("chrome://browser/content/shopping/assets/packaging.svg");
+ }
+}
+
+.highlight-label {
+ font-weight: 590;
+ grid-column-start: 2;
+ grid-row-start: 1;
+}
+
+.highlight-details {
+ grid-column-start: 2;
+ grid-row-start: 2;
+ margin: 0;
+ padding: 0;
+}
+
+.highlight-details-list {
+ list-style-type: none;
+ padding: 0;
+ margin: 0;
+}
+
+.highlight-details-list > li {
+ /* Render LTR since English review snippets are only supported at this time. */
+ direction: ltr;
+ margin: 1em 0;
+}
diff --git a/browser/components/shopping/content/highlight-item.mjs b/browser/components/shopping/content/highlight-item.mjs
new file mode 100644
index 0000000000..a61764dc86
--- /dev/null
+++ b/browser/components/shopping/content/highlight-item.mjs
@@ -0,0 +1,57 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*-
+ * 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 { html } from "chrome://global/content/vendor/lit.all.mjs";
+
+import { MozLitElement } from "chrome://global/content/lit-utils.mjs";
+
+/**
+ * Class for displaying a list of highlighted product reviews, according to highlight category.
+ */
+class Highlight extends MozLitElement {
+ l10nId;
+ highlightType;
+ /**
+ * reviews is a list of Strings, representing all the reviews to display
+ * under a highlight category.
+ */
+ reviews;
+
+ /**
+ * lang defines the language in which the reviews are written. We should specify
+ * language so that screen readers can read text with the appropriate language packs.
+ */
+ lang;
+
+ render() {
+ let ulTemplate = [];
+
+ for (let review of this.reviews) {
+ ulTemplate.push(
+ html`<li>
+ <q><span lang=${this.lang}>${review}</span></q>
+ </li>`
+ );
+ }
+
+ return html`
+ <link
+ rel="stylesheet"
+ href="chrome://browser/content/shopping/highlight-item.css"
+ />
+ <div class="highlight-item-wrapper">
+ <span class="highlight-icon ${this.highlightType}"></span>
+ <dt class="highlight-label" data-l10n-id=${this.l10nId}></dt>
+ <dd class="highlight-details">
+ <ul class="highlight-details-list">
+ ${ulTemplate}
+ </ul>
+ </dd>
+ </div>
+ `;
+ }
+}
+
+customElements.define("highlight-item", Highlight);
diff --git a/browser/components/shopping/content/highlights.mjs b/browser/components/shopping/content/highlights.mjs
new file mode 100644
index 0000000000..09ed055b28
--- /dev/null
+++ b/browser/components/shopping/content/highlights.mjs
@@ -0,0 +1,124 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*-
+ * 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 { html } from "chrome://global/content/vendor/lit.all.mjs";
+
+import { MozLitElement } from "chrome://global/content/lit-utils.mjs";
+
+// eslint-disable-next-line import/no-unassigned-import
+import "chrome://browser/content/shopping/highlight-item.mjs";
+// eslint-disable-next-line import/no-unassigned-import
+import "chrome://browser/content/shopping/shopping-card.mjs";
+
+const VALID_HIGHLIGHT_L10N_IDs = new Map([
+ ["price", "shopping-highlight-price"],
+ ["quality", "shopping-highlight-quality"],
+ ["shipping", "shopping-highlight-shipping"],
+ ["competitiveness", "shopping-highlight-competitiveness"],
+ ["packaging/appearance", "shopping-highlight-packaging"],
+]);
+
+/**
+ * Class for displaying all available highlight categories for a product and any
+ * highlight reviews per category.
+ */
+class ReviewHighlights extends MozLitElement {
+ /**
+ * highlightsMap is a map of highlight categories to an array of reviews per category.
+ * It is possible for a category to have no reviews.
+ */
+ #highlightsMap;
+
+ static properties = {
+ highlights: { type: Object },
+ lang: { type: String, reflect: true },
+ };
+
+ static get queries() {
+ return {
+ reviewHighlightsListEl: "#review-highlights-list",
+ };
+ }
+
+ updateHighlightsMap() {
+ let availableKeys;
+ this.#highlightsMap = null;
+
+ try {
+ if (!this.highlights) {
+ return;
+ }
+
+ // Filter highlights that have data.
+ let keys = Object.keys(this.highlights);
+ availableKeys = keys.filter(
+ key => Object.values(this.highlights[key]).flat().length !== 0
+ );
+
+ // Filter valid highlight category types. Valid types are guaranteed to have data-l10n-ids.
+ availableKeys = availableKeys.filter(key =>
+ VALID_HIGHLIGHT_L10N_IDs.has(key)
+ );
+
+ // If there are no highlights to show in the end, stop here and don't render content.
+ if (!availableKeys.length) {
+ return;
+ }
+ } catch (e) {
+ return;
+ }
+
+ this.#highlightsMap = new Map();
+
+ for (let key of availableKeys) {
+ // Ignore negative,neutral,positive sentiments and simply append review strings into one array.
+ let reviews = Object.values(this.highlights[key]).flat();
+ this.#highlightsMap.set(key, reviews);
+ }
+ }
+
+ createHighlightElement(type, reviews) {
+ let highlightEl = document.createElement("highlight-item");
+ // We already verify highlight type and its l10n id in updateHighlightsMap.
+ let l10nId = VALID_HIGHLIGHT_L10N_IDs.get(type);
+ highlightEl.id = type;
+ highlightEl.l10nId = l10nId;
+ highlightEl.highlightType = type;
+ highlightEl.reviews = reviews;
+ highlightEl.lang = this.lang;
+ return highlightEl;
+ }
+
+ render() {
+ this.updateHighlightsMap();
+
+ if (!this.#highlightsMap) {
+ this.hidden = true;
+ return null;
+ }
+
+ this.hidden = false;
+
+ let highlightsTemplate = [];
+ for (let [key, value] of this.#highlightsMap) {
+ let highlightEl = this.createHighlightElement(key, value);
+ highlightsTemplate.push(highlightEl);
+ }
+
+ return html`
+ <shopping-card
+ data-l10n-id="shopping-highlights-label"
+ data-l10n-attrs="label"
+ type="show-more"
+ >
+ <div slot="content" id="review-highlights-wrapper">
+ <dl id="review-highlights-list">${highlightsTemplate}</dl>
+ </div>
+ </shopping-card>
+ `;
+ }
+}
+
+customElements.define("review-highlights", ReviewHighlights);
diff --git a/browser/components/shopping/content/letter-grade.css b/browser/components/shopping/content/letter-grade.css
new file mode 100644
index 0000000000..75be411151
--- /dev/null
+++ b/browser/components/shopping/content/letter-grade.css
@@ -0,0 +1,132 @@
+/* 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 url("chrome://global/skin/in-content/common.css");
+
+:host {
+ --background-term-a: #B3FFE3;
+ --background-term-b: #80EBFF;
+ --background-term-c: #FFEA80;
+ --background-term-d: #FFB587;
+ --background-term-f: #FF848B;
+ --in-content-box-border-color: rgba(0, 0, 0, 0.15);
+ --inner-border: 1px solid var(--in-content-box-border-color);
+ --letter-grade-width: 1.5rem;
+ --letter-grade-term-inline-padding: 0.25rem;
+}
+
+#letter-grade-wrapper {
+ border-radius: 4px;
+ color: #000;
+ display: flex;
+ font-weight: 600;
+ line-height: 150%;
+ margin: 0;
+ overflow-wrap: anywhere;
+}
+
+#letter-grade-term {
+ align-items: center;
+ align-self: stretch;
+ box-sizing: border-box;
+ display: flex;
+ flex-shrink: 0;
+ font-size: 1em;
+ justify-content: center;
+ margin: 0;
+ padding: 0.0625rem var(--letter-grade-term-inline-padding);
+ text-align: center;
+ width: var(--letter-grade-width);
+}
+
+:host([showdescription]) #letter-grade-term {
+ /* For border "shadow" inside the container */
+ border-block: var(--inner-border);
+ border-inline-start: var(--inner-border);
+ border-start-start-radius: 4px;
+ border-end-start-radius: 4px;
+ /* Add 1px padding so that the letter does not shift when changing
+ * between the show description and no description variants. */
+ padding-inline-end: calc(var(--letter-grade-term-inline-padding) + 1px);
+}
+
+:host(:not([showdescription])) #letter-grade-term {
+ border: var(--inner-border);
+ border-radius: 4px;
+}
+
+#letter-grade-description {
+ /* For border "shadow" inside the container */
+ border-block: var(--inner-border);
+ border-inline-end: var(--inner-border);
+ border-start-end-radius: 4px;
+ border-end-end-radius: 4px;
+
+ align-items: center;
+ align-self: stretch;
+ box-sizing: border-box;
+ display: flex;
+ font-size: 0.87rem;
+ font-weight: var(--font-weight-default);
+ margin: 0;
+ padding: 0.125rem 0.5rem;
+}
+
+/* Letter grade colors */
+
+:host([letter="A"]) #letter-grade-term {
+ background-color: var(--background-term-a);
+}
+
+:host([letter="A"]) #letter-grade-description {
+ background-color: rgba(231, 255, 246, 1);
+}
+
+:host([letter="B"]) #letter-grade-term {
+ background-color: var(--background-term-b);
+}
+
+:host([letter="B"]) #letter-grade-description {
+ background-color: rgba(222, 250, 255, 1);
+}
+
+:host([letter="C"]) #letter-grade-term {
+ background-color: var(--background-term-c);
+}
+
+:host([letter="C"]) #letter-grade-description {
+ background-color: rgba(255, 249, 218, 1);
+}
+
+:host([letter="D"]) #letter-grade-term {
+ background-color: var(--background-term-d);
+}
+
+:host([letter="D"]) #letter-grade-description {
+ background-color: rgba(252, 230, 213, 1);
+}
+
+:host([letter="F"]) #letter-grade-term {
+ background-color: var(--background-term-f);
+}
+
+:host([letter="F"]) #letter-grade-description {
+ background-color: rgba(255, 228, 230, 1);
+}
+
+@media (prefers-contrast) {
+ :host {
+ --in-content-box-border-color: unset;
+ }
+
+ #letter-grade-term {
+ background-color: var(--in-content-page-color) !important;
+ color: var(--in-content-page-background) !important;
+ }
+
+ #letter-grade-description {
+ background-color: var(--in-content-page-background) !important;
+ color: var(--in-content-page-color) !important;
+ }
+}
diff --git a/browser/components/shopping/content/letter-grade.mjs b/browser/components/shopping/content/letter-grade.mjs
new file mode 100644
index 0000000000..71badb1c45
--- /dev/null
+++ b/browser/components/shopping/content/letter-grade.mjs
@@ -0,0 +1,78 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*-
+ * 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 { html } from "chrome://global/content/vendor/lit.all.mjs";
+
+import { MozLitElement } from "chrome://global/content/lit-utils.mjs";
+
+const VALID_LETTER_GRADE_L10N_IDS = new Map([
+ ["A", "shopping-letter-grade-description-ab"],
+ ["B", "shopping-letter-grade-description-ab"],
+ ["C", "shopping-letter-grade-description-c"],
+ ["D", "shopping-letter-grade-description-df"],
+ ["F", "shopping-letter-grade-description-df"],
+]);
+
+class LetterGrade extends MozLitElement {
+ #descriptionL10N;
+
+ static properties = {
+ letter: { type: String, reflect: true },
+ showdescription: { type: Boolean, reflect: true },
+ };
+
+ get fluentStrings() {
+ if (!this._fluentStrings) {
+ this._fluentStrings = new Localization(["browser/shopping.ftl"], true);
+ }
+ return this._fluentStrings;
+ }
+
+ descriptionTemplate() {
+ if (this.showdescription) {
+ return html`<p
+ id="letter-grade-description"
+ data-l10n-id=${this.#descriptionL10N}
+ ></p>`;
+ }
+
+ return null;
+ }
+
+ render() {
+ // Do not render if letter is invalid
+ if (!VALID_LETTER_GRADE_L10N_IDS.has(this.letter)) {
+ return null;
+ }
+
+ this.#descriptionL10N = VALID_LETTER_GRADE_L10N_IDS.get(this.letter);
+ let tooltipL10NArgs;
+ // Do not localize tooltip if using Storybook.
+ if (!window.IS_STORYBOOK) {
+ const localizedDescription = this.fluentStrings.formatValueSync(
+ this.#descriptionL10N
+ );
+ tooltipL10NArgs = `{"letter": "${this.letter}", "description": "${localizedDescription}"}`;
+ }
+
+ return html`
+ <link
+ rel="stylesheet"
+ href="chrome://browser/content/shopping/letter-grade.css"
+ />
+ <article
+ id="letter-grade-wrapper"
+ data-l10n-id="shopping-letter-grade-tooltip"
+ data-l10n-attrs="title"
+ data-l10n-args=${tooltipL10NArgs}
+ >
+ <p id="letter-grade-term">${this.letter}</p>
+ ${this.descriptionTemplate()}
+ </article>
+ `;
+ }
+}
+
+customElements.define("letter-grade", LetterGrade);
diff --git a/browser/components/shopping/content/onboarding.mjs b/browser/components/shopping/content/onboarding.mjs
new file mode 100644
index 0000000000..68124c0253
--- /dev/null
+++ b/browser/components/shopping/content/onboarding.mjs
@@ -0,0 +1,69 @@
+/* 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/. */
+
+const BUNDLE_SRC =
+ "chrome://browser/content/aboutwelcome/aboutwelcome.bundle.js";
+
+class Onboarding {
+ constructor({ win } = {}) {
+ this.doc = win.document;
+ win.addEventListener("RenderWelcome", () => this._addScriptsAndRender(), {
+ once: true,
+ });
+ }
+
+ async _addScriptsAndRender() {
+ const addStylesheet = href => {
+ if (this.doc.head.querySelector(`link[href="${href}"]`)) {
+ return;
+ }
+ const link = this.doc.head.appendChild(this.doc.createElement("link"));
+ link.rel = "stylesheet";
+ link.href = href;
+ };
+ addStylesheet("chrome://browser/content/aboutwelcome/aboutwelcome.css");
+ const reactSrc = "resource://activity-stream/vendor/react.js";
+ const domSrc = "resource://activity-stream/vendor/react-dom.js";
+ // Add React script
+ const getReactReady = async () => {
+ return new Promise(resolve => {
+ let reactScript = this.doc.createElement("script");
+ reactScript.src = reactSrc;
+ this.doc.head.appendChild(reactScript);
+ reactScript.addEventListener("load", resolve);
+ });
+ };
+ // Add ReactDom script
+ const getDomReady = async () => {
+ return new Promise(resolve => {
+ let domScript = this.doc.createElement("script");
+ domScript.src = domSrc;
+ this.doc.head.appendChild(domScript);
+ domScript.addEventListener("load", resolve);
+ });
+ };
+ // Load React, then React Dom
+ if (!this.doc.querySelector(`[src="${reactSrc}"]`)) {
+ await getReactReady();
+ }
+ if (!this.doc.querySelector(`[src="${domSrc}"]`)) {
+ await getDomReady();
+ }
+ // Load the bundle to render the content as configured.
+ this.doc.querySelector(`[src="${BUNDLE_SRC}"]`)?.remove();
+ let bundleScript = this.doc.createElement("script");
+ bundleScript.src = BUNDLE_SRC;
+ this.doc.head.appendChild(bundleScript);
+ }
+
+ static getOnboarding() {
+ if (!this.onboarding) {
+ this.onboarding = new Onboarding({ win: window });
+ }
+ return this.onboarding;
+ }
+}
+
+const OnboardingContainer = Onboarding.getOnboarding();
+export default OnboardingContainer;
diff --git a/browser/components/shopping/content/recommended-ad.css b/browser/components/shopping/content/recommended-ad.css
new file mode 100644
index 0000000000..20f0775ce0
--- /dev/null
+++ b/browser/components/shopping/content/recommended-ad.css
@@ -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/. */
+
+#recommended-ad-wrapper {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ text-decoration: none;
+ color: var(--in-content-text-color);
+}
+
+#recommended-ad-wrapper:hover {
+ cursor: pointer;
+}
+
+#recommended-ad-wrapper:hover #ad-title {
+ text-decoration: underline;
+ color: var(--link-color-hover);
+}
+
+#recommended-ad-wrapper:focus-visible {
+ outline-offset: 4px;
+ border-radius: 1px;
+}
+
+#ad-content {
+ display: flex;
+ justify-content: space-between;
+ gap: 12px;
+ height: 80px;
+}
+
+#ad-preview-image {
+ max-width: 80px;
+ max-height: 80px;
+}
+
+#ad-title {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ height: fit-content;
+ display: -webkit-box;
+ -webkit-line-clamp: 4;
+ -webkit-box-orient: vertical;
+ /* This text won't be localized and when in RTL, the ellipsis is positioned
+ awkwardly so we are forcing LTR */
+ direction: ltr;
+}
+
+#price {
+ font-size: 1em;
+ font-weight: 600;
+}
+
+#footer {
+ display: flex;
+ justify-content: space-between;
+}
diff --git a/browser/components/shopping/content/recommended-ad.mjs b/browser/components/shopping/content/recommended-ad.mjs
new file mode 100644
index 0000000000..09351ffe85
--- /dev/null
+++ b/browser/components/shopping/content/recommended-ad.mjs
@@ -0,0 +1,150 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*-
+ * 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 { html } from "chrome://global/content/vendor/lit.all.mjs";
+import { MozLitElement } from "chrome://global/content/lit-utils.mjs";
+
+// eslint-disable-next-line import/no-unassigned-import
+import "chrome://browser/content/shopping/shopping-card.mjs";
+// eslint-disable-next-line import/no-unassigned-import
+import "chrome://global/content/elements/moz-five-star.mjs";
+
+const AD_IMPRESSION_TIMEOUT = 1500;
+
+class RecommendedAd extends MozLitElement {
+ static properties = {
+ product: { type: Object, reflect: true },
+ };
+
+ static get queries() {
+ return {
+ letterGradeEl: "letter-grade",
+ linkEl: "#recommended-ad-wrapper",
+ priceEl: "#price",
+ ratingEl: "moz-five-star",
+ };
+ }
+
+ connectedCallback() {
+ super.connectedCallback();
+ if (this.initialized) {
+ return;
+ }
+ this.initialized = true;
+
+ document.addEventListener("visibilitychange", this);
+ }
+
+ disconnectedCallback() {
+ super.disconnectedCallback();
+ document.removeEventListener("visibilitychange", this);
+ this.resetImpressionTimer();
+ this.revokeImageUrl();
+ }
+
+ startImpressionTimer() {
+ if (!this.timeout && document.visibilityState === "visible") {
+ this.timeout = setTimeout(
+ () => this.recordImpression(),
+ AD_IMPRESSION_TIMEOUT
+ );
+ }
+ }
+
+ resetImpressionTimer() {
+ this.timeout = clearTimeout(this.timeout);
+ }
+
+ revokeImageUrl() {
+ if (this.imageUrl) {
+ URL.revokeObjectURL(this.imageUrl);
+ }
+ }
+
+ recordImpression() {
+ if (this.hasImpressed) {
+ return;
+ }
+
+ this.dispatchEvent(
+ new CustomEvent("AdImpression", {
+ bubbles: true,
+ detail: { aid: this.product.aid },
+ })
+ );
+
+ document.removeEventListener("visibilitychange", this);
+ this.resetImpressionTimer();
+
+ this.hasImpressed = true;
+ }
+
+ handleClick(event) {
+ if (event.button === 0 || event.button === 1) {
+ this.dispatchEvent(
+ new CustomEvent("AdClicked", {
+ bubbles: true,
+ detail: { aid: this.product.aid },
+ })
+ );
+ }
+ }
+
+ handleEvent(event) {
+ if (event.type !== "visibilitychange") {
+ return;
+ }
+ if (document.visibilityState === "visible") {
+ this.startImpressionTimer();
+ } else if (!this.hasImpressed) {
+ this.resetImpressionTimer();
+ }
+ }
+
+ priceTemplate() {
+ // We are only showing prices in USD for now. In the future we will need
+ // to update this to include other currencies.
+ return html`<span id="price">$${this.product.price}</span>`;
+ }
+
+ render() {
+ this.startImpressionTimer();
+
+ this.revokeImageUrl();
+ this.imageUrl = URL.createObjectURL(this.product.image_blob);
+
+ return html`
+ <link
+ rel="stylesheet"
+ href="chrome://browser/content/shopping/recommended-ad.css"
+ />
+ <shopping-card
+ data-l10n-id="more-to-consider-ad-label"
+ data-l10n-attrs="label"
+ >
+ <a id="recommended-ad-wrapper" slot="content" href=${
+ this.product.url
+ } target="_blank" title="${this.product.name}" @click=${
+ this.handleClick
+ } @auxclick=${this.handleClick}>
+ <div id="ad-content">
+ <img id="ad-preview-image" src=${this.imageUrl}></img>
+ <span id="ad-title" lang="en">${this.product.name}</span>
+ <letter-grade letter="${this.product.grade}"></letter-grade>
+ </div>
+ <div id="footer">
+ ${this.priceTemplate()}
+ <moz-five-star rating=${
+ this.product.adjusted_rating
+ }></moz-five-star>
+ </div>
+ </a>
+ </shopping-card>
+ <p data-l10n-id="ad-by-fakespot"></p>
+ `;
+ }
+}
+
+customElements.define("recommended-ad", RecommendedAd);
diff --git a/browser/components/shopping/content/reliability.mjs b/browser/components/shopping/content/reliability.mjs
new file mode 100644
index 0000000000..1e46b30c4b
--- /dev/null
+++ b/browser/components/shopping/content/reliability.mjs
@@ -0,0 +1,48 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*-
+ * 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 { html, ifDefined } from "chrome://global/content/vendor/lit.all.mjs";
+
+import { MozLitElement } from "chrome://global/content/lit-utils.mjs";
+
+// eslint-disable-next-line import/no-unassigned-import
+import "chrome://browser/content/shopping/letter-grade.mjs";
+// eslint-disable-next-line import/no-unassigned-import
+import "chrome://browser/content/shopping/shopping-card.mjs";
+
+class ReviewReliability extends MozLitElement {
+ static properties = {
+ letter: { type: String },
+ };
+
+ static get queries() {
+ return {
+ letterGradeEl: "letter-grade",
+ };
+ }
+
+ render() {
+ if (!this.letter) {
+ this.hidden = true;
+ return null;
+ }
+
+ return html`
+ <shopping-card
+ data-l10n-id="shopping-review-reliability-label"
+ data-l10n-attrs="label"
+ >
+ <div slot="content">
+ <letter-grade
+ letter=${ifDefined(this.letter)}
+ showdescription
+ ></letter-grade>
+ </div>
+ </shopping-card>
+ `;
+ }
+}
+
+customElements.define("review-reliability", ReviewReliability);
diff --git a/browser/components/shopping/content/settings.css b/browser/components/shopping/content/settings.css
new file mode 100644
index 0000000000..cd0f497a23
--- /dev/null
+++ b/browser/components/shopping/content/settings.css
@@ -0,0 +1,69 @@
+/* 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 url("chrome://global/skin/in-content/common.css");
+
+#shopping-settings-wrapper {
+ --shopping-settings-between-label-and-control-option-gap: 4px;
+ display: grid;
+ grid-template-rows: auto;
+ row-gap: 8px;
+ margin-top: 12px;
+
+ .shopping-settings-toggle-option-wrapper {
+ display: grid;
+ row-gap: var(--shopping-settings-between-label-and-control-option-gap);
+ }
+
+ #shopping-settings-opt-out-section {
+ display: grid;
+ justify-content: center;
+
+ #shopping-settings-opt-out-button {
+ display: flex;
+ align-items: center;
+ }
+ }
+
+ #powered-by-fakespot {
+ font-size: 12px;
+ color: var(--text-color-deemphasized);
+ }
+
+ #shopping-settings-toggles-section {
+ display: grid;
+ row-gap: 8px;
+
+ #shopping-ads-learn-more,
+ #shopping-auto-open-description {
+ margin-inline-end: 20px;
+ color: var(--text-color-deemphasized);
+ }
+ }
+
+ /* When `browser.shopping.experience2023.autoOpen` is true. */
+ &.shopping-settings-auto-open-ui-enabled {
+ --shopping-settings-between-options-gap: 12px;
+ row-gap: var(--shopping-settings-between-options-gap);
+
+ #shopping-settings-toggles-section {
+ display: grid;
+ row-gap: var(--shopping-settings-between-options-gap);
+ }
+
+ .divider {
+ border: var(--shopping-card-border-width) solid var(--shopping-card-border-color);
+ }
+
+ #shopping-settings-opt-out-section {
+ justify-content: flex-start;
+ row-gap: var(--shopping-settings-between-label-and-control-option-gap);
+
+ #shopping-settings-opt-out-button {
+ width: fit-content;
+ margin-inline-start: 0;
+ }
+ }
+ }
+}
diff --git a/browser/components/shopping/content/settings.mjs b/browser/components/shopping/content/settings.mjs
new file mode 100644
index 0000000000..7619bbb131
--- /dev/null
+++ b/browser/components/shopping/content/settings.mjs
@@ -0,0 +1,210 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*-
+ * 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/. */
+
+/* eslint-env mozilla/remote-page */
+
+import { html } from "chrome://global/content/vendor/lit.all.mjs";
+
+import { MozLitElement } from "chrome://global/content/lit-utils.mjs";
+
+// eslint-disable-next-line import/no-unassigned-import
+import "chrome://global/content/elements/moz-toggle.mjs";
+
+class ShoppingSettings extends MozLitElement {
+ static properties = {
+ adsEnabled: { type: Boolean },
+ adsEnabledByUser: { type: Boolean },
+ autoOpenEnabled: { type: Boolean },
+ autoOpenEnabledByUser: { type: Boolean },
+ hostname: { type: String },
+ };
+
+ static get queries() {
+ return {
+ wrapperEl: "#shopping-settings-wrapper",
+ recommendationsToggleEl: "#shopping-settings-recommendations-toggle",
+ autoOpenToggleEl: "#shopping-settings-auto-open-toggle",
+ autoOpenToggleDescriptionEl: "#shopping-auto-open-description",
+ dividerEl: ".divider",
+ sidebarEnabledStateEl: "#shopping-settings-sidebar-enabled-state",
+ optOutButtonEl: "#shopping-settings-opt-out-button",
+ shoppingCardEl: "shopping-card",
+ adsLearnMoreLinkEl: "#shopping-ads-learn-more-link",
+ fakespotLearnMoreLinkEl: "#powered-by-fakespot-link",
+ };
+ }
+
+ onToggleRecommendations() {
+ this.adsEnabledByUser = this.recommendationsToggleEl.pressed;
+ let action = this.adsEnabledByUser ? "enabled" : "disabled";
+ Glean.shopping.surfaceAdsSettingToggled.record({ action });
+ RPMSetPref(
+ "browser.shopping.experience2023.ads.userEnabled",
+ this.adsEnabledByUser
+ );
+ }
+
+ onToggleAutoOpen() {
+ this.autoOpenEnabledByUser = this.autoOpenToggleEl.pressed;
+ let action = this.autoOpenEnabledByUser ? "enabled" : "disabled";
+ Glean.shopping.surfaceAutoOpenSettingToggled.record({ action });
+ RPMSetPref(
+ "browser.shopping.experience2023.autoOpen.userEnabled",
+ this.autoOpenEnabledByUser
+ );
+ if (!this.autoOpenEnabledByUser) {
+ RPMSetPref("browser.shopping.experience2023.active", false);
+ }
+ }
+
+ onDisableShopping() {
+ window.dispatchEvent(
+ new CustomEvent("DisableShopping", { bubbles: true, composed: true })
+ );
+ Glean.shopping.surfaceOptOutButtonClicked.record();
+ }
+
+ fakespotLinkClicked(e) {
+ if (e.target.localName == "a" && e.button == 0) {
+ Glean.shopping.surfacePoweredByFakespotLinkClicked.record();
+ }
+ }
+
+ render() {
+ // Whether we show recommendations at all (including offering a user
+ // control for them) is controlled via a nimbus-enabled pref.
+ let canShowRecommendationToggle = this.adsEnabled;
+
+ let adsToggleMarkup = canShowRecommendationToggle
+ ? html`
+ <div class="shopping-settings-toggle-option-wrapper">
+ <moz-toggle
+ id="shopping-settings-recommendations-toggle"
+ ?pressed=${this.adsEnabledByUser}
+ data-l10n-id="shopping-settings-recommendations-toggle"
+ data-l10n-attrs="label"
+ @toggle=${this.onToggleRecommendations}>
+ </moz-toggle/>
+ <span id="shopping-ads-learn-more" data-l10n-id="shopping-settings-recommendations-learn-more2">
+ <a
+ id="shopping-ads-learn-more-link"
+ target="_blank"
+ href="${window.RPMGetFormatURLPref(
+ "app.support.baseURL"
+ )}review-checker-review-quality?utm_campaign=learn-more&utm_medium=inproduct&utm_term=core-sidebar#w_ads_for_relevant_products"
+ data-l10n-name="review-quality-url"
+ ></a>
+ </span>
+ </div>`
+ : null;
+
+ /* Auto-open experiment changes how the settings card appears by:
+ * 1. Showing a new toggle for enabling/disabling auto-open behaviour
+ * 2. Adding a divider between the toggles and opt-out button
+ * 3. Showing text indicating that Review Checker is enabled (not opted-out) above the opt-out button
+ *
+ * Only show if `browser.shopping.experience2023.autoOpen.enabled` is true.
+ */
+ let autoOpenDescriptionL10nId;
+ let autoOpenDescriptionL10nArgs;
+
+ switch (this.hostname) {
+ case "www.amazon.fr":
+ case "www.amazon.de":
+ autoOpenDescriptionL10nId =
+ "shopping-settings-auto-open-description-single-site";
+ autoOpenDescriptionL10nArgs = {
+ currentSite: "Amazon",
+ };
+ break;
+ default:
+ autoOpenDescriptionL10nId =
+ "shopping-settings-auto-open-description-three-sites";
+ autoOpenDescriptionL10nArgs = {
+ firstSite: "Amazon",
+ secondSite: "Best Buy",
+ thirdSite: "Walmart",
+ };
+ }
+
+ let autoOpenToggleMarkup = this.autoOpenEnabled
+ ? html` <div class="shopping-settings-toggle-option-wrapper">
+ <moz-toggle
+ id="shopping-settings-auto-open-toggle"
+ ?pressed=${this.autoOpenEnabledByUser}
+ data-l10n-id="shopping-settings-auto-open-toggle"
+ data-l10n-attrs="label"
+ @toggle=${this.onToggleAutoOpen}
+ >
+ </moz-toggle>
+ <span
+ id="shopping-auto-open-description"
+ data-l10n-id=${autoOpenDescriptionL10nId}
+ data-l10n-args=${JSON.stringify(autoOpenDescriptionL10nArgs)}
+ ></span>
+ </div>`
+ : null;
+
+ return html`
+ <link
+ rel="stylesheet"
+ href="chrome://browser/content/shopping/settings.css"
+ />
+ <link
+ rel="stylesheet"
+ href="chrome://browser/content/shopping/shopping-page.css"
+ />
+ <shopping-card
+ data-l10n-id="shopping-settings-label"
+ data-l10n-attrs="label"
+ type=${!this.autoOpenEnabled ? "accordion" : ""}
+ >
+ <div
+ id="shopping-settings-wrapper"
+ class=${this.autoOpenEnabled
+ ? "shopping-settings-auto-open-ui-enabled"
+ : ""}
+ slot="content"
+ >
+ <section id="shopping-settings-toggles-section">
+ ${adsToggleMarkup} ${autoOpenToggleMarkup}
+ </section>
+ ${this.autoOpenEnabled
+ ? html`<span class="divider" role="separator"></span>`
+ : null}
+ <section id="shopping-settings-opt-out-section">
+ ${this.autoOpenEnabled
+ ? html`<span
+ id="shopping-settings-sidebar-enabled-state"
+ data-l10n-id="shopping-settings-sidebar-enabled-state"
+ ></span>`
+ : null}
+ <button
+ class="shopping-button"
+ id="shopping-settings-opt-out-button"
+ data-l10n-id="shopping-settings-opt-out-button"
+ @click=${this.onDisableShopping}
+ ></button>
+ </section>
+ </div>
+ </shopping-card>
+ <p
+ id="powered-by-fakespot"
+ class="deemphasized"
+ data-l10n-id="powered-by-fakespot"
+ @click=${this.fakespotLinkClicked}
+ >
+ <a
+ id="powered-by-fakespot-link"
+ data-l10n-name="fakespot-link"
+ target="_blank"
+ href="https://www.fakespot.com/our-mission?utm_source=review-checker&utm_campaign=fakespot-by-mozilla&utm_medium=inproduct&utm_term=core-sidebar"
+ ></a>
+ </p>
+ `;
+ }
+}
+
+customElements.define("shopping-settings", ShoppingSettings);
diff --git a/browser/components/shopping/content/shopping-card.css b/browser/components/shopping/content/shopping-card.css
new file mode 100644
index 0000000000..5b0ce9c280
--- /dev/null
+++ b/browser/components/shopping/content/shopping-card.css
@@ -0,0 +1,201 @@
+/* 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 url("chrome://global/skin/in-content/common.css");
+
+:host {
+ display: block;
+ --shopping-card-border-color: light-dark(#f0f0f4, #52525e);
+ --shopping-card-border-radius: 8px;
+ --shopping-card-border-width: 1px;
+ --shopping-card-summary-border-radius: calc(var(--shopping-card-border-radius) - var(--shopping-card-border-width));
+ --shopping-card-padding: 12px;
+ border-radius: var(--shopping-card-border-radius);
+ box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
+ box-sizing: border-box;
+}
+
+.shopping-card {
+ display: flex;
+ border: var(--shopping-card-border-width) solid var(--shopping-card-border-color);
+ border-radius: var(--shopping-card-border-radius);
+ background-color: var(--in-content-page-background);
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 8px;
+ padding: var(--shopping-card-padding);
+ position: relative;
+}
+
+/* For accordions, adjust padding and border so that focus outlines wrap around the summary. */
+:host([type="accordion"]) > .shopping-card {
+ padding: 0;
+
+ summary {
+ padding: var(--shopping-card-padding);
+ border-radius: var(--shopping-card-summary-border-radius);
+ }
+
+ #content {
+ padding: 0 var(--shopping-card-padding) var(--shopping-card-padding);
+ }
+
+ > details[open] > summary {
+ border-radius: var(--shopping-card-summary-border-radius) var(--shopping-card-summary-border-radius) 0 0;
+ }
+}
+
+button {
+ margin: 0;
+}
+
+#shopping-details {
+ width: 100%;
+}
+
+#label-wrapper {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: space-between;
+ align-items: center;
+ gap: 0.5rem;
+ width: 100%;
+}
+
+:host([type="accordion"]) #label-wrapper {
+ cursor: pointer;
+ position: relative;
+
+ & > #header {
+ /* 24px for the ghost button, 12px gap between
+ * text and button. */
+ margin-inline-end: 36px;
+ }
+}
+
+#header {
+ color: var(--in-content-text-color);
+ font-size: 1em;
+ margin: 0;
+}
+
+#content {
+ align-self: stretch;
+}
+
+details > summary {
+ list-style: none;
+}
+
+details > summary:focus-visible {
+ outline: var(--in-content-focus-outline);
+}
+
+.chevron-icon {
+ background-image: url("chrome://global/skin/icons/arrow-down.svg");
+ background-position: center;
+ background-repeat: no-repeat;
+ color: var(--icon-color);
+ -moz-context-properties: fill;
+ fill: currentColor;
+ width: 24px;
+ height: 24px;
+ /* override some default button sizing styles */
+ min-width: 24px;
+ min-height: 24px;
+ padding: 0;
+ /* Abspos rather than flexbox so we don't influence the padding
+ * around the title.
+ */
+ position: absolute;
+ top: calc(50% - var(--shopping-card-padding));
+ /* This approximates the top/bottom 'padding' gap created by the abspos
+ * above. It won't always be perfectly accurate, and that's OK. */
+ inset-inline-end: -2px;
+}
+
+details[open] .chevron-icon {
+ background-image: url("chrome://global/skin/icons/arrow-up.svg");
+}
+
+.show-more footer {
+ width: 100%;
+ background-color: var(--in-content-page-background);
+ box-shadow: 2px -10px 11px var(--in-content-page-background);
+ border-top: var(--shopping-card-border-width) solid var(--shopping-card-border-color);
+ border-radius: 0 0 var(--shopping-card-border-radius) var(--shopping-card-border-radius);
+ position: absolute;
+ bottom: 0;
+ text-align: center;
+ padding-block: 8px;
+ left: 0;
+ right: 0;
+}
+
+.show-more[expanded="false"] {
+ overflow: clip;
+ height: 200px;
+}
+
+:host(:not([showMoreButtonDisabled])) .show-more ::slotted(div) {
+ margin-block-end: 4rem;
+}
+
+:host([showMoreButtonDisabled]) footer {
+ display: none;
+}
+
+@media (prefers-color-scheme: dark) {
+ :host > .shopping-card {
+ background-color: #42414d;
+ }
+
+ .show-more footer {
+ background-color: #42414d;
+ box-shadow: 2px -10px 11px #42414d;
+ }
+}
+
+@media (prefers-contrast) {
+ /* Style accordion card like a dropdown button. */
+ :host([type="accordion"]) > .shopping-card {
+ border: var(--shopping-card-border-width) solid ButtonText;
+
+ summary {
+ background-color: var(--button-background-color);
+ color: ButtonText;
+
+ #label-wrapper {
+ color: inherit;
+
+ > :is(#header, .chevron-icon) {
+ color: inherit;
+ }
+ }
+
+ .chevron-icon {
+ background-color: transparent;
+ border: none;
+ }
+
+ &:hover {
+ background-color: SelectedItemText;
+ border-color: SelectedItem;
+ color: SelectedItem;
+ }
+
+ &:hover:active {
+ border-color: ButtonText;
+ }
+ }
+
+ details[open] {
+ border-color: var(--shopping-card-border-color);
+
+ summary {
+ border-block-end: var(--shopping-card-border-width) solid ButtonText;
+ }
+ }
+ }
+}
diff --git a/browser/components/shopping/content/shopping-card.mjs b/browser/components/shopping/content/shopping-card.mjs
new file mode 100644
index 0000000000..7b2448df36
--- /dev/null
+++ b/browser/components/shopping/content/shopping-card.mjs
@@ -0,0 +1,204 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*-
+ * 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 { html, ifDefined } from "chrome://global/content/vendor/lit.all.mjs";
+import { MozLitElement } from "chrome://global/content/lit-utils.mjs";
+
+const MIN_SHOW_MORE_HEIGHT = 200;
+/**
+ * A card container to be used in the shopping sidebar. There are three card types.
+ * The default type where no type attribute is required and the card will have no extra functionality.
+ * The "accordion" type will initially not show any content. The card will contain a arrow to expand the
+ * card so all of the content is visible.
+ *
+ * @property {string} label - The label text that will be used for the card header
+ * @property {string} type - (optional) The type of card. No type specified
+ * will be the default card. The other available types are "accordion" and "show-more".
+ */
+class ShoppingCard extends MozLitElement {
+ static properties = {
+ label: { type: String },
+ type: { type: String },
+ _isExpanded: { type: Boolean },
+ };
+
+ static get queries() {
+ return {
+ detailsEl: "#shopping-details",
+ contentEl: "#content",
+ };
+ }
+
+ labelTemplate() {
+ if (this.label) {
+ if (this.type === "accordion") {
+ return html`
+ <div id="label-wrapper">
+ <h2 id="header">${this.label}</h2>
+ <button
+ tabindex="-1"
+ class="icon chevron-icon ghost-button"
+ aria-labelledby="header"
+ @click=${this.handleChevronButtonClick}
+ ></button>
+ </div>
+ `;
+ }
+ return html`
+ <div id="label-wrapper">
+ <h2 id="header">${this.label}</h2>
+ <slot name="rating"></slot>
+ </div>
+ `;
+ }
+ return "";
+ }
+
+ cardTemplate() {
+ if (this.type === "accordion") {
+ return html`
+ <details id="shopping-details" @toggle=${this.onCardToggle}>
+ <summary>${this.labelTemplate()}</summary>
+ <div id="content"><slot name="content"></slot></div>
+ </details>
+ `;
+ } else if (this.type === "show-more") {
+ return html`
+ ${this.labelTemplate()}
+ <article
+ id="content"
+ class="show-more"
+ aria-describedby="content"
+ expanded="false"
+ >
+ <slot name="content"></slot>
+
+ <footer>
+ <button
+ aria-controls="content"
+ class="small-button shopping-button"
+ data-l10n-id="shopping-show-more-button"
+ @click=${this.handleShowMoreButtonClick}
+ ></button>
+ </footer>
+ </article>
+ `;
+ }
+ return html`
+ ${this.labelTemplate()}
+ <div id="content" aria-describedby="content">
+ <slot name="content"></slot>
+ </div>
+ `;
+ }
+
+ onCardToggle() {
+ const action = this.detailsEl.open ? "expanded" : "collapsed";
+ let l10nId = this.getAttribute("data-l10n-id");
+ switch (l10nId) {
+ case "shopping-settings-label":
+ Glean.shopping.surfaceSettingsExpandClicked.record({ action });
+ break;
+ case "shopping-analysis-explainer-label":
+ Glean.shopping.surfaceShowQualityExplainerClicked.record({
+ action,
+ });
+ break;
+ }
+ }
+
+ handleShowMoreButtonClick(e) {
+ this._isExpanded = !this._isExpanded;
+ // toggle show more/show less text
+ e.target.setAttribute(
+ "data-l10n-id",
+ this._isExpanded
+ ? "shopping-show-less-button"
+ : "shopping-show-more-button"
+ );
+ // toggle content expanded attribute
+ this.contentEl.attributes.expanded.value = this._isExpanded;
+
+ let action = this._isExpanded ? "expanded" : "collapsed";
+ Glean.shopping.surfaceShowMoreReviewsButtonClicked.record({
+ action,
+ });
+ }
+
+ enableShowMoreButton() {
+ this._isExpanded = false;
+ this.toggleAttribute("showMoreButtonDisabled", false);
+ this.contentEl.attributes.expanded.value = false;
+ }
+
+ disableShowMoreButton() {
+ this._isExpanded = true;
+ this.toggleAttribute("showMoreButtonDisabled", true);
+ this.contentEl.attributes.expanded.value = true;
+ }
+
+ handleChevronButtonClick() {
+ this.detailsEl.open = !this.detailsEl.open;
+ }
+
+ firstUpdated() {
+ if (this.type !== "show-more") {
+ return;
+ }
+
+ let contentSlot = this.shadowRoot.querySelector("slot[name='content']");
+ let contentSlotEls = contentSlot.assignedElements();
+ if (!contentSlotEls.length) {
+ return;
+ }
+
+ let slottedDiv = contentSlotEls[0];
+
+ this.handleContentSlotResize = this.handleContentSlotResize.bind(this);
+ this.contentResizeObserver = new ResizeObserver(
+ this.handleContentSlotResize
+ );
+ this.contentResizeObserver.observe(slottedDiv);
+ }
+
+ disconnectedCallback() {
+ this.contentResizeObserver?.disconnect();
+ }
+
+ handleContentSlotResize(entries) {
+ for (let entry of entries) {
+ if (entry.contentRect.height === 0) {
+ return;
+ }
+
+ if (entry.contentRect.height < MIN_SHOW_MORE_HEIGHT) {
+ this.disableShowMoreButton();
+ } else if (this.hasAttribute("showMoreButtonDisabled")) {
+ this.enableShowMoreButton();
+ }
+ }
+ }
+
+ render() {
+ return html`
+ <link
+ rel="stylesheet"
+ href="chrome://browser/content/shopping/shopping-card.css"
+ />
+ <link
+ rel="stylesheet"
+ href="chrome://browser/content/shopping/shopping-page.css"
+ />
+ <article
+ class="shopping-card"
+ aria-labelledby="header"
+ aria-label=${ifDefined(this.label)}
+ >
+ ${this.cardTemplate()}
+ </article>
+ `;
+ }
+}
+customElements.define("shopping-card", ShoppingCard);
diff --git a/browser/components/shopping/content/shopping-container.css b/browser/components/shopping/content/shopping-container.css
new file mode 100644
index 0000000000..6ac09964bf
--- /dev/null
+++ b/browser/components/shopping/content/shopping-container.css
@@ -0,0 +1,152 @@
+/* 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/. */
+
+:host {
+ --shopping-close-button-size: var(--button-min-height);
+ --shopping-header-font-size: 1.3rem;
+}
+
+#shopping-container {
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+}
+
+#header-wrapper {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 8px;
+ background-color: var(--shopping-header-background);
+ box-sizing: border-box;
+ padding-block: 16px 8px;
+ padding-inline: 16px 8px;
+ position: sticky;
+ top: 0;
+ width: 100%;
+ z-index: 2;
+}
+
+#shopping-header {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+#shopping-header-title {
+ font-size: var(--shopping-header-font-size);
+ font-weight: var(--font-weight-bold);
+ margin: 0;
+}
+
+.shopping-header-overflow {
+ box-shadow: 0 1px 2px 0 rgba(58, 57, 68, 0.20);
+}
+
+#beta-marker {
+ font-size: var(--font-size-small);
+ font-weight: var(--font-weight-default);
+ padding: 2px 4px;
+ margin: 0;
+ line-height: 150%;
+ text-transform: uppercase;
+ color: var(--icon-color);
+ border: 1px solid var(--icon-color);
+ border-radius: var(--border-radius-small);
+}
+
+#close-button {
+ min-width: var(--shopping-close-button-size);
+ min-height: var(--shopping-close-button-size);
+ -moz-context-properties: fill;
+ fill: currentColor;
+ background-image: url("chrome://global/skin/icons/close.svg");
+ background-repeat: no-repeat;
+ background-position: center;
+ margin-inline-end: 0;
+
+ @media not (prefers-contrast) {
+ color: var(--icon-color);
+ }
+}
+
+#content {
+ overflow: auto;
+ display: flex;
+ flex-direction: column;
+ align-items: stretch;
+ gap: 16px;
+ padding: 0 16px 16px;
+}
+
+#content:focus-visible {
+ outline-offset: -2px;
+}
+
+.loading-box {
+ box-shadow: none;
+ border: none;
+ background: var(--in-content-button-background);
+ margin-block: 1rem;
+}
+
+.loading-box.small {
+ height: 2.67rem;
+}
+
+.loading-box.medium {
+ height: 5.34rem;
+}
+
+.loading-box.large {
+ height: 12.8rem;
+}
+
+.loading-box:nth-child(odd) {
+ background-color: var(--in-content-button-background);
+}
+
+.loading-box:nth-child(even) {
+ background-color: var(--in-content-button-background-hover);
+}
+
+@media not (prefers-reduced-motion) {
+ .animate > .loading-box {
+ animation-name: fade-in;
+ animation-direction: alternate;
+ animation-duration: 1s;
+ animation-iteration-count: infinite;
+ animation-timing-function: ease-in-out;
+ }
+
+ /* First box + every 4th box, fifth box + every 4th box */
+ .loading-box:nth-child(4n-3) {
+ animation-delay: -1s;
+ }
+
+ /* Second box + every 4th box, sixth box + every 4th box */
+ .loading-box:nth-child(4n-2) {
+ animation-delay: 0s;
+ }
+
+ /* Third box + every 4th box */
+ .loading-box:nth-child(4n-1) {
+ animation-delay: -1.5s;
+ }
+
+ /* Fourth box + every 4th box */
+ .loading-box:nth-child(4n) {
+ animation-delay: -0.5s;
+ }
+
+ @keyframes fade-in {
+ from {
+ opacity: .25;
+ }
+ to {
+ opacity: 1;
+ }
+ }
+}
diff --git a/browser/components/shopping/content/shopping-container.mjs b/browser/components/shopping/content/shopping-container.mjs
new file mode 100644
index 0000000000..a1a6774d49
--- /dev/null
+++ b/browser/components/shopping/content/shopping-container.mjs
@@ -0,0 +1,471 @@
+/* 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/. */
+
+/* eslint-env mozilla/remote-page */
+
+import { MozLitElement } from "chrome://global/content/lit-utils.mjs";
+import { html, ifDefined } from "chrome://global/content/vendor/lit.all.mjs";
+
+// eslint-disable-next-line import/no-unassigned-import
+import "chrome://browser/content/shopping/highlights.mjs";
+// eslint-disable-next-line import/no-unassigned-import
+import "chrome://browser/content/shopping/settings.mjs";
+// eslint-disable-next-line import/no-unassigned-import
+import "chrome://browser/content/shopping/adjusted-rating.mjs";
+// eslint-disable-next-line import/no-unassigned-import
+import "chrome://browser/content/shopping/reliability.mjs";
+// eslint-disable-next-line import/no-unassigned-import
+import "chrome://browser/content/shopping/analysis-explainer.mjs";
+// eslint-disable-next-line import/no-unassigned-import
+import "chrome://browser/content/shopping/shopping-message-bar.mjs";
+// eslint-disable-next-line import/no-unassigned-import
+import "chrome://browser/content/shopping/unanalyzed.mjs";
+// eslint-disable-next-line import/no-unassigned-import
+import "chrome://browser/content/shopping/recommended-ad.mjs";
+
+// The number of pixels that must be scrolled from the
+// top of the sidebar to show the header box shadow.
+const HEADER_SCROLL_PIXEL_OFFSET = 8;
+
+const SIDEBAR_CLOSED_COUNT_PREF =
+ "browser.shopping.experience2023.sidebarClosedCount";
+const SHOW_KEEP_SIDEBAR_CLOSED_MESSAGE_PREF =
+ "browser.shopping.experience2023.showKeepSidebarClosedMessage";
+const SHOPPING_SIDEBAR_ACTIVE_PREF = "browser.shopping.experience2023.active";
+
+export class ShoppingContainer extends MozLitElement {
+ static properties = {
+ data: { type: Object },
+ showOnboarding: { type: Boolean },
+ productUrl: { type: String },
+ recommendationData: { type: Array },
+ isOffline: { type: Boolean },
+ analysisEvent: { type: Object },
+ userReportedAvailable: { type: Boolean },
+ adsEnabled: { type: Boolean },
+ adsEnabledByUser: { type: Boolean },
+ isAnalysisInProgress: { type: Boolean },
+ analysisProgress: { type: Number },
+ isOverflow: { type: Boolean },
+ autoOpenEnabled: { type: Boolean },
+ autoOpenEnabledByUser: { type: Boolean },
+ showingKeepClosedMessage: { type: Boolean },
+ };
+
+ static get queries() {
+ return {
+ reviewReliabilityEl: "review-reliability",
+ adjustedRatingEl: "adjusted-rating",
+ highlightsEl: "review-highlights",
+ settingsEl: "shopping-settings",
+ analysisExplainerEl: "analysis-explainer",
+ unanalyzedProductEl: "unanalyzed-product-card",
+ shoppingMessageBarEl: "shopping-message-bar",
+ recommendedAdEl: "recommended-ad",
+ loadingEl: "#loading-wrapper",
+ closeButtonEl: "#close-button",
+ keepClosedMessageBarEl: "#keep-closed-message-bar",
+ };
+ }
+
+ connectedCallback() {
+ super.connectedCallback();
+ if (this.initialized) {
+ return;
+ }
+ this.initialized = true;
+
+ window.document.addEventListener("Update", this);
+ window.document.addEventListener("NewAnalysisRequested", this);
+ window.document.addEventListener("ReanalysisRequested", this);
+ window.document.addEventListener("ReportedProductAvailable", this);
+ window.document.addEventListener("adsEnabledByUserChanged", this);
+ window.document.addEventListener("scroll", this);
+ window.document.addEventListener("UpdateRecommendations", this);
+ window.document.addEventListener("UpdateAnalysisProgress", this);
+ window.document.addEventListener("autoOpenEnabledByUserChanged", this);
+ window.document.addEventListener("ShowKeepClosedMessage", this);
+ window.document.addEventListener("HideKeepClosedMessage", this);
+
+ window.dispatchEvent(
+ new CustomEvent("ContentReady", {
+ bubbles: true,
+ composed: true,
+ })
+ );
+ }
+
+ updated() {
+ if (this.focusCloseButton) {
+ this.closeButtonEl.focus();
+ }
+ }
+
+ async _update({
+ data,
+ showOnboarding,
+ productUrl,
+ recommendationData,
+ adsEnabled,
+ adsEnabledByUser,
+ isAnalysisInProgress,
+ analysisProgress,
+ focusCloseButton,
+ autoOpenEnabled,
+ autoOpenEnabledByUser,
+ }) {
+ // If we're not opted in or there's no shopping URL in the main browser,
+ // the actor will pass `null`, which means this will clear out any existing
+ // content in the sidebar.
+ this.data = data;
+ this.showOnboarding = showOnboarding;
+ this.productUrl = productUrl;
+ this.recommendationData = recommendationData;
+ this.isOffline = !navigator.onLine;
+ this.isAnalysisInProgress = isAnalysisInProgress;
+ this.adsEnabled = adsEnabled;
+ this.adsEnabledByUser = adsEnabledByUser;
+ this.analysisProgress = analysisProgress;
+ this.focusCloseButton = focusCloseButton;
+ this.autoOpenEnabled = autoOpenEnabled;
+ this.autoOpenEnabledByUser = autoOpenEnabledByUser;
+ }
+
+ _updateRecommendations({ recommendationData }) {
+ this.recommendationData = recommendationData;
+ }
+
+ _updateAnalysisProgress({ progress }) {
+ this.analysisProgress = progress;
+ }
+
+ handleEvent(event) {
+ switch (event.type) {
+ case "Update":
+ this._update(event.detail);
+ break;
+ case "NewAnalysisRequested":
+ case "ReanalysisRequested":
+ this.isAnalysisInProgress = true;
+ this.analysisEvent = {
+ type: event.type,
+ productUrl: this.productUrl,
+ };
+ window.dispatchEvent(
+ new CustomEvent("PolledRequestMade", {
+ bubbles: true,
+ composed: true,
+ })
+ );
+ break;
+ case "ReportedProductAvailable":
+ this.userReportedAvailable = true;
+ window.dispatchEvent(
+ new CustomEvent("ReportProductAvailable", {
+ bubbles: true,
+ composed: true,
+ })
+ );
+ Glean.shopping.surfaceReactivatedButtonClicked.record();
+ break;
+ case "adsEnabledByUserChanged":
+ this.adsEnabledByUser = event.detail?.adsEnabledByUser;
+ break;
+ case "scroll":
+ let scrollYPosition = window.scrollY;
+ this.isOverflow = scrollYPosition > HEADER_SCROLL_PIXEL_OFFSET;
+ break;
+ case "UpdateRecommendations":
+ this._updateRecommendations(event.detail);
+ break;
+ case "UpdateAnalysisProgress":
+ this._updateAnalysisProgress(event.detail);
+ break;
+ case "autoOpenEnabledByUserChanged":
+ this.autoOpenEnabledByUser = event.detail?.autoOpenEnabledByUser;
+ break;
+ case "ShowKeepClosedMessage":
+ this.showingKeepClosedMessage = true;
+ break;
+ case "HideKeepClosedMessage":
+ this.showingKeepClosedMessage = false;
+ break;
+ }
+ }
+
+ getHostnameFromProductUrl() {
+ let hostname;
+ try {
+ hostname = new URL(this.productUrl)?.hostname;
+ return hostname;
+ } catch (e) {
+ console.error(`Unknown product url ${this.productUrl}.`);
+ return null;
+ }
+ }
+
+ analysisDetailsTemplate() {
+ /* At present, en is supported as the default language for reviews. As we support more sites,
+ * update `lang` accordingly if highlights need to be displayed in other languages. */
+ let lang;
+ let hostname = this.getHostnameFromProductUrl();
+
+ switch (hostname) {
+ case "www.amazon.fr":
+ lang = "fr";
+ break;
+ case "www.amazon.de":
+ lang = "de";
+ break;
+ default:
+ lang = "en";
+ }
+ return html`
+ <review-reliability letter=${this.data.grade}></review-reliability>
+ <adjusted-rating
+ rating=${ifDefined(this.data.adjusted_rating)}
+ ></adjusted-rating>
+ <review-highlights
+ .highlights=${this.data.highlights}
+ lang=${lang}
+ ></review-highlights>
+ `;
+ }
+
+ contentTemplate() {
+ // The user requested an analysis which is not done yet.
+ if (
+ this.analysisEvent?.productUrl == this.productUrl &&
+ this.isAnalysisInProgress
+ ) {
+ const isReanalysis = this.analysisEvent.type === "ReanalysisRequested";
+ return html`<shopping-message-bar
+ type=${isReanalysis
+ ? "reanalysis-in-progress"
+ : "analysis-in-progress"}
+ progress=${this.analysisProgress}
+ ></shopping-message-bar>
+ ${isReanalysis ? this.analysisDetailsTemplate() : null}`;
+ }
+
+ if (this.data?.error) {
+ return html`<shopping-message-bar
+ type="generic-error"
+ ></shopping-message-bar>`;
+ }
+
+ if (this.data.page_not_supported) {
+ return html`<shopping-message-bar
+ type="page-not-supported"
+ ></shopping-message-bar>`;
+ }
+
+ if (this.data.deleted_product_reported) {
+ return html`<shopping-message-bar
+ type="product-not-available-reported"
+ ></shopping-message-bar>`;
+ }
+
+ if (this.data.deleted_product) {
+ return this.userReportedAvailable
+ ? html`<shopping-message-bar
+ type="thanks-for-reporting"
+ ></shopping-message-bar>`
+ : html`<shopping-message-bar
+ type="product-not-available"
+ ></shopping-message-bar>`;
+ }
+
+ if (this.data.needs_analysis) {
+ if (!this.data.product_id || typeof this.data.grade != "string") {
+ // Product is new to us.
+ return html`<unanalyzed-product-card
+ productUrl=${ifDefined(this.productUrl)}
+ ></unanalyzed-product-card>`;
+ }
+
+ // We successfully analyzed the product before, but the current analysis is outdated and can be updated
+ // via a re-analysis.
+ return html`
+ <shopping-message-bar
+ type="stale"
+ .productUrl=${this.productUrl}
+ ></shopping-message-bar>
+ ${this.analysisDetailsTemplate()}
+ `;
+ }
+
+ if (this.data.not_enough_reviews) {
+ // We already saw and tried to analyze this product before, but there are not enough reviews
+ // to make a detailed analysis.
+ return html`<shopping-message-bar
+ type="not-enough-reviews"
+ ></shopping-message-bar>`;
+ }
+
+ return this.analysisDetailsTemplate();
+ }
+
+ recommendationTemplate() {
+ const canShowAds = this.adsEnabled && this.adsEnabledByUser;
+ if (this.recommendationData?.length && canShowAds) {
+ return html`<recommended-ad
+ .product=${this.recommendationData[0]}
+ ></recommended-ad>`;
+ }
+ return null;
+ }
+
+ /**
+ * @param {object?} options
+ * @param {boolean?} options.animate = true
+ * Whether to animate the loading state. Defaults to true.
+ * There will be no animation for users who prefer reduced motion,
+ * irrespective of the value of this option.
+ */
+ loadingTemplate({ animate = true } = {}) {
+ /* Due to limitations with aria-busy for certain screen readers
+ * (see Bug 1682063), mark loading container as a pseudo image and
+ * use aria-label as a workaround. */
+ return html`
+ <div
+ id="loading-wrapper"
+ data-l10n-id="shopping-a11y-loading"
+ role="img"
+ class=${animate ? "animate" : ""}
+ >
+ <div class="loading-box medium"></div>
+ <div class="loading-box medium"></div>
+ <div class="loading-box large"></div>
+ <div class="loading-box small"></div>
+ <div class="loading-box large"></div>
+ <div class="loading-box small"></div>
+ </div>
+ `;
+ }
+
+ renderContainer(sidebarContent, hideFooter = false) {
+ return html`<link
+ rel="stylesheet"
+ href="chrome://browser/content/shopping/shopping-container.css"
+ />
+ <link
+ rel="stylesheet"
+ href="chrome://global/skin/in-content/common.css"
+ />
+ <link
+ rel="stylesheet"
+ href="chrome://browser/content/shopping/shopping-page.css"
+ />
+ <div id="shopping-container">
+ <div
+ id="header-wrapper"
+ class=${this.isOverflow ? "shopping-header-overflow" : ""}
+ >
+ <header id="shopping-header" data-l10n-id="shopping-a11y-header">
+ <h1
+ id="shopping-header-title"
+ data-l10n-id="shopping-main-container-title"
+ ></h1>
+ <p id="beta-marker" data-l10n-id="shopping-beta-marker"></p>
+ </header>
+ <button
+ id="close-button"
+ class="ghost-button shopping-button"
+ data-l10n-id="shopping-close-button"
+ @click=${this.handleCloseButtonClick}
+ ></button>
+ </div>
+ <div id="content" aria-live="polite" aria-busy=${!this.data}>
+ <slot name="multi-stage-message-slot"></slot>
+ ${this.keepClosedMessageTemplate()}${sidebarContent}
+ ${!hideFooter ? this.footerTemplate() : null}
+ </div>
+ </div>`;
+ }
+
+ footerTemplate() {
+ let hostname = this.getHostnameFromProductUrl();
+ return html`
+ <analysis-explainer
+ productUrl=${ifDefined(this.productUrl)}
+ ></analysis-explainer>
+ ${this.recommendationTemplate()}
+ <shopping-settings
+ ?adsEnabled=${this.adsEnabled}
+ ?adsEnabledByUser=${this.adsEnabledByUser}
+ ?autoOpenEnabled=${this.autoOpenEnabled}
+ ?autoOpenEnabledByUser=${this.autoOpenEnabledByUser}
+ .hostname=${hostname}
+ ></shopping-settings>
+ `;
+ }
+
+ keepClosedMessageTemplate() {
+ if (
+ this.autoOpenEnabled &&
+ this.autoOpenEnabledByUser &&
+ this.showingKeepClosedMessage &&
+ RPMGetBoolPref(SHOW_KEEP_SIDEBAR_CLOSED_MESSAGE_PREF, true)
+ ) {
+ return html`<shopping-message-bar
+ id="keep-closed-message-bar"
+ type="keep-closed"
+ ></shopping-message-bar>`;
+ }
+ return null;
+ }
+
+ render() {
+ let content;
+ let hideFooter;
+ if (this.showOnboarding) {
+ content = html``;
+ hideFooter = true;
+ } else if (this.isOffline) {
+ content = this.loadingTemplate({ animate: false });
+ hideFooter = true;
+ } else if (!this.data) {
+ if (this.isAnalysisInProgress) {
+ content = html`<shopping-message-bar
+ type="analysis-in-progress"
+ progress=${this.analysisProgress}
+ ></shopping-message-bar>`;
+ } else {
+ content = this.loadingTemplate();
+ hideFooter = true;
+ }
+ } else {
+ content = this.contentTemplate();
+ }
+ return this.renderContainer(content, hideFooter);
+ }
+
+ handleCloseButtonClick() {
+ if (this.autoOpenEnabled && this.autoOpenEnabledByUser) {
+ let sidebarClosedCount = RPMGetIntPref(SIDEBAR_CLOSED_COUNT_PREF, 0);
+ if (
+ !this.showingKeepClosedMessage &&
+ sidebarClosedCount >= 4 &&
+ RPMGetBoolPref(SHOW_KEEP_SIDEBAR_CLOSED_MESSAGE_PREF, true)
+ ) {
+ this.showingKeepClosedMessage = true;
+ return;
+ }
+
+ this.showingKeepClosedMessage = false;
+
+ if (sidebarClosedCount >= 6) {
+ RPMSetPref(SHOW_KEEP_SIDEBAR_CLOSED_MESSAGE_PREF, false);
+ }
+
+ RPMSetPref(SIDEBAR_CLOSED_COUNT_PREF, sidebarClosedCount + 1);
+ }
+
+ RPMSetPref(SHOPPING_SIDEBAR_ACTIVE_PREF, false);
+ Glean.shopping.surfaceClosed.record({ source: "closeButton" });
+ }
+}
+
+customElements.define("shopping-container", ShoppingContainer);
diff --git a/browser/components/shopping/content/shopping-message-bar.css b/browser/components/shopping/content/shopping-message-bar.css
new file mode 100644
index 0000000000..f88b18be45
--- /dev/null
+++ b/browser/components/shopping/content/shopping-message-bar.css
@@ -0,0 +1,78 @@
+/* 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 url("chrome://global/skin/in-content/common.css");
+
+:host {
+ display: block;
+ border-radius: 4px;
+}
+
+#message-bar-container {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ padding: 0;
+ margin: 0;
+}
+
+:host([type="stale"]) #message-bar-container {
+ display: flex;
+ flex-direction: row;
+ flex-wrap: wrap;
+ column-gap: 8px;
+
+ > span {
+ display: flex;
+ flex-direction: column;
+ align-self: center;
+ }
+}
+
+button {
+ margin-inline-start: 0;
+}
+
+message-bar::part(container) {
+ align-items: start;
+ padding: 0.5rem 0.75rem;
+ gap: 0.75rem;
+}
+
+message-bar::part(icon) {
+ padding: 0;
+}
+
+:host([type=analysis-in-progress]) message-bar::part(icon),
+:host([type=reanalysis-in-progress]) message-bar::part(icon) {
+ border: 1px solid var(--icon-color);
+ border-radius: 50%;
+}
+
+:host([type=analysis-in-progress]) message-bar::part(icon)::after,
+:host([type=reanalysis-in-progress]) message-bar::part(icon)::after {
+ --message-bar-icon-url: conic-gradient(var(--icon-color-information) var(--analysis-progress-pcent, 0%), transparent var(--analysis-progress-pcent, 0%));
+ border-radius: 50%;
+ margin-block: 1px 0;
+ margin-inline: 1px 0;
+ width: calc(var(--icon-size) - 2px);
+ height: calc(var(--icon-size) - 2px);
+}
+
+:host([type=reanalysis-in-progress]) message-bar::part(container),
+:host([type=stale]) message-bar::part(container) {
+ align-items: center;
+ background-color: transparent;
+ padding: 0;
+}
+
+:host([type=thank-you-for-feedback]) message-bar::part(icon) {
+ --message-bar-icon-url: url("chrome://global/skin/icons/check-filled.svg");
+}
+
+:host([type=thank-you-for-feedback]) message-bar::part(container) {
+ text-align: start;
+ align-items: center;
+ gap: 12px;
+}
diff --git a/browser/components/shopping/content/shopping-message-bar.mjs b/browser/components/shopping/content/shopping-message-bar.mjs
new file mode 100644
index 0000000000..d6ec9c0888
--- /dev/null
+++ b/browser/components/shopping/content/shopping-message-bar.mjs
@@ -0,0 +1,278 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*-
+ * 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/. */
+
+/* eslint-env mozilla/remote-page */
+
+import { html, styleMap } from "chrome://global/content/vendor/lit.all.mjs";
+import { MozLitElement } from "chrome://global/content/lit-utils.mjs";
+
+// eslint-disable-next-line import/no-unassigned-import
+import "chrome://global/content/elements/moz-message-bar.mjs";
+
+const SHOPPING_SIDEBAR_ACTIVE_PREF = "browser.shopping.experience2023.active";
+const SHOW_KEEP_SIDEBAR_CLOSED_MESSAGE_PREF =
+ "browser.shopping.experience2023.showKeepSidebarClosedMessage";
+const SHOPPING_AUTO_OPEN_SIDEBAR_PREF =
+ "browser.shopping.experience2023.autoOpen.userEnabled";
+
+class ShoppingMessageBar extends MozLitElement {
+ #MESSAGE_TYPES_RENDER_TEMPLATE_MAPPING = new Map([
+ ["stale", () => this.staleWarningTemplate()],
+ ["generic-error", () => this.genericErrorTemplate()],
+ ["not-enough-reviews", () => this.notEnoughReviewsTemplate()],
+ ["product-not-available", () => this.productNotAvailableTemplate()],
+ ["thanks-for-reporting", () => this.thanksForReportingTemplate()],
+ [
+ "product-not-available-reported",
+ () => this.productNotAvailableReportedTemplate(),
+ ],
+ ["analysis-in-progress", () => this.analysisInProgressTemplate()],
+ ["reanalysis-in-progress", () => this.reanalysisInProgressTemplate()],
+ ["page-not-supported", () => this.pageNotSupportedTemplate()],
+ ["thank-you-for-feedback", () => this.thankYouForFeedbackTemplate()],
+ ["keep-closed", () => this.keepClosedTemplate()],
+ ]);
+
+ static properties = {
+ type: { type: String },
+ productUrl: { type: String, reflect: true },
+ progress: { type: Number, reflect: true },
+ };
+
+ static get queries() {
+ return {
+ reAnalysisButtonEl: "#message-bar-reanalysis-button",
+ productAvailableBtnEl: "#message-bar-report-product-available-btn",
+ yesKeepClosedButtonEl: "#yes-keep-closed-button",
+ noThanksButtonEl: "#no-thanks-button",
+ };
+ }
+
+ onClickAnalysisButton() {
+ this.dispatchEvent(
+ new CustomEvent("ReanalysisRequested", {
+ bubbles: true,
+ composed: true,
+ })
+ );
+ Glean.shopping.surfaceReanalyzeClicked.record();
+ }
+
+ onClickProductAvailable() {
+ this.dispatchEvent(
+ new CustomEvent("ReportedProductAvailable", {
+ bubbles: true,
+ composed: true,
+ })
+ );
+ }
+
+ handleNoThanksClick() {
+ RPMSetPref(SHOPPING_SIDEBAR_ACTIVE_PREF, false);
+ RPMSetPref(SHOW_KEEP_SIDEBAR_CLOSED_MESSAGE_PREF, false);
+ this.dispatchEvent(
+ new CustomEvent("HideKeepClosedMessage", {
+ bubbles: true,
+ composed: true,
+ })
+ );
+ Glean.shopping.surfaceNoThanksButtonClicked.record();
+ }
+
+ handleKeepClosedClick() {
+ RPMSetPref(SHOPPING_SIDEBAR_ACTIVE_PREF, false);
+ RPMSetPref(SHOW_KEEP_SIDEBAR_CLOSED_MESSAGE_PREF, false);
+ RPMSetPref(SHOPPING_AUTO_OPEN_SIDEBAR_PREF, false);
+ this.dispatchEvent(
+ new CustomEvent("HideKeepClosedMessage", {
+ bubbles: true,
+ composed: true,
+ })
+ );
+ Glean.shopping.surfaceYesKeepClosedButtonClicked.record();
+ }
+
+ staleWarningTemplate() {
+ return html`<message-bar>
+ <article id="message-bar-container" aria-labelledby="header">
+ <span
+ data-l10n-id="shopping-message-bar-warning-stale-analysis-message-2"
+ ></span>
+ <button
+ id="message-bar-reanalysis-button"
+ class="small-button shopping-button"
+ data-l10n-id="shopping-message-bar-warning-stale-analysis-button"
+ @click=${this.onClickAnalysisButton}
+ ></button>
+ </article>
+ </message-bar>`;
+ }
+
+ genericErrorTemplate() {
+ return html`<moz-message-bar
+ data-l10n-attrs="heading, message"
+ type="warning"
+ data-l10n-id="shopping-message-bar-generic-error"
+ >
+ </moz-message-bar>`;
+ }
+
+ notEnoughReviewsTemplate() {
+ return html`<moz-message-bar
+ data-l10n-attrs="heading, message"
+ type="warning"
+ data-l10n-id="shopping-message-bar-warning-not-enough-reviews"
+ >
+ </moz-message-bar>`;
+ }
+
+ productNotAvailableTemplate() {
+ return html`<moz-message-bar
+ data-l10n-attrs="heading, message"
+ type="warning"
+ data-l10n-id="shopping-message-bar-warning-product-not-available"
+ >
+ <button
+ slot="actions"
+ id="message-bar-report-product-available-btn"
+ class="small-button shopping-button"
+ data-l10n-id="shopping-message-bar-warning-product-not-available-button2"
+ @click=${this.onClickProductAvailable}
+ ></button>
+ </moz-message-bar>`;
+ }
+
+ thanksForReportingTemplate() {
+ return html`<moz-message-bar
+ data-l10n-attrs="heading, message"
+ type="info"
+ data-l10n-id="shopping-message-bar-thanks-for-reporting"
+ >
+ </moz-message-bar>`;
+ }
+
+ productNotAvailableReportedTemplate() {
+ return html`<moz-message-bar
+ data-l10n-attrs="heading, message"
+ type="warning"
+ data-l10n-id="shopping-message-bar-warning-product-not-available-reported"
+ >
+ </moz-message-bar>`;
+ }
+
+ analysisInProgressTemplate() {
+ return html`<message-bar
+ style=${styleMap({
+ "--analysis-progress-pcent": `${this.progress}%`,
+ })}
+ >
+ <article
+ id="message-bar-container"
+ aria-labelledby="header"
+ type="analysis"
+ >
+ <strong
+ id="header"
+ data-l10n-id="shopping-message-bar-analysis-in-progress-with-amount"
+ data-l10n-args="${JSON.stringify({
+ percentage: Math.round(this.progress),
+ })}"
+ ></strong>
+ <span
+ data-l10n-id="shopping-message-bar-analysis-in-progress-message2"
+ ></span>
+ </article>
+ </message-bar>`;
+ }
+
+ reanalysisInProgressTemplate() {
+ return html`<message-bar
+ style=${styleMap({
+ "--analysis-progress-pcent": `${this.progress}%`,
+ })}
+ >
+ <article
+ id="message-bar-container"
+ aria-labelledby="header"
+ type="re-analysis"
+ >
+ <span
+ id="header"
+ data-l10n-id="shopping-message-bar-analysis-in-progress-with-amount"
+ data-l10n-args="${JSON.stringify({
+ percentage: Math.round(this.progress),
+ })}"
+ ></span>
+ </article>
+ </message-bar>`;
+ }
+
+ pageNotSupportedTemplate() {
+ return html`<moz-message-bar
+ data-l10n-attrs="heading, message"
+ type="warning"
+ data-l10n-id="shopping-message-bar-page-not-supported"
+ >
+ </moz-message-bar>`;
+ }
+
+ thankYouForFeedbackTemplate() {
+ return html`<moz-message-bar
+ data-l10n-attrs="heading"
+ type="success"
+ dismissable
+ data-l10n-id="shopping-survey-thanks"
+ >
+ </moz-message-bar>`;
+ }
+
+ keepClosedTemplate() {
+ return html`<moz-message-bar
+ data-l10n-attrs="heading, message"
+ type="info"
+ data-l10n-id="shopping-message-bar-keep-closed-header"
+ >
+ <moz-button-group slot="actions">
+ <button
+ id="no-thanks-button"
+ class="small-button shopping-button"
+ data-l10n-id="shopping-message-bar-keep-closed-dismiss-button"
+ @click=${this.handleNoThanksClick}
+ ></button>
+ <button
+ id="yes-keep-closed-button"
+ class="primary small-button shopping-button"
+ data-l10n-id="shopping-message-bar-keep-closed-accept-button"
+ @click=${this.handleKeepClosedClick}
+ ></button>
+ </moz-button-group>
+ </moz-message-bar>`;
+ }
+
+ render() {
+ let messageBarTemplate = this.#MESSAGE_TYPES_RENDER_TEMPLATE_MAPPING.get(
+ this.type
+ )();
+ if (messageBarTemplate) {
+ if (this.type == "stale") {
+ Glean.shopping.surfaceStaleAnalysisShown.record();
+ }
+ return html`
+ <link
+ rel="stylesheet"
+ href="chrome://browser/content/shopping/shopping-message-bar.css"
+ />
+ <link
+ rel="stylesheet"
+ href="chrome://browser/content/shopping/shopping-page.css"
+ />
+ ${messageBarTemplate}
+ `;
+ }
+ return null;
+ }
+}
+
+customElements.define("shopping-message-bar", ShoppingMessageBar);
diff --git a/browser/components/shopping/content/shopping-page.css b/browser/components/shopping/content/shopping-page.css
new file mode 100644
index 0000000000..b6fd43704f
--- /dev/null
+++ b/browser/components/shopping/content/shopping-page.css
@@ -0,0 +1,28 @@
+/* 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/. */
+
+:root {
+ --shopping-header-background: light-dark(#f9f9fb, #2b2a33);
+ background-color: var(--shopping-header-background);
+ font: menu;
+}
+
+@media (prefers-contrast) {
+ button.shopping-button:enabled,
+ button.ghost-button:not(.semi-transparent):enabled {
+ background-color: ButtonFace;
+ border-color: ButtonText;
+ color: ButtonText;
+
+ &:hover {
+ background-color: SelectedItemText;
+ border-color: SelectedItem;
+ color: SelectedItem;
+ }
+
+ &:hover:active {
+ border-color: ButtonText;
+ }
+ }
+}
diff --git a/browser/components/shopping/content/shopping-sidebar.js b/browser/components/shopping/content/shopping-sidebar.js
new file mode 100644
index 0000000000..6873682ae6
--- /dev/null
+++ b/browser/components/shopping/content/shopping-sidebar.js
@@ -0,0 +1,80 @@
+/* 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 strict";
+
+// This is loaded into chrome windows with the subscript loader. Wrap in
+// a block to prevent accidentally leaking globals onto `window`.
+{
+ const SHOPPING_SIDEBAR_WIDTH_PREF =
+ "browser.shopping.experience2023.sidebarWidth";
+ class ShoppingSidebar extends MozXULElement {
+ #browser;
+ #initialized;
+
+ static get markup() {
+ return `
+ <browser
+ class="shopping-sidebar"
+ autoscroll="false"
+ disablefullscreen="true"
+ disablehistory="true"
+ flex="1"
+ message="true"
+ manualactiveness="true"
+ remoteType="privilegedabout"
+ maychangeremoteness="true"
+ remote="true"
+ src="about:shoppingsidebar"
+ type="content"
+ />
+ `;
+ }
+
+ constructor() {
+ super();
+ }
+
+ connectedCallback() {
+ this.initialize();
+ }
+
+ initialize() {
+ if (this.#initialized) {
+ return;
+ }
+ this.resizeObserverFn = this.resizeObserverFn.bind(this);
+ this.appendChild(this.constructor.fragment);
+ this.#browser = this.querySelector(".shopping-sidebar");
+
+ let previousWidth = Services.prefs.getIntPref(
+ SHOPPING_SIDEBAR_WIDTH_PREF,
+ 0
+ );
+ if (previousWidth > 0) {
+ this.style.width = `${previousWidth}px`;
+ }
+
+ this.resizeObserver = new ResizeObserver(this.resizeObserverFn);
+ this.resizeObserver.observe(this);
+
+ this.#initialized = true;
+ }
+
+ resizeObserverFn(entries) {
+ for (let entry of entries) {
+ if (entry.contentBoxSize[0].inlineSize < 1) {
+ return;
+ }
+
+ Services.prefs.setIntPref(
+ SHOPPING_SIDEBAR_WIDTH_PREF,
+ entry.contentBoxSize[0].inlineSize
+ );
+ }
+ }
+ }
+
+ customElements.define("shopping-sidebar", ShoppingSidebar);
+}
diff --git a/browser/components/shopping/content/shopping.ftl b/browser/components/shopping/content/shopping.ftl
new file mode 100644
index 0000000000..282c65656b
--- /dev/null
+++ b/browser/components/shopping/content/shopping.ftl
@@ -0,0 +1,7 @@
+# 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/.
+
+### This file is not in a locales directory to prevent it from
+### being translated as the feature is still in heavy development
+### and strings are likely to change often.
diff --git a/browser/components/shopping/content/shopping.html b/browser/components/shopping/content/shopping.html
new file mode 100644
index 0000000000..1c5d627869
--- /dev/null
+++ b/browser/components/shopping/content/shopping.html
@@ -0,0 +1,53 @@
+<!-- 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/. -->
+<!DOCTYPE html>
+<html
+ xmlns="http://www.w3.org/1999/xhtml"
+ role="document"
+ id="shopping"
+ class="system-font-size"
+>
+ <head>
+ <meta charset="utf-8" />
+ <meta
+ http-equiv="Content-Security-Policy"
+ content="default-src resource: chrome:; object-src 'none'; img-src blob: chrome:;"
+ />
+ <meta name="color-scheme" content="light dark" />
+ <link rel="localization" href="branding/brand.ftl" />
+ <link rel="localization" href="toolkit/branding/brandings.ftl" />
+ <link rel="localization" href="toolkit/global/mozFiveStar.ftl" />
+ <link rel="localization" href="toolkit/global/mozSupportLink.ftl" />
+ <link rel="localization" href="toolkit/global/notification.ftl" />
+ <link rel="localization" href="preview/shopping.ftl" />
+ <link rel="localization" href="browser/shopping.ftl" />
+ <link rel="localization" href="toolkit/global/mozMessageBar.ftl" />
+
+ <link rel="stylesheet" href="chrome://global/skin/in-content/common.css" />
+ <link
+ rel="stylesheet"
+ href="chrome://browser/content/shopping/shopping-page.css"
+ />
+
+ <script src="chrome://global/content/elements/message-bar.js"></script>
+ <script
+ type="module"
+ src="chrome://browser/content/shopping/onboarding.mjs"
+ ></script>
+ <script
+ type="module"
+ src="chrome://browser/content/shopping/shopping-container.mjs"
+ ></script>
+ <title data-l10n-id="shopping-page-title"></title>
+ </head>
+ <body>
+ <shopping-container>
+ <div
+ id="multi-stage-message-root"
+ class="onboardingContainer shopping"
+ slot="multi-stage-message-slot"
+ ></div>
+ </shopping-container>
+ </body>
+</html>
diff --git a/browser/components/shopping/content/unanalyzed.css b/browser/components/shopping/content/unanalyzed.css
new file mode 100644
index 0000000000..d2e2b0b4b0
--- /dev/null
+++ b/browser/components/shopping/content/unanalyzed.css
@@ -0,0 +1,41 @@
+/* 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 url("chrome://global/skin/in-content/common.css");
+
+#unanalyzed-product-wrapper {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+#unanalyzed-product-icon {
+ max-width: 264px;
+ max-height: 290px;
+ width: 100%;
+ content: url("chrome://browser/content/shopping/assets/unanalyzedLight.avif");
+
+ @media (prefers-color-scheme: dark) {
+ content: url("chrome://browser/content/shopping/assets/unanalyzedDark.avif");
+ }
+}
+
+#unanalyzed-product-message-content {
+ display: flex;
+ flex-direction: column;
+ line-height: 1.5;
+
+ > h2 {
+ font-size: inherit;
+ }
+
+ > p {
+ margin-block: 0.25rem;
+ }
+}
+
+#unanalyzed-product-analysis-button {
+ width: 100%;
+}
diff --git a/browser/components/shopping/content/unanalyzed.mjs b/browser/components/shopping/content/unanalyzed.mjs
new file mode 100644
index 0000000000..0be85b65e4
--- /dev/null
+++ b/browser/components/shopping/content/unanalyzed.mjs
@@ -0,0 +1,61 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*-
+ * 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 { html } from "chrome://global/content/vendor/lit.all.mjs";
+
+import { MozLitElement } from "chrome://global/content/lit-utils.mjs";
+
+// eslint-disable-next-line import/no-unassigned-import
+import "chrome://browser/content/shopping/shopping-card.mjs";
+
+class UnanalyzedProductCard extends MozLitElement {
+ static properties = {
+ productURL: { type: String, reflect: true },
+ };
+
+ static get queries() {
+ return {
+ analysisButtonEl: "#unanalyzed-product-analysis-button",
+ };
+ }
+
+ onClickAnalysisButton() {
+ this.dispatchEvent(
+ new CustomEvent("NewAnalysisRequested", {
+ bubbles: true,
+ composed: true,
+ })
+ );
+ Glean.shopping.surfaceAnalyzeReviewsNoneAvailableClicked.record();
+ }
+
+ render() {
+ return html`
+ <link
+ rel="stylesheet"
+ href="chrome://browser/content/shopping/unanalyzed.css"
+ />
+ <shopping-card>
+ <div id="unanalyzed-product-wrapper" slot="content">
+ <img id="unanalyzed-product-icon" role="presentation" alt=""></img>
+ <div id="unanalyzed-product-message-content">
+ <h2
+ data-l10n-id="shopping-unanalyzed-product-header-2"
+ ></h2>
+ <p data-l10n-id="shopping-unanalyzed-product-message-2"></p>
+ </div>
+ <button
+ id="unanalyzed-product-analysis-button"
+ class="primary"
+ data-l10n-id="shopping-unanalyzed-product-analyze-button"
+ @click=${this.onClickAnalysisButton}
+ ></button>
+ </div>
+ </shopping-card>
+ `;
+ }
+}
+
+customElements.define("unanalyzed-product-card", UnanalyzedProductCard);
diff --git a/browser/components/shopping/jar.mn b/browser/components/shopping/jar.mn
new file mode 100644
index 0000000000..25fe1b1c0e
--- /dev/null
+++ b/browser/components/shopping/jar.mn
@@ -0,0 +1,31 @@
+# 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/.
+
+browser.jar:
+ content/browser/shopping/onboarding.mjs (content/onboarding.mjs)
+ content/browser/shopping/shopping.html (content/shopping.html)
+ content/browser/shopping/shopping-container.css (content/shopping-container.css)
+ content/browser/shopping/shopping-page.css (content/shopping-page.css)
+ content/browser/shopping/shopping-sidebar.js (content/shopping-sidebar.js)
+ content/browser/shopping/shopping-message-bar.css (content/shopping-message-bar.css)
+ content/browser/shopping/shopping-message-bar.mjs (content/shopping-message-bar.mjs)
+ content/browser/shopping/highlights.mjs (content/highlights.mjs)
+ content/browser/shopping/highlight-item.css (content/highlight-item.css)
+ content/browser/shopping/highlight-item.mjs (content/highlight-item.mjs)
+ content/browser/shopping/shopping-card.css (content/shopping-card.css)
+ content/browser/shopping/shopping-card.mjs (content/shopping-card.mjs)
+ content/browser/shopping/letter-grade.css (content/letter-grade.css)
+ content/browser/shopping/letter-grade.mjs (content/letter-grade.mjs)
+ content/browser/shopping/settings.mjs (content/settings.mjs)
+ content/browser/shopping/settings.css (content/settings.css)
+ content/browser/shopping/shopping-container.mjs (content/shopping-container.mjs)
+ content/browser/shopping/adjusted-rating.mjs (content/adjusted-rating.mjs)
+ content/browser/shopping/reliability.mjs (content/reliability.mjs)
+ content/browser/shopping/analysis-explainer.css (content/analysis-explainer.css)
+ content/browser/shopping/analysis-explainer.mjs (content/analysis-explainer.mjs)
+ content/browser/shopping/unanalyzed.css (content/unanalyzed.css)
+ content/browser/shopping/unanalyzed.mjs (content/unanalyzed.mjs)
+ content/browser/shopping/recommended-ad.css (content/recommended-ad.css)
+ content/browser/shopping/recommended-ad.mjs (content/recommended-ad.mjs)
+ content/browser/shopping/assets/ (content/assets/*)
diff --git a/browser/components/shopping/metrics.yaml b/browser/components/shopping/metrics.yaml
new file mode 100644
index 0000000000..b1869e859a
--- /dev/null
+++ b/browser/components/shopping/metrics.yaml
@@ -0,0 +1,738 @@
+# 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/.
+
+# Adding a new metric? We have docs for that!
+# https://firefox-source-docs.mozilla.org/toolkit/components/glean/user/new_definitions_file.html
+
+---
+$schema: moz://mozilla.org/schemas/glean/metrics/2-0-0
+$tags:
+ - "Firefox :: Shopping"
+
+shopping.settings:
+ nimbus_disabled_shopping:
+ type: boolean
+ lifetime: application
+ description: |
+ Indicates if Nimbus has disabled the use the shopping component.
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1845822
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1845822
+ data_sensitivity:
+ - interaction
+ expires: 134
+ notification_emails:
+ - betling@mozilla.com
+ - fx-desktop-shopping-eng@mozilla.com
+ send_in_pings:
+ - metrics
+ telemetry_mirror: SHOPPING_NIMBUS_DISABLED
+
+ component_opted_out:
+ type: boolean
+ lifetime: application
+ description: |
+ Indicates if the user has opted out of using the shopping component.
+ Set during shopping component init and updated when changed in browser.
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1845822
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1845822
+ data_sensitivity:
+ - interaction
+ expires: 134
+ notification_emails:
+ - betling@mozilla.com
+ - fx-desktop-shopping-eng@mozilla.com
+ send_in_pings:
+ - metrics
+ telemetry_mirror: SHOPPING_COMPONENT_OPTED_OUT
+
+ has_onboarded:
+ type: boolean
+ lifetime: application
+ description: |
+ Indicates if the user has completed the Shopping product Onboarding
+ experience. Set during shopping component init and updated when changed
+ in browser.
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1845822
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1845822
+ data_sensitivity:
+ - interaction
+ expires: 134
+ notification_emails:
+ - betling@mozilla.com
+ - fx-desktop-shopping-eng@mozilla.com
+ send_in_pings:
+ - metrics
+ telemetry_mirror: SHOPPING_HAS_ONBOARDED
+
+ disabled_ads:
+ type: boolean
+ lifetime: application
+ description: |
+ Indicates if the user has manually disabled ads. Set during shopping
+ component init and updated when changed in browser.
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1858540
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1858540
+ data_sensitivity:
+ - interaction
+ expires: 134
+ notification_emails:
+ - betling@mozilla.com
+ - fx-desktop-shopping-eng@mozilla.com
+ send_in_pings:
+ - metrics
+ telemetry_mirror: SHOPPING_DISABLED_ADS
+
+ auto_open_user_disabled:
+ type: boolean
+ lifetime: application
+ description: |
+ Indicates if the user has manually disabled the auto open sidebar feature.
+ Set during shopping component init and updated when changed in browser.
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1879119
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1879119
+ data_sensitivity:
+ - interaction
+ expires: 134
+ notification_emails:
+ - betling@mozilla.com
+ - fx-desktop-shopping-eng@mozilla.com
+ send_in_pings:
+ - metrics
+ telemetry_mirror: SHOPPING_AUTO_OPEN_USER_DISABLED
+
+shopping:
+ surface_displayed:
+ type: event
+ description: |
+ The Shopping product Sidebar was displayed.
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1849236
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1848870#c2
+ data_sensitivity:
+ - interaction
+ expires: 134
+ notification_emails:
+ - betling@mozilla.com
+ - fx-desktop-shopping-eng@mozilla.com
+ send_in_pings:
+ - events
+ extra_keys:
+ side_bar_state:
+ type: string
+ description: |
+ Which of the possible configurations of the sidebar was displayed.
+
+ surface_reanalyze_clicked:
+ type: event
+ description: |
+ The user clicked to REanalyze reviews in the shopping side bar. This
+ metric does not contain any information about the product the user is
+ viewing or any displayed trusted deals.
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1848870
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1848870
+ data_sensitivity:
+ - interaction
+ expires: 134
+ send_in_pings:
+ - events
+ notification_emails:
+ - betling@mozilla.com
+ - fx-desktop-shopping-eng@mozilla.com
+
+ surface_show_quality_explainer_clicked:
+ type: event
+ description: |
+ The user clicked to see the explanation of Review Quality in the
+ shopping component.
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1849382
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1849382
+ data_sensitivity:
+ - interaction
+ expires: 134
+ notification_emails:
+ - betling@mozilla.com
+ - fx-desktop-shopping-eng@mozilla.com
+ send_in_pings:
+ - events
+ extra_keys:
+ action:
+ description: >
+ Whether the button was used to expand or collapse the quality
+ explainer card.
+ Possible values are `expanded` and `collapsed`.
+ type: string
+
+ surface_settings_expand_clicked:
+ type: event
+ description: |
+ The user opened the settings menu of the shopping component.
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1849382
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1849382
+ data_sensitivity:
+ - interaction
+ expires: 134
+ notification_emails:
+ - betling@mozilla.com
+ - fx-desktop-shopping-eng@mozilla.com
+ send_in_pings:
+ - events
+ extra_keys:
+ action:
+ description: >
+ Whether the button was used to expand or collapse the settings card.
+ Possible values are `expanded` and `collapsed`.
+ type: string
+
+ surface_closed:
+ type: event
+ description: |
+ The user opened the settings menu of the shopping component.
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1849240
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1841892
+ data_sensitivity:
+ - interaction
+ expires: 134
+ notification_emails:
+ - betling@mozilla.com
+ - fx-desktop-shopping-eng@mozilla.com
+ send_in_pings:
+ - events
+ extra_keys:
+ source:
+ description: >
+ The source of the close event. For example, whether the shopping
+ sidebar was closed with the close button or the icon in the
+ address bar.
+ type: string
+
+ address_bar_icon_clicked:
+ type: event
+ description: |
+ The Shopping product Address Bar Icon was clicked by the user.
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1849239
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1841892
+ data_sensitivity:
+ - interaction
+ expires: 134
+ notification_emails:
+ - betling@mozilla.com
+ - fx-desktop-shopping-eng@mozilla.com
+ send_in_pings:
+ - events
+ extra_keys:
+ action:
+ description: >
+ Whether the icon was used to open or close the Shopping sidebar.
+ type: string
+
+ surface_show_more_reviews_button_clicked:
+ type: event
+ description: |
+ The user clicked to expand the recent reviews to see more.
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1849241
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1849241
+ data_sensitivity:
+ - interaction
+ expires: 134
+ notification_emails:
+ - betling@mozilla.com
+ send_in_pings:
+ - events
+ extra_keys:
+ action:
+ description: >
+ Whether the button was used to expand or collapse the more reviews
+ card.
+ type: string
+
+ surface_show_terms_clicked:
+ type: event
+ description: |
+ The user clicked to view the Terms of Service.
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1849899
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1841892
+ data_sensitivity:
+ - interaction
+ expires: 134
+ notification_emails:
+ - betling@mozilla.com
+ - fx-desktop-shopping-eng@mozilla.com
+ send_in_pings:
+ - events
+
+ surface_show_privacy_policy_clicked:
+ type: event
+ description: |
+ The user clicked to view the Privacy Policy.
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1849899
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1841892
+ data_sensitivity:
+ - interaction
+ expires: 134
+ notification_emails:
+ - betling@mozilla.com
+ - fx-desktop-shopping-eng@mozilla.com
+ send_in_pings:
+ - events
+
+ surface_not_now_clicked:
+ type: event
+ description: |
+ The user clicked 'Not Now' to dismiss the dialog.
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1849899
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1841892
+ data_sensitivity:
+ - interaction
+ expires: 134
+ notification_emails:
+ - betling@mozilla.com
+ - fx-desktop-shopping-eng@mozilla.com
+ send_in_pings:
+ - events
+
+ surface_opt_in_clicked:
+ type: event
+ description: |
+ The user clicked the "Yes, try it" element to use the Shopping product's
+ functionality.
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1849899
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1841892
+ data_sensitivity:
+ - interaction
+ expires: 134
+ notification_emails:
+ - betling@mozilla.com
+ - fx-desktop-shopping-eng@mozilla.com
+ send_in_pings:
+ - events
+
+ surface_onboarding_displayed:
+ type: event
+ description: |
+ The Shopping Side bar displayed the onboarding experience.
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1849899
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1841892
+ data_sensitivity:
+ - interaction
+ expires: 134
+ notification_emails:
+ - betling@mozilla.com
+ - fx-desktop-shopping-eng@mozilla.com
+ send_in_pings:
+ - events
+ extra_keys:
+ configuration:
+ description: >
+ Which version of the onboarding experience the user was shown.
+ type: string
+
+ surface_no_review_reliability_available:
+ type: event
+ description: |
+ Review reliability was not available for display in the shopping side
+ bar. This metric does not contain any information about the product
+ the user is viewing.
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1849243
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1841892#c6
+ data_sensitivity:
+ - interaction
+ expires: 134
+ notification_emails:
+ - betling@mozilla.com
+ - fx-desktop-shopping-eng@mozilla.com
+ send_in_pings:
+ - events
+
+ surface_analyze_reviews_none_available_clicked:
+ type: event
+ description: |
+ The user clicked to analyze reviews in the case the reliability rating
+ was not available for display in the shopping side bar. This metric
+ does not contain any information about the product the user is viewing.
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1849244
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1841892
+ data_sensitivity:
+ - interaction
+ expires: 134
+ notification_emails:
+ - betling@mozilla.com
+ - fx-desktop-shopping-eng@mozilla.com
+ send_in_pings:
+ - events
+
+ surface_learn_more_clicked:
+ type: event
+ description: |
+ The user clicked the 'Learn More' link in the Shopping onboarding
+ experience.
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1851820
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1851820#c2
+ data_sensitivity:
+ - interaction
+ expires: 134
+ notification_emails:
+ - betling@mozilla.com
+ - fx-desktop-shopping-eng@mozilla.com
+ send_in_pings:
+ - events
+
+ surface_show_quality_explainer_url_clicked:
+ type: event
+ description: |
+ The user clicked to see the explanation of Review Quality in the
+ shopping component.
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1849382
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1848870
+ data_sensitivity:
+ - interaction
+ expires: 134
+ notification_emails:
+ - betling@mozilla.com
+ - fx-desktop-shopping-eng@mozilla.com
+ send_in_pings:
+ - events
+
+ address_bar_icon_displayed:
+ type: event
+ description: |
+ The Shopping product Address Bar Icon was displayed to the user.
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1851036
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1841892
+ data_sensitivity:
+ - interaction
+ expires: 134
+ notification_emails:
+ - betling@mozilla.com
+ - fx-desktop-shopping-eng@mozilla.com
+ send_in_pings:
+ - events
+
+ surface_reactivated_button_clicked:
+ type: event
+ description: |
+ The user clicked the reactivated product button.
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1851675
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1851675#c4
+ data_sensitivity:
+ - interaction
+ expires: 134
+ notification_emails:
+ - betling@mozilla.com
+ - fx-desktop-shopping-eng@mozilla.com
+ send_in_pings:
+ - events
+
+ surface_stale_analysis_shown:
+ type: event
+ description: |
+ The user was shown the dialogue box indicating that analysis of a product
+ was stale. No information about the product is included.
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1854223
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1854223
+ data_sensitivity:
+ - interaction
+ expires: 134
+ notification_emails:
+ - betling@mozilla.com
+ - fx-desktop-shopping-eng@mozilla.com
+ send_in_pings:
+ - events
+
+ product_page_visits:
+ type: counter
+ description: |
+ Counts number of visits to a supported retailer product page
+ while enrolled in either the control or treatment branches
+ of the shopping experiment.
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1848160
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1848160
+ data_sensitivity:
+ - interaction
+ expires: 134
+ notification_emails:
+ - betling@mozilla.com
+ - fx-desktop-shopping-eng@mozilla.com
+ send_in_pings:
+ - metrics
+ telemetry_mirror: SHOPPING_PRODUCT_PAGE_VISITS
+
+ surface_powered_by_fakespot_link_clicked:
+ type: event
+ description: |
+ The user clicked the "Fakespot by Mozilla" link in the shopping side
+ bar.
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1853785
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1853785
+ data_sensitivity:
+ - interaction
+ expires: 134
+ notification_emails:
+ - betling@mozilla.com
+ - fx-desktop-shopping-eng@mozilla.com
+ send_in_pings:
+ - events
+
+ address_bar_feature_callout_displayed:
+ type: event
+ description: |
+ The user was shown the feature callout for the Shopping component.
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1854376
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1854376
+ data_sensitivity:
+ - interaction
+ expires: 134
+ notification_emails:
+ - betling@mozilla.com
+ - fx-desktop-shopping-eng@mozilla.com
+ send_in_pings:
+ - events
+ extra_keys:
+ configuration:
+ description: >
+ Message id for the version of the feature callout shown.
+ type: string
+
+ surface_ads_clicked:
+ type: event
+ description: |
+ An ad shown in the sidebar was clicked.
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1855812
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1855812
+ data_sensitivity:
+ - interaction
+ expires: 134
+ notification_emails:
+ - betling@mozilla.com
+ - fx-desktop-shopping-eng@mozilla.com
+ send_in_pings:
+ - events
+
+ surface_ads_impression:
+ type: event
+ description: |
+ An ad was shown and visible in the sidebar for 1.5 seconds.
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1855810
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1855810
+ data_sensitivity:
+ - interaction
+ expires: 134
+ notification_emails:
+ - betling@mozilla.com
+ - fx-desktop-shopping-eng@mozilla.com
+ send_in_pings:
+ - events
+
+ surface_ads_placement:
+ type: event
+ description: |
+ An ad unit was fetched successfully.
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1872872
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1872872
+ data_sensitivity:
+ - interaction
+ expires: 134
+ notification_emails:
+ - betling@mozilla.com
+ - fx-desktop-shopping-eng@mozilla.com
+ send_in_pings:
+ - events
+
+ surface_no_ads_available:
+ type: event
+ description: |
+ On a supported product page, the review checker showed analysis, and
+ review checker ads were enabled, but when we tried to fetch an ad from
+ the ad server, no ad was available.
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1855811
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1855811
+ data_sensitivity:
+ - interaction
+ expires: 134
+ notification_emails:
+ - betling@mozilla.com
+ - fx-desktop-shopping-eng@mozilla.com
+ send_in_pings:
+ - events
+
+ ads_exposure:
+ type: event
+ description: |
+ On a supported product page, the review checker showed analysis, and
+ the ads exposure pref was enabled, or review checker ads were enabled,
+ and when we tried to fetch an ad from the ad server, an ad was available.
+ Does not indicate whether the ad was actually shown.
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1858470
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1858470
+ data_sensitivity:
+ - interaction
+ expires: 134
+ notification_emails:
+ - betling@mozilla.com
+ - fx-desktop-shopping-eng@mozilla.com
+ send_in_pings:
+ - events
+
+ surface_ads_setting_toggled:
+ type: event
+ description: |
+ The user clicked the settings toggle to enable or disable ads in the
+ sidebar settings component.
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1858540
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1858540
+ data_sensitivity:
+ - interaction
+ expires: 134
+ notification_emails:
+ - betling@mozilla.com
+ - fx-desktop-shopping-eng@mozilla.com
+ send_in_pings:
+ - events
+ extra_keys:
+ action:
+ description: >
+ Whether the toggle was used to enable or disable ads. Possible values
+ are `enabled` and `disabled`.
+ type: string
+
+ surface_opt_out_button_clicked:
+ type: event
+ description: |
+ The user clicked the button in the settings panel to turn off the shopping experience.
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1869413
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1869413
+ data_sensitivity:
+ - interaction
+ expires: 134
+ notification_emails:
+ - betling@mozilla.com
+ - fx-desktop-shopping-eng@mozilla.com
+ send_in_pings:
+ - events
+
+ surface_auto_open_setting_toggled:
+ type: event
+ description: |
+ The user clicked the settings toggle to enable or disable auto-open in the
+ sidebar settings component.
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1879125
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1879125
+ data_sensitivity:
+ - interaction
+ expires: 134
+ notification_emails:
+ - betling@mozilla.com
+ - fx-desktop-shopping-eng@mozilla.com
+ send_in_pings:
+ - events
+ extra_keys:
+ action:
+ description: >
+ Whether the toggle was used to enable or disable auto-open. Possible values
+ are `enabled` and `disabled`.
+ type: string
+
+ surface_no_thanks_button_clicked:
+ type: event
+ description: |
+ The user clicks the 'No thanks' button when asked if they want to
+ disable auto-open behavior.
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1879127
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1879127
+ data_sensitivity:
+ - interaction
+ expires: 134
+ notification_emails:
+ - betling@mozilla.com
+ - fx-desktop-shopping-eng@mozilla.com
+ send_in_pings:
+ - events
+
+ surface_yes_keep_closed_button_clicked:
+ type: event
+ description: |
+ The user clicks the 'Yes, keep closed' button when asked if they want to
+ disable auto-open behavior.
+ bugs:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1879127
+ data_reviews:
+ - https://bugzilla.mozilla.org/show_bug.cgi?id=1879127
+ data_sensitivity:
+ - interaction
+ expires: 134
+ notification_emails:
+ - betling@mozilla.com
+ - fx-desktop-shopping-eng@mozilla.com
+ send_in_pings:
+ - events
diff --git a/browser/components/shopping/moz.build b/browser/components/shopping/moz.build
new file mode 100644
index 0000000000..1db819a631
--- /dev/null
+++ b/browser/components/shopping/moz.build
@@ -0,0 +1,21 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+JAR_MANIFESTS += ["jar.mn"]
+
+FINAL_TARGET_FILES.actors += [
+ "ShoppingSidebarChild.sys.mjs",
+ "ShoppingSidebarParent.sys.mjs",
+]
+
+EXTRA_JS_MODULES += [
+ "ShoppingUtils.sys.mjs",
+]
+
+BROWSER_CHROME_MANIFESTS += ["tests/browser/browser.toml"]
+
+with Files("**"):
+ BUG_COMPONENT = ("Firefox", "Shopping")
diff --git a/browser/components/shopping/tests/browser/browser.toml b/browser/components/shopping/tests/browser/browser.toml
new file mode 100644
index 0000000000..d93abec789
--- /dev/null
+++ b/browser/components/shopping/tests/browser/browser.toml
@@ -0,0 +1,79 @@
+[DEFAULT]
+support-files = [
+ "head.js",
+ "!/toolkit/components/shopping/test/mockapis/server_helper.js",
+ "!/toolkit/components/shopping/test/mockapis/analysis_status.sjs",
+ "!/toolkit/components/shopping/test/mockapis/analysis.sjs",
+ "!/toolkit/components/shopping/test/mockapis/analyze.sjs",
+ "!/toolkit/components/shopping/test/mockapis/attribution.sjs",
+ "!/toolkit/components/shopping/test/mockapis/recommendations.sjs",
+ "!/toolkit/components/shopping/test/mockapis/reporting.sjs",
+]
+
+prefs = [
+ "browser.shopping.experience2023.enabled=true",
+ "browser.shopping.experience2023.optedIn=1",
+ "browser.shopping.experience2023.ads.enabled=true",
+ "browser.shopping.experience2023.ads.userEnabled=true",
+ "browser.shopping.experience2023.autoOpen.enabled=false",
+ "browser.shopping.experience2023.autoOpen.userEnabled=true",
+ "toolkit.shopping.environment=test",
+ "toolkit.shopping.ohttpRelayURL=https://example.com/relay", # These URLs don't actually host a relay or gateway config, but are needed to stop us making outside network connections.
+ "toolkit.shopping.ohttpConfigURL=https://example.com/ohttp-config",
+ "browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features=false", # Disable the fakespot feature callouts to avoid interference. Individual tests that need them can re-enable them as needed.
+]
+
+["browser_adjusted_rating.js"]
+
+["browser_ads_exposure_telemetry.js"]
+
+["browser_analysis_explainer.js"]
+
+["browser_auto_open.js"]
+
+["browser_exposure_telemetry.js"]
+
+["browser_inprogress_analysis.js"]
+
+["browser_keep_close_message_bar.js"]
+
+["browser_network_offline.js"]
+
+["browser_not_enough_reviews.js"]
+
+["browser_page_not_supported.js"]
+
+["browser_private_mode.js"]
+
+["browser_recommended_ad_test.js"]
+
+["browser_review_highlights.js"]
+
+["browser_settings_telemetry.js"]
+
+["browser_shopping_card.js"]
+
+["browser_shopping_container.js"]
+
+["browser_shopping_message_triggers.js"]
+
+["browser_shopping_onboarding.js"]
+
+["browser_shopping_settings.js"]
+
+["browser_shopping_sidebar.js"]
+
+["browser_shopping_survey.js"]
+
+["browser_shopping_urlbar.js"]
+
+["browser_stale_product.js"]
+
+["browser_ui_telemetry.js"]
+skip-if = [
+ "os == 'linux' && os_version == '18.04'"
+]
+
+["browser_unanalyzed_product.js"]
+
+["browser_unavailable_product.js"]
diff --git a/browser/components/shopping/tests/browser/browser_adjusted_rating.js b/browser/components/shopping/tests/browser/browser_adjusted_rating.js
new file mode 100644
index 0000000000..b0d2da41d5
--- /dev/null
+++ b/browser/components/shopping/tests/browser/browser_adjusted_rating.js
@@ -0,0 +1,115 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function test_adjusted_rating() {
+ await BrowserTestUtils.withNewTab(
+ {
+ url: "about:shoppingsidebar",
+ gBrowser,
+ },
+ async browser => {
+ await SpecialPowers.spawn(
+ browser,
+ [MOCK_ANALYZED_PRODUCT_RESPONSE],
+ async mockData => {
+ let rating = mockData.adjusted_rating;
+
+ let shoppingContainer =
+ content.document.querySelector(
+ "shopping-container"
+ ).wrappedJSObject;
+ shoppingContainer.data = Cu.cloneInto(mockData, content);
+ await shoppingContainer.updateComplete;
+
+ let adjustedRating = shoppingContainer.adjustedRatingEl;
+ await adjustedRating.updateComplete;
+
+ let mozFiveStar = adjustedRating.ratingEl;
+ ok(mozFiveStar, "The moz-five-star element exists");
+
+ is(
+ mozFiveStar.rating,
+ rating,
+ `The moz-five-star rating is ${rating}`
+ );
+ is(
+ adjustedRating.rating,
+ rating,
+ `The adjusted rating "rating" is ${rating}`
+ );
+
+ rating = 2.55;
+ adjustedRating.rating = rating;
+
+ await adjustedRating.updateComplete;
+
+ is(
+ mozFiveStar.rating,
+ rating,
+ `The moz-five-star rating is now ${rating}`
+ );
+ is(
+ adjustedRating.rating,
+ rating,
+ `The adjusted rating "rating" is now ${rating}`
+ );
+
+ rating = 0;
+ adjustedRating.rating = rating;
+
+ await adjustedRating.updateComplete;
+
+ is(
+ adjustedRating.rating,
+ rating,
+ `The adjusted rating "rating" is now ${rating}`
+ );
+
+ is(
+ mozFiveStar.rating,
+ 0.5,
+ `When the rating is 0, the star rating displays 0.5 stars.`
+ );
+
+ rating = null;
+ adjustedRating.rating = rating;
+
+ await adjustedRating.updateComplete;
+
+ is(
+ adjustedRating.rating,
+ rating,
+ `The adjusted rating "rating" is now ${rating}`
+ );
+
+ ok(
+ ContentTaskUtils.isHidden(adjustedRating),
+ "adjusted rating should not be visible"
+ );
+
+ rating = 3;
+ adjustedRating.rating = rating;
+
+ await adjustedRating.updateComplete;
+ mozFiveStar = adjustedRating.ratingEl;
+ ok(
+ ContentTaskUtils.isVisible(adjustedRating),
+ "adjusted rating should be visible"
+ );
+ is(
+ mozFiveStar.rating,
+ rating,
+ `The moz-five-star rating is now ${rating}`
+ );
+ is(
+ adjustedRating.rating,
+ rating,
+ `The adjusted rating "rating" is now ${rating}`
+ );
+ }
+ );
+ }
+ );
+});
diff --git a/browser/components/shopping/tests/browser/browser_ads_exposure_telemetry.js b/browser/components/shopping/tests/browser/browser_ads_exposure_telemetry.js
new file mode 100644
index 0000000000..5358667716
--- /dev/null
+++ b/browser/components/shopping/tests/browser/browser_ads_exposure_telemetry.js
@@ -0,0 +1,213 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const PRODUCT_PAGE = "https://example.com/product/B09TJGHL5F";
+
+const ADS_JSON = `[{
+ "name": "Test product name ftw",
+ "url": ${PRODUCT_PAGE},
+ "image_url": "https://i.fakespot.io/b6vx27xf3rgwr1a597q6qd3rutp6",
+ "price": "249.99",
+ "currency": "USD",
+ "grade": "A",
+ "adjusted_rating": 4.6,
+ "analysis_url": "https://www.fakespot.com/product/test-product",
+ "sponsored": true,
+ "aid": "a2VlcCBvbiByb2NraW4gdGhlIGZyZWUgd2ViIQ==",
+}]`;
+
+// Verifies that, if the ads server returns an ad, but we have disabled
+// ads exposure, no Glean telemetry is recorded.
+add_task(async function test_ads_exposure_disabled_not_recorded() {
+ await Services.fog.testFlushAllChildren();
+ Services.fog.testResetFOG();
+
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["browser.shopping.experience2023.ads.enabled", false],
+ ["browser.shopping.experience2023.ads.exposure", false],
+ ],
+ });
+
+ await BrowserTestUtils.withNewTab(
+ {
+ url: "about:shoppingsidebar",
+ gBrowser,
+ },
+ async browser => {
+ await SpecialPowers.spawn(
+ browser,
+ [PRODUCT_PAGE, ADS_JSON],
+ async (prodPage, adResponse) => {
+ const { ShoppingProduct } = ChromeUtils.importESModule(
+ "chrome://global/content/shopping/ShoppingProduct.mjs"
+ );
+ const { sinon } = ChromeUtils.importESModule(
+ "resource://testing-common/Sinon.sys.mjs"
+ );
+
+ let productURI = Services.io.newURI(prodPage);
+ let product = new ShoppingProduct(productURI);
+ let productRequestAdsStub = sinon.stub(
+ product,
+ "requestRecommendations"
+ );
+ productRequestAdsStub.resolves(adResponse);
+
+ let actor = content.windowGlobalChild.getActor("ShoppingSidebar");
+ actor.productURI = productURI;
+ actor.product = product;
+
+ actor.requestRecommendations(productURI);
+
+ Assert.ok(
+ productRequestAdsStub.notCalled,
+ "product.requestRecommendations should not have been called if ads and ads exposure were disabled"
+ );
+ }
+ );
+ }
+ );
+
+ await Services.fog.testFlushAllChildren();
+
+ var events = Glean.shopping.adsExposure.testGetValue();
+ Assert.equal(events, null, "Ads exposure should not have been recorded");
+ await SpecialPowers.popPrefEnv();
+});
+
+// Verifies that, if the ads server returns nothing, and ads exposure is
+// enabled, no Glean telemetry is recorded.
+add_task(async function test_ads_exposure_enabled_no_ad_not_recorded() {
+ await Services.fog.testFlushAllChildren();
+ Services.fog.testResetFOG();
+
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["browser.shopping.experience2023.ads.enabled", true],
+ ["browser.shopping.experience2023.ads.exposure", true],
+ ],
+ });
+
+ await BrowserTestUtils.withNewTab(
+ {
+ url: "about:shoppingsidebar",
+ gBrowser,
+ },
+ async browser => {
+ await SpecialPowers.spawn(browser, [PRODUCT_PAGE], async prodPage => {
+ const { ShoppingProduct } = ChromeUtils.importESModule(
+ "chrome://global/content/shopping/ShoppingProduct.mjs"
+ );
+ const { sinon } = ChromeUtils.importESModule(
+ "resource://testing-common/Sinon.sys.mjs"
+ );
+
+ let productURI = Services.io.newURI(prodPage);
+ let product = new ShoppingProduct(productURI);
+ let productRequestAdsStub = sinon.stub(
+ product,
+ "requestRecommendations"
+ );
+ productRequestAdsStub.resolves([]);
+
+ let actor = content.windowGlobalChild.getActor("ShoppingSidebar");
+ actor.productURI = productURI;
+ actor.product = product;
+
+ actor.requestRecommendations(productURI);
+
+ Assert.ok(
+ productRequestAdsStub.called,
+ "product.requestRecommendations should have been called"
+ );
+ });
+ }
+ );
+
+ await Services.fog.testFlushAllChildren();
+
+ var events = Glean.shopping.adsExposure.testGetValue();
+ Assert.equal(
+ events,
+ null,
+ "Ads exposure should not have been recorded if ads exposure was enabled but no ads were returned"
+ );
+ await SpecialPowers.popPrefEnv();
+});
+
+// Verifies that, if ads are disabled but ads exposure is enabled, ads will
+// be fetched, and if an ad is returned, the Glean probe will be recorded.
+add_task(async function test_ads_exposure_enabled_with_ad_recorded() {
+ await Services.fog.testFlushAllChildren();
+ Services.fog.testResetFOG();
+
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["browser.shopping.experience2023.ads.enabled", false],
+ ["browser.shopping.experience2023.ads.exposure", true],
+ ],
+ });
+
+ await BrowserTestUtils.withNewTab(
+ {
+ url: "about:shoppingsidebar",
+ gBrowser,
+ },
+ async browser => {
+ await SpecialPowers.spawn(
+ browser,
+ [PRODUCT_PAGE, ADS_JSON],
+ async (prodPage, adResponse) => {
+ const { ShoppingProduct } = ChromeUtils.importESModule(
+ "chrome://global/content/shopping/ShoppingProduct.mjs"
+ );
+ const { sinon } = ChromeUtils.importESModule(
+ "resource://testing-common/Sinon.sys.mjs"
+ );
+
+ let productURI = Services.io.newURI(prodPage);
+ let product = new ShoppingProduct(productURI);
+ let productRequestAdsStub = sinon.stub(
+ product,
+ "requestRecommendations"
+ );
+ productRequestAdsStub.resolves(adResponse);
+
+ let actor = content.windowGlobalChild.getActor("ShoppingSidebar");
+ actor.productURI = productURI;
+ actor.product = product;
+
+ actor.requestRecommendations(productURI);
+
+ Assert.ok(
+ productRequestAdsStub.called,
+ "product.requestRecommendations should have been called if ads exposure is enabled, even if ads are not"
+ );
+ }
+ );
+ }
+ );
+
+ await Services.fog.testFlushAllChildren();
+
+ const events = Glean.shopping.adsExposure.testGetValue();
+ Assert.equal(
+ events.length,
+ 1,
+ "Ads exposure should have been recorded if ads exposure was enabled and ads were returned"
+ );
+ Assert.equal(
+ events[0].category,
+ "shopping",
+ "Glean event should have category 'shopping'"
+ );
+ Assert.equal(
+ events[0].name,
+ "ads_exposure",
+ "Glean event should have name 'ads_exposure'"
+ );
+ await SpecialPowers.popPrefEnv();
+});
diff --git a/browser/components/shopping/tests/browser/browser_analysis_explainer.js b/browser/components/shopping/tests/browser/browser_analysis_explainer.js
new file mode 100644
index 0000000000..cb73a80709
--- /dev/null
+++ b/browser/components/shopping/tests/browser/browser_analysis_explainer.js
@@ -0,0 +1,43 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests that the analysis explainer SUMO link is rendered with the expected
+ * UTM parameters.
+ */
+add_task(async function test_analysis_explainer_sumo_link_utm() {
+ await BrowserTestUtils.withNewTab(
+ {
+ url: "about:shoppingsidebar",
+ gBrowser,
+ },
+ async browser => {
+ await SpecialPowers.spawn(
+ browser,
+ [MOCK_ANALYZED_PRODUCT_RESPONSE],
+ async mockData => {
+ let shoppingContainer =
+ content.document.querySelector(
+ "shopping-container"
+ ).wrappedJSObject;
+ shoppingContainer.data = Cu.cloneInto(mockData, content);
+ await shoppingContainer.updateComplete;
+
+ let card =
+ shoppingContainer.analysisExplainerEl.shadowRoot.querySelector(
+ "shopping-card"
+ );
+
+ let href = card.querySelector("a").href;
+ let qs = new URL(href).searchParams;
+ is(qs.get("as"), "u");
+ is(qs.get("utm_source"), "inproduct");
+ is(qs.get("utm_campaign"), "learn-more");
+ is(qs.get("utm_term"), "core-sidebar");
+ }
+ );
+ }
+ );
+});
diff --git a/browser/components/shopping/tests/browser/browser_auto_open.js b/browser/components/shopping/tests/browser/browser_auto_open.js
new file mode 100644
index 0000000000..69c37316c5
--- /dev/null
+++ b/browser/components/shopping/tests/browser/browser_auto_open.js
@@ -0,0 +1,90 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ ShoppingUtils: "resource:///modules/ShoppingUtils.sys.mjs",
+});
+
+const ACTIVE_PREF = "browser.shopping.experience2023.active";
+const AUTO_OPEN_ENABLED_PREF =
+ "browser.shopping.experience2023.autoOpen.enabled";
+const AUTO_OPEN_USER_ENABLED_PREF =
+ "browser.shopping.experience2023.autoOpen.userEnabled";
+const PRODUCT_PAGE = "https://example.com/product/B09TJGHL5F";
+const productURI = Services.io.newURI(PRODUCT_PAGE);
+
+async function trigger_auto_open_flow(expectedActivePrefValue) {
+ // Set the active pref to false, which triggers ShoppingUtils.onActiveUpdate.
+ Services.prefs.setBoolPref(ACTIVE_PREF, false);
+
+ // Call onLocationChange with a product URL, triggering auto-open to flip the
+ // active pref back to true, if the auto-open conditions are satisfied.
+ ShoppingUtils.onLocationChange(productURI, 0);
+
+ // Wait a turn for the change to propagate...
+ await TestUtils.waitForTick();
+
+ // Finally, assert the active pref has the expected state.
+ Assert.equal(
+ expectedActivePrefValue,
+ Services.prefs.getBoolPref(ACTIVE_PREF, false)
+ );
+}
+
+add_task(async function test_auto_open() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["browser.shopping.experience2023.optedIn", 1],
+ ["browser.shopping.experience2023.autoOpen.enabled", true],
+ ["browser.shopping.experience2023.autoOpen.userEnabled", true],
+ ],
+ });
+
+ await trigger_auto_open_flow(true);
+
+ await SpecialPowers.popPrefEnv();
+});
+
+add_task(async function test_auto_open_disabled() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["browser.shopping.experience2023.optedIn", 1],
+ ["browser.shopping.experience2023.autoOpen.enabled", false],
+ ["browser.shopping.experience2023.autoOpen.userEnabled", true],
+ ],
+ });
+
+ await trigger_auto_open_flow(false);
+
+ await SpecialPowers.popPrefEnv();
+});
+
+add_task(async function test_auto_open_user_disabled() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["browser.shopping.experience2023.optedIn", 1],
+ ["browser.shopping.experience2023.autoOpen.enabled", true],
+ ["browser.shopping.experience2023.autoOpen.userEnabled", false],
+ ],
+ });
+
+ await trigger_auto_open_flow(false);
+
+ await SpecialPowers.popPrefEnv();
+});
+
+add_task(async function test_auto_open_not_opted_in() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["browser.shopping.experience2023.optedIn", 0],
+ ["browser.shopping.experience2023.autoOpen.enabled", true],
+ ["browser.shopping.experience2023.autoOpen.userEnabled", true],
+ ],
+ });
+
+ await trigger_auto_open_flow(false);
+
+ await SpecialPowers.popPrefEnv();
+});
diff --git a/browser/components/shopping/tests/browser/browser_exposure_telemetry.js b/browser/components/shopping/tests/browser/browser_exposure_telemetry.js
new file mode 100644
index 0000000000..51334ce722
--- /dev/null
+++ b/browser/components/shopping/tests/browser/browser_exposure_telemetry.js
@@ -0,0 +1,123 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ ShoppingUtils: "resource:///modules/ShoppingUtils.sys.mjs",
+});
+
+// Tests in this file simulate exposure detection without actually loading the
+// product pages. Instead, we call the `ShoppingUtils.maybeRecordExposure`
+// method, passing in flags and URLs to simulate onLocationChange events.
+// Bug 1853401 captures followup work to add integration tests.
+
+const PRODUCT_PAGE = Services.io.newURI(
+ "https://example.com/product/B09TJGHL5F"
+);
+const WALMART_PAGE = Services.io.newURI(
+ "https://www.walmart.com/ip/Utz-Cheese-Balls-23-Oz/15543964"
+);
+const WALMART_OTHER_PAGE = Services.io.newURI(
+ "https://www.walmart.com/ip/Utz-Gluten-Free-Cheese-Balls-23-0-OZ/10898644"
+);
+
+async function setup(pref) {
+ await SpecialPowers.pushPrefEnv({
+ set: [[`browser.shopping.experience2023.${pref}`, true]],
+ });
+ await Services.fog.testFlushAllChildren();
+ Services.fog.testResetFOG();
+}
+
+async function teardown(pref) {
+ await SpecialPowers.popPrefEnv();
+ await Services.fog.testFlushAllChildren();
+ Services.fog.testResetFOG();
+
+ // Clear out the normally short-lived pushState navigation cache in between
+ // runs, to avoid accidentally deduping when we shouldn't.
+ ShoppingUtils.lastWalmartURI = null;
+}
+
+async function runTest({ aLocationURI, aFlags, expected }) {
+ async function _run() {
+ Assert.equal(undefined, Glean.shopping.productPageVisits.testGetValue());
+ ShoppingUtils.onLocationChange(aLocationURI, aFlags);
+ await Services.fog.testFlushAllChildren();
+ Assert.equal(expected, Glean.shopping.productPageVisits.testGetValue());
+ }
+
+ await setup("enabled");
+ await _run();
+ await teardown("enabled");
+
+ await setup("control");
+ await _run();
+ await teardown("control");
+}
+
+add_task(async function test_shopping_exposure_new_page() {
+ await runTest({
+ aLocationURI: PRODUCT_PAGE,
+ aFlags: 0,
+ expected: 1,
+ });
+});
+
+add_task(async function test_shopping_exposure_reload_page() {
+ await runTest({
+ aLocationURI: PRODUCT_PAGE,
+ aFlags: Ci.nsIWebProgressListener.LOCATION_CHANGE_RELOAD,
+ expected: 1,
+ });
+});
+
+add_task(async function test_shopping_exposure_session_restore_page() {
+ await runTest({
+ aLocationURI: PRODUCT_PAGE,
+ aFlags: Ci.nsIWebProgressListener.LOCATION_CHANGE_SESSION_STORE,
+ expected: 1,
+ });
+});
+
+add_task(async function test_shopping_exposure_ignore_same_page() {
+ await runTest({
+ aLocationURI: PRODUCT_PAGE,
+ aFlags: Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT,
+ expected: undefined,
+ });
+});
+
+add_task(async function test_shopping_exposure_count_same_page_pushstate() {
+ await runTest({
+ aLocationURI: WALMART_PAGE,
+ aFlags: Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT,
+ expected: 1,
+ });
+});
+
+add_task(async function test_shopping_exposure_ignore_pushstate_repeats() {
+ async function _run() {
+ let aFlags = Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT;
+ Assert.equal(undefined, Glean.shopping.productPageVisits.testGetValue());
+
+ // Slightly different setup here: simulate deduping by setting the first
+ // walmart page's URL as the `ShoppingUtils.lastWalmartURI`, then fire the
+ // pushState for the first page, then twice for a second page. This seems
+ // to be roughly the observed behavior when navigating between walmart
+ // product pages.
+ ShoppingUtils.lastWalmartURI = WALMART_PAGE;
+ ShoppingUtils.onLocationChange(WALMART_PAGE, aFlags);
+ ShoppingUtils.onLocationChange(WALMART_OTHER_PAGE, aFlags);
+ ShoppingUtils.onLocationChange(WALMART_OTHER_PAGE, aFlags);
+ await Services.fog.testFlushAllChildren();
+ Assert.equal(1, Glean.shopping.productPageVisits.testGetValue());
+ }
+ await setup("enabled");
+ await _run();
+ await teardown("enabled");
+ await setup("control");
+ await _run();
+ await teardown("control");
+});
diff --git a/browser/components/shopping/tests/browser/browser_inprogress_analysis.js b/browser/components/shopping/tests/browser/browser_inprogress_analysis.js
new file mode 100644
index 0000000000..d2d1ddeb8c
--- /dev/null
+++ b/browser/components/shopping/tests/browser/browser_inprogress_analysis.js
@@ -0,0 +1,151 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests that the correct shopping-message-bar component appears after requesting analysis for an unanalyzed product.
+ */
+add_task(async function test_in_progress_analysis_unanalyzed() {
+ await BrowserTestUtils.withNewTab(
+ {
+ url: "about:shoppingsidebar",
+ gBrowser,
+ },
+ async browser => {
+ await SpecialPowers.spawn(
+ browser,
+ [MOCK_UNANALYZED_PRODUCT_RESPONSE],
+ async mockData => {
+ let shoppingContainer =
+ content.document.querySelector(
+ "shopping-container"
+ ).wrappedJSObject;
+ shoppingContainer.data = Cu.cloneInto(mockData, content);
+ await shoppingContainer.updateComplete;
+
+ let unanalyzedProduct = shoppingContainer.unanalyzedProductEl;
+ let analysisButton = unanalyzedProduct.analysisButtonEl;
+
+ let messageBarVisiblePromise = ContentTaskUtils.waitForCondition(
+ () => {
+ return (
+ !!shoppingContainer.shoppingMessageBarEl &&
+ ContentTaskUtils.isVisible(
+ shoppingContainer.shoppingMessageBarEl
+ )
+ );
+ },
+ "Waiting for shopping-message-bar to be visible"
+ );
+
+ analysisButton.click();
+ await shoppingContainer.updateComplete;
+
+ // Mock the response from analysis status being "pending"
+ shoppingContainer.isAnalysisInProgress = true;
+ // Add data back, as it was unset as due to the lack of mock APIs.
+ // TODO: Support for the mocks will be added in Bug 1853474.
+ shoppingContainer.data = Cu.cloneInto(mockData, content);
+
+ await messageBarVisiblePromise;
+ await shoppingContainer.updateComplete;
+
+ is(
+ shoppingContainer.shoppingMessageBarEl?.getAttribute("type"),
+ "analysis-in-progress",
+ "shopping-message-bar type should be correct"
+ );
+ }
+ );
+ }
+ );
+});
+
+/**
+ * Tests that the correct shopping-message-bar component appears after re-requesting analysis for a stale product,
+ * and that component shows progress percentage.
+ */
+add_task(async function test_in_progress_analysis_stale() {
+ await BrowserTestUtils.withNewTab(
+ {
+ url: "about:shoppingsidebar",
+ gBrowser,
+ },
+ async browser => {
+ await SpecialPowers.spawn(
+ browser,
+ [MOCK_STALE_PRODUCT_RESPONSE],
+ async mockData => {
+ let shoppingContainer =
+ content.document.querySelector(
+ "shopping-container"
+ ).wrappedJSObject;
+ shoppingContainer.data = Cu.cloneInto(mockData, content);
+ await shoppingContainer.updateComplete;
+
+ let staleMessageBar = shoppingContainer.shoppingMessageBarEl;
+ is(staleMessageBar?.type, "stale", "Got stale message-bar");
+
+ let analysisButton = staleMessageBar.reAnalysisButtonEl;
+
+ let messageBarVisiblePromise = ContentTaskUtils.waitForCondition(
+ () => {
+ return (
+ !!shoppingContainer.shoppingMessageBarEl &&
+ ContentTaskUtils.isVisible(
+ shoppingContainer.shoppingMessageBarEl
+ )
+ );
+ },
+ "Waiting for shopping-message-bar to be visible"
+ );
+
+ analysisButton.click();
+ await shoppingContainer.updateComplete;
+
+ // Mock the response from analysis status being "pending"
+ shoppingContainer.isAnalysisInProgress = true;
+ // Mock the analysis status response with progress.
+ shoppingContainer.analysisProgress = 50;
+ // Add data back, as it was unset as due to the lack of mock APIs.
+ // TODO: Support for the mocks will be added in Bug 1853474.
+ shoppingContainer.data = Cu.cloneInto(mockData, content);
+
+ await messageBarVisiblePromise;
+ await shoppingContainer.updateComplete;
+
+ let shoppingMessageBarEl = shoppingContainer.shoppingMessageBarEl;
+ is(
+ shoppingMessageBarEl?.getAttribute("type"),
+ "reanalysis-in-progress",
+ "shopping-message-bar type should be correct"
+ );
+ is(
+ shoppingMessageBarEl?.getAttribute("progress"),
+ "50",
+ "shopping-message-bar should have progress"
+ );
+
+ let messageBarEl =
+ shoppingMessageBarEl?.shadowRoot.querySelector("message-bar");
+ is(
+ messageBarEl?.getAttribute("style"),
+ "--analysis-progress-pcent: 50%;",
+ "message-bar should have progress set as a CSS variable"
+ );
+
+ let messageBarContainerEl =
+ shoppingMessageBarEl?.shadowRoot.querySelector(
+ "#message-bar-container"
+ );
+ is(
+ messageBarContainerEl.querySelector("#header")?.dataset.l10nArgs,
+ `{"percentage":50}`,
+ "message-bar-container header should have progress set as a l10n arg"
+ );
+ }
+ );
+ }
+ );
+});
diff --git a/browser/components/shopping/tests/browser/browser_keep_close_message_bar.js b/browser/components/shopping/tests/browser/browser_keep_close_message_bar.js
new file mode 100644
index 0000000000..c4d5f5f81a
--- /dev/null
+++ b/browser/components/shopping/tests/browser/browser_keep_close_message_bar.js
@@ -0,0 +1,530 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const PRODUCT_PAGE = "https://example.com/product/B09TJGHL5F";
+
+const SIDEBAR_CLOSED_COUNT_PREF =
+ "browser.shopping.experience2023.sidebarClosedCount";
+const SHOW_KEEP_SIDEBAR_CLOSED_MESSAGE_PREF =
+ "browser.shopping.experience2023.showKeepSidebarClosedMessage";
+const SHOPPING_SIDEBAR_ACTIVE_PREF = "browser.shopping.experience2023.active";
+const SIDEBAR_AUTO_OPEN_ENABLED_PREF =
+ "browser.shopping.experience2023.autoOpen.enabled";
+const SIDEBAR_AUTO_OPEN_USER_ENABLED_PREF =
+ "browser.shopping.experience2023.autoOpen.userEnabled";
+const SHOPPING_OPTED_IN_PREF = "browser.shopping.experience2023.optedIn";
+
+add_task(
+ async function test_keep_close_message_bar_no_longer_shows_after_3_appearences() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [SHOPPING_SIDEBAR_ACTIVE_PREF, true],
+ [SHOW_KEEP_SIDEBAR_CLOSED_MESSAGE_PREF, true],
+ [SHOPPING_OPTED_IN_PREF, 1],
+ [SIDEBAR_CLOSED_COUNT_PREF, 3],
+ [SIDEBAR_AUTO_OPEN_ENABLED_PREF, true],
+ [SIDEBAR_AUTO_OPEN_USER_ENABLED_PREF, true],
+ ],
+ });
+
+ await BrowserTestUtils.withNewTab(PRODUCT_PAGE, async browser => {
+ let shoppingButton = document.getElementById("shopping-sidebar-button");
+ let browserPanel = gBrowser.getPanel(browser);
+
+ let sidebar = browserPanel.querySelector("shopping-sidebar");
+
+ function waitForSidebarOpen() {
+ return BrowserTestUtils.waitForMutationCondition(
+ shoppingButton,
+ {
+ attributeFilter: ["shoppingsidebaropen"],
+ },
+ () => shoppingButton.getAttribute("shoppingsidebaropen") === "true"
+ );
+ }
+
+ function waitForSidebarClosed() {
+ return BrowserTestUtils.waitForMutationCondition(
+ shoppingButton,
+ {
+ attributeFilter: ["shoppingsidebaropen"],
+ },
+ () => shoppingButton.getAttribute("shoppingsidebaropen") === "false"
+ );
+ }
+
+ function assertKeepClosedMessageBarVisible() {
+ return SpecialPowers.spawn(
+ sidebar.querySelector("browser"),
+ [],
+ async () => {
+ let shoppingContainer =
+ content.document.querySelector(
+ "shopping-container"
+ ).wrappedJSObject;
+ await shoppingContainer.updateComplete;
+
+ await ContentTaskUtils.waitForCondition(() => {
+ return (
+ !!shoppingContainer.keepClosedMessageBarEl &&
+ ContentTaskUtils.isVisible(
+ shoppingContainer.keepClosedMessageBarEl
+ )
+ );
+ }, "Waiting for keep message bar to be visible");
+
+ await shoppingContainer.keepClosedMessageBarEl.updateComplete;
+
+ Assert.ok(
+ shoppingContainer.showingKeepClosedMessage,
+ "We are showing the keep closed message bar"
+ );
+ }
+ );
+ }
+
+ function assertKeepClosedMessageBarNotShowing() {
+ return SpecialPowers.spawn(
+ sidebar.querySelector("browser"),
+ [],
+ async () => {
+ let shoppingContainer =
+ content.document.querySelector(
+ "shopping-container"
+ ).wrappedJSObject;
+ await shoppingContainer.updateComplete;
+
+ await ContentTaskUtils.waitForCondition(() => {
+ return (
+ !shoppingContainer.keepClosedMessageBarEl ||
+ ContentTaskUtils.isHidden(
+ shoppingContainer.keepClosedMessageBarEl
+ )
+ );
+ }, "Waiting for keep message bar to be visible");
+
+ Assert.ok(
+ !shoppingContainer.showingKeepClosedMessage,
+ "We are not showing the keep closed message bar"
+ );
+ }
+ );
+ }
+
+ function clickSidebarCloseButton() {
+ return SpecialPowers.spawn(
+ sidebar.querySelector("browser"),
+ [],
+ async () => {
+ let shoppingContainer =
+ content.document.querySelector(
+ "shopping-container"
+ ).wrappedJSObject;
+ await shoppingContainer.updateComplete;
+
+ shoppingContainer.closeButtonEl.click();
+ }
+ );
+ }
+
+ await promiseSidebarUpdated(sidebar, PRODUCT_PAGE);
+
+ await waitForSidebarOpen();
+ ok(
+ BrowserTestUtils.isVisible(sidebar),
+ "Shopping sidebar should be open"
+ );
+
+ // Close sidebar
+ shoppingButton.click();
+
+ await waitForSidebarClosed();
+ ok(
+ BrowserTestUtils.isHidden(sidebar),
+ "Shopping sidebar should be closed"
+ );
+
+ // Open sidebar
+ shoppingButton.click();
+
+ await waitForSidebarOpen();
+ ok(
+ BrowserTestUtils.isVisible(sidebar),
+ "Shopping sidebar should be open"
+ );
+
+ // Try closing sidebar. Keep closed message bar will show
+ await clickSidebarCloseButton();
+
+ await TestUtils.waitForTick();
+ await assertKeepClosedMessageBarVisible();
+
+ await clickSidebarCloseButton();
+
+ await waitForSidebarClosed();
+ ok(
+ BrowserTestUtils.isHidden(sidebar),
+ "Shopping sidebar should be closed"
+ );
+
+ // Open sidebar
+ shoppingButton.click();
+
+ await waitForSidebarOpen();
+ ok(
+ BrowserTestUtils.isVisible(sidebar),
+ "Shopping sidebar should be open"
+ );
+
+ // Try closing sidebar. Keep closed message bar will show
+ shoppingButton.click();
+
+ await TestUtils.waitForTick();
+ await assertKeepClosedMessageBarVisible();
+
+ shoppingButton.click();
+
+ await waitForSidebarClosed();
+ ok(
+ BrowserTestUtils.isHidden(sidebar),
+ "Shopping sidebar should be closed"
+ );
+
+ // Open sidebar
+ shoppingButton.click();
+
+ await waitForSidebarOpen();
+ ok(
+ BrowserTestUtils.isVisible(sidebar),
+ "Shopping sidebar should be open"
+ );
+
+ // Try closing sidebar. Keep closed message bar will show
+ shoppingButton.click();
+
+ await TestUtils.waitForTick();
+ await assertKeepClosedMessageBarVisible();
+
+ shoppingButton.click();
+
+ await waitForSidebarClosed();
+ ok(
+ BrowserTestUtils.isHidden(sidebar),
+ "Shopping sidebar should be closed"
+ );
+
+ // Open sidebar
+ shoppingButton.click();
+
+ await waitForSidebarOpen();
+ ok(
+ BrowserTestUtils.isVisible(sidebar),
+ "Shopping sidebar should be open"
+ );
+ await assertKeepClosedMessageBarNotShowing();
+
+ // Close sidebar. Keep closed message bar no longer shows
+ await clickSidebarCloseButton();
+
+ await waitForSidebarClosed();
+ ok(
+ BrowserTestUtils.isHidden(sidebar),
+ "Shopping sidebar should be closed"
+ );
+ });
+ }
+);
+
+add_task(async function test_keep_close_message_bar_no_thanks() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [SHOPPING_SIDEBAR_ACTIVE_PREF, true],
+ [SHOW_KEEP_SIDEBAR_CLOSED_MESSAGE_PREF, true],
+ [SHOPPING_OPTED_IN_PREF, 1],
+ [SIDEBAR_CLOSED_COUNT_PREF, 5],
+ [SIDEBAR_AUTO_OPEN_ENABLED_PREF, true],
+ [SIDEBAR_AUTO_OPEN_USER_ENABLED_PREF, true],
+ ],
+ });
+
+ await BrowserTestUtils.withNewTab(PRODUCT_PAGE, async browser => {
+ let shoppingButton = document.getElementById("shopping-sidebar-button");
+ let browserPanel = gBrowser.getPanel(browser);
+
+ let sidebar = browserPanel.querySelector("shopping-sidebar");
+
+ function waitForSidebarOpen() {
+ return BrowserTestUtils.waitForMutationCondition(
+ shoppingButton,
+ {
+ attributeFilter: ["shoppingsidebaropen"],
+ },
+ () => shoppingButton.getAttribute("shoppingsidebaropen") === "true"
+ );
+ }
+
+ function waitForSidebarClosed() {
+ return BrowserTestUtils.waitForMutationCondition(
+ shoppingButton,
+ {
+ attributeFilter: ["shoppingsidebaropen"],
+ },
+ () => shoppingButton.getAttribute("shoppingsidebaropen") === "false"
+ );
+ }
+
+ function assertKeepClosedMessageBarVisible() {
+ return SpecialPowers.spawn(
+ sidebar.querySelector("browser"),
+ [],
+ async () => {
+ let shoppingContainer =
+ content.document.querySelector(
+ "shopping-container"
+ ).wrappedJSObject;
+ await shoppingContainer.updateComplete;
+
+ await ContentTaskUtils.waitForCondition(() => {
+ return (
+ !!shoppingContainer.keepClosedMessageBarEl &&
+ ContentTaskUtils.isVisible(
+ shoppingContainer.keepClosedMessageBarEl
+ )
+ );
+ }, "Waiting for keep message bar to be visible");
+
+ await shoppingContainer.keepClosedMessageBarEl.updateComplete;
+
+ Assert.ok(
+ shoppingContainer.showingKeepClosedMessage,
+ "We are showing the keep closed message bar"
+ );
+ }
+ );
+ }
+
+ function assertKeepClosedMessageBarNotShowing() {
+ return SpecialPowers.spawn(
+ sidebar.querySelector("browser"),
+ [],
+ async () => {
+ let shoppingContainer =
+ content.document.querySelector(
+ "shopping-container"
+ ).wrappedJSObject;
+ await shoppingContainer.updateComplete;
+
+ await ContentTaskUtils.waitForCondition(() => {
+ return (
+ !shoppingContainer.keepClosedMessageBarEl ||
+ ContentTaskUtils.isHidden(
+ shoppingContainer.keepClosedMessageBarEl
+ )
+ );
+ }, "Waiting for keep message bar to be visible");
+
+ Assert.ok(
+ !shoppingContainer.showingKeepClosedMessage,
+ "We are not showing the keep closed message bar"
+ );
+ }
+ );
+ }
+
+ function clickNoThanksButton() {
+ return SpecialPowers.spawn(
+ sidebar.querySelector("browser"),
+ [],
+ async () => {
+ let shoppingContainer =
+ content.document.querySelector(
+ "shopping-container"
+ ).wrappedJSObject;
+ await shoppingContainer.updateComplete;
+
+ let keepClosedMessageBar = shoppingContainer.keepClosedMessageBarEl;
+ await keepClosedMessageBar.updateComplete;
+
+ keepClosedMessageBar.noThanksButtonEl.click();
+ }
+ );
+ }
+
+ await promiseSidebarUpdated(sidebar, PRODUCT_PAGE);
+
+ await waitForSidebarOpen();
+ ok(BrowserTestUtils.isVisible(sidebar), "Shopping sidebar should be open");
+
+ // Try closing sidebar. Keep closed message bar will show
+ shoppingButton.click();
+
+ await TestUtils.waitForTick();
+ await assertKeepClosedMessageBarVisible();
+
+ await clickNoThanksButton();
+
+ await waitForSidebarClosed();
+ ok(BrowserTestUtils.isHidden(sidebar), "Shopping sidebar should be closed");
+
+ // Open sidebar
+ shoppingButton.click();
+
+ await waitForSidebarOpen();
+ ok(BrowserTestUtils.isVisible(sidebar), "Shopping sidebar should be open");
+ await assertKeepClosedMessageBarNotShowing();
+
+ // Close sidebar. Keep closed message no longer shows
+ shoppingButton.click();
+
+ await waitForSidebarClosed();
+ ok(BrowserTestUtils.isHidden(sidebar), "Shopping sidebar should be closed");
+ });
+});
+
+add_task(async function test_keep_close_message_bar_yes_keep_closed() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [SHOPPING_SIDEBAR_ACTIVE_PREF, true],
+ [SHOW_KEEP_SIDEBAR_CLOSED_MESSAGE_PREF, true],
+ [SHOPPING_OPTED_IN_PREF, 1],
+ [SIDEBAR_CLOSED_COUNT_PREF, 5],
+ [SIDEBAR_AUTO_OPEN_ENABLED_PREF, true],
+ [SIDEBAR_AUTO_OPEN_USER_ENABLED_PREF, true],
+ ],
+ });
+
+ await BrowserTestUtils.withNewTab(PRODUCT_PAGE, async browser => {
+ let shoppingButton = document.getElementById("shopping-sidebar-button");
+ let browserPanel = gBrowser.getPanel(browser);
+
+ let sidebar = browserPanel.querySelector("shopping-sidebar");
+
+ function waitForSidebarOpen() {
+ return BrowserTestUtils.waitForMutationCondition(
+ shoppingButton,
+ {
+ attributeFilter: ["shoppingsidebaropen"],
+ },
+ () => shoppingButton.getAttribute("shoppingsidebaropen") === "true"
+ );
+ }
+
+ function waitForSidebarClosed() {
+ return BrowserTestUtils.waitForMutationCondition(
+ shoppingButton,
+ {
+ attributeFilter: ["shoppingsidebaropen"],
+ },
+ () => shoppingButton.getAttribute("shoppingsidebaropen") === "false"
+ );
+ }
+
+ function assertKeepClosedMessageBarVisible() {
+ return SpecialPowers.spawn(
+ sidebar.querySelector("browser"),
+ [],
+ async () => {
+ let shoppingContainer =
+ content.document.querySelector(
+ "shopping-container"
+ ).wrappedJSObject;
+ await shoppingContainer.updateComplete;
+
+ await ContentTaskUtils.waitForCondition(() => {
+ return (
+ !!shoppingContainer.keepClosedMessageBarEl &&
+ ContentTaskUtils.isVisible(
+ shoppingContainer.keepClosedMessageBarEl
+ )
+ );
+ }, "Waiting for keep message bar to be visible");
+
+ await shoppingContainer.keepClosedMessageBarEl.updateComplete;
+
+ Assert.ok(
+ shoppingContainer.showingKeepClosedMessage,
+ "We are showing the keep closed message bar"
+ );
+ }
+ );
+ }
+
+ function assertKeepClosedMessageBarNotShowing() {
+ return SpecialPowers.spawn(
+ sidebar.querySelector("browser"),
+ [],
+ async () => {
+ let shoppingContainer =
+ content.document.querySelector(
+ "shopping-container"
+ ).wrappedJSObject;
+ await shoppingContainer.updateComplete;
+
+ await ContentTaskUtils.waitForCondition(() => {
+ return (
+ !shoppingContainer.keepClosedMessageBarEl ||
+ ContentTaskUtils.isHidden(
+ shoppingContainer.keepClosedMessageBarEl
+ )
+ );
+ }, "Waiting for keep message bar to be visible");
+
+ Assert.ok(
+ !shoppingContainer.showingKeepClosedMessage,
+ "We are not showing the keep closed message bar"
+ );
+ }
+ );
+ }
+
+ function clickYesKeepClosedButton() {
+ return SpecialPowers.spawn(
+ sidebar.querySelector("browser"),
+ [],
+ async () => {
+ let shoppingContainer =
+ content.document.querySelector(
+ "shopping-container"
+ ).wrappedJSObject;
+ await shoppingContainer.updateComplete;
+
+ let keepClosedMessageBar = shoppingContainer.keepClosedMessageBarEl;
+ await keepClosedMessageBar.updateComplete;
+
+ keepClosedMessageBar.yesKeepClosedButtonEl.click();
+ }
+ );
+ }
+
+ await promiseSidebarUpdated(sidebar, PRODUCT_PAGE);
+
+ await waitForSidebarOpen();
+ ok(BrowserTestUtils.isVisible(sidebar), "Shopping sidebar should be open");
+
+ // Try closing sidebar. Keep closed message bar will show
+ shoppingButton.click();
+
+ await TestUtils.waitForTick();
+ await assertKeepClosedMessageBarVisible();
+
+ await clickYesKeepClosedButton();
+
+ await waitForSidebarClosed();
+ ok(BrowserTestUtils.isHidden(sidebar), "Shopping sidebar should be closed");
+
+ // Open sidebar
+ shoppingButton.click();
+
+ await waitForSidebarOpen();
+ ok(BrowserTestUtils.isVisible(sidebar), "Shopping sidebar should be open");
+ await assertKeepClosedMessageBarNotShowing();
+
+ // Close sidebar. Keep closed message no longer shows
+ shoppingButton.click();
+
+ await waitForSidebarClosed();
+ ok(BrowserTestUtils.isHidden(sidebar), "Shopping sidebar should be closed");
+ });
+});
diff --git a/browser/components/shopping/tests/browser/browser_network_offline.js b/browser/components/shopping/tests/browser/browser_network_offline.js
new file mode 100644
index 0000000000..d833d551d8
--- /dev/null
+++ b/browser/components/shopping/tests/browser/browser_network_offline.js
@@ -0,0 +1,33 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function test_setup() {
+ let originalIoOffline = Services.io.offline;
+ Services.io.offline = true;
+
+ registerCleanupFunction(() => {
+ Services.io.offline = originalIoOffline;
+ });
+});
+
+/**
+ * Tests that only the loading state appears when there is no network connection.
+ */
+add_task(async function test_offline_warning() {
+ await BrowserTestUtils.withNewTab(
+ {
+ url: "about:shoppingsidebar",
+ gBrowser,
+ },
+ async browser => {
+ let shoppingContainer = await getAnalysisDetails(browser, null);
+
+ ok(shoppingContainer.isOffline, "Offline status detected");
+ ok(shoppingContainer.loadingEl, "Render loading state");
+ verifyAnalysisDetailsHidden(shoppingContainer);
+ verifyFooterHidden(shoppingContainer);
+ }
+ );
+});
diff --git a/browser/components/shopping/tests/browser/browser_not_enough_reviews.js b/browser/components/shopping/tests/browser/browser_not_enough_reviews.js
new file mode 100644
index 0000000000..d979156c1e
--- /dev/null
+++ b/browser/components/shopping/tests/browser/browser_not_enough_reviews.js
@@ -0,0 +1,80 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Tests that the unanalyzed card is shown when not_enough_reviews is not present.
+
+add_task(async function test_show_unanalyzed_on_initial_load() {
+ await BrowserTestUtils.withNewTab(
+ {
+ url: "about:shoppingsidebar",
+ gBrowser,
+ },
+ async browser => {
+ let shoppingContainer = await getAnalysisDetails(
+ browser,
+ MOCK_UNANALYZED_PRODUCT_RESPONSE
+ );
+ ok(
+ shoppingContainer.unanalyzedProductEl,
+ "Got unanalyzed card on first try"
+ );
+
+ verifyAnalysisDetailsHidden(shoppingContainer);
+ verifyFooterVisible(shoppingContainer);
+ }
+ );
+});
+
+// Tests that the not enough reviews card is shown when not_enough_reviews is true.
+
+add_task(async function test_show_not_enough_reviews() {
+ await BrowserTestUtils.withNewTab(
+ {
+ url: "about:shoppingsidebar",
+ gBrowser,
+ },
+
+ async browser => {
+ await SpecialPowers.spawn(
+ browser,
+ [MOCK_NOT_ENOUGH_REVIEWS_PRODUCT_RESPONSE],
+ async mockData => {
+ let shoppingContainer =
+ content.document.querySelector(
+ "shopping-container"
+ ).wrappedJSObject;
+
+ shoppingContainer.data = Cu.cloneInto(mockData, content);
+
+ let messageBarVisiblePromise = ContentTaskUtils.waitForCondition(
+ () => {
+ return (
+ !!shoppingContainer.shoppingMessageBarEl &&
+ ContentTaskUtils.isVisible(
+ shoppingContainer.shoppingMessageBarEl
+ )
+ );
+ },
+ "Waiting for shopping-message-bar to be visible"
+ );
+
+ await messageBarVisiblePromise;
+ await shoppingContainer.updateComplete;
+
+ ok(
+ shoppingContainer.shoppingMessageBarEl,
+ "Got shopping-message-bar element"
+ );
+
+ is(
+ shoppingContainer.shoppingMessageBarEl?.getAttribute("type"),
+ "not-enough-reviews",
+ "shopping-message-bar type should be correct"
+ );
+ }
+ );
+ }
+ );
+});
diff --git a/browser/components/shopping/tests/browser/browser_page_not_supported.js b/browser/components/shopping/tests/browser/browser_page_not_supported.js
new file mode 100644
index 0000000000..c14fd697da
--- /dev/null
+++ b/browser/components/shopping/tests/browser/browser_page_not_supported.js
@@ -0,0 +1,36 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests that the correct shopping-message-bar component appears if a page is not supported.
+ * Only footer should be visible.
+ */
+add_task(async function test_page_not_supported() {
+ await BrowserTestUtils.withNewTab(
+ {
+ url: "about:shoppingsidebar",
+ gBrowser,
+ },
+ async browser => {
+ let shoppingContainer = await getAnalysisDetails(
+ browser,
+ MOCK_PAGE_NOT_SUPPORTED_RESPONSE
+ );
+
+ ok(
+ shoppingContainer.shoppingMessageBarEl,
+ "Got shopping-message-bar element"
+ );
+ is(
+ shoppingContainer.shoppingMessageBarType,
+ "page-not-supported",
+ "shopping-message-bar type should be correct"
+ );
+
+ verifyAnalysisDetailsHidden(shoppingContainer);
+ verifyFooterVisible(shoppingContainer);
+ }
+ );
+});
diff --git a/browser/components/shopping/tests/browser/browser_private_mode.js b/browser/components/shopping/tests/browser/browser_private_mode.js
new file mode 100644
index 0000000000..16d7ee733b
--- /dev/null
+++ b/browser/components/shopping/tests/browser/browser_private_mode.js
@@ -0,0 +1,35 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// This test verifies that the shopping sidebar is not initialized if the
+// user visits a shopping product page while in private browsing mode.
+
+add_task(async function test_private_window_disabled() {
+ let privateWindow = await BrowserTestUtils.openNewBrowserWindow({
+ private: true,
+ });
+
+ let browser = privateWindow.gBrowser.selectedBrowser;
+ BrowserTestUtils.startLoadingURIString(
+ browser,
+ "https://example.com/product/B09TJGHL5F"
+ );
+ await BrowserTestUtils.browserLoaded(browser);
+
+ let shoppingButton = privateWindow.document.getElementById(
+ "shopping-sidebar-button"
+ );
+ ok(
+ BrowserTestUtils.isHidden(shoppingButton),
+ "Shopping Button should not be visible on a product page"
+ );
+
+ ok(
+ !privateWindow.document.querySelector("shopping-sidebar"),
+ "Shopping sidebar does not exist"
+ );
+
+ await BrowserTestUtils.closeWindow(privateWindow);
+});
diff --git a/browser/components/shopping/tests/browser/browser_recommended_ad_test.js b/browser/components/shopping/tests/browser/browser_recommended_ad_test.js
new file mode 100644
index 0000000000..159bd0514e
--- /dev/null
+++ b/browser/components/shopping/tests/browser/browser_recommended_ad_test.js
@@ -0,0 +1,71 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function test_ads_requested_after_enabled() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["browser.shopping.experience2023.ads.enabled", true],
+ ["browser.shopping.experience2023.ads.userEnabled", false],
+ ["browser.shopping.experience2023.autoOpen.enabled", true],
+ ["toolkit.shopping.ohttpRelayURL", ""],
+ ["toolkit.shopping.ohttpConfigURL", ""],
+ ],
+ });
+ await BrowserTestUtils.withNewTab(
+ {
+ url: PRODUCT_TEST_URL,
+ gBrowser,
+ },
+ async browser => {
+ let sidebar = gBrowser
+ .getPanel(browser)
+ .querySelector("shopping-sidebar");
+ Assert.ok(sidebar, "Sidebar should exist");
+ Assert.ok(
+ BrowserTestUtils.isVisible(sidebar),
+ "Sidebar should be visible."
+ );
+ info("Waiting for sidebar to update.");
+ await promiseSidebarUpdated(sidebar, PRODUCT_TEST_URL);
+
+ await SpecialPowers.spawn(
+ sidebar.querySelector("browser"),
+ [],
+ async () => {
+ let shoppingContainer =
+ content.document.querySelector(
+ "shopping-container"
+ ).wrappedJSObject;
+ await shoppingContainer.updateComplete;
+
+ Assert.ok(
+ !shoppingContainer.recommendedAdEl,
+ "Recommended card should not exist"
+ );
+
+ let shoppingSettings = shoppingContainer.settingsEl;
+ await shoppingSettings.updateComplete;
+
+ let recommendationsToggle = shoppingSettings.recommendationsToggleEl;
+ recommendationsToggle.click();
+
+ await ContentTaskUtils.waitForCondition(() => {
+ return shoppingContainer.recommendedAdEl;
+ });
+
+ await shoppingContainer.updateComplete;
+
+ let recommendedCard = shoppingContainer.recommendedAdEl;
+ await recommendedCard.updateComplete;
+ Assert.ok(recommendedCard, "Recommended card should exist");
+ Assert.ok(
+ ContentTaskUtils.isVisible(recommendedCard),
+ "Recommended card is visible"
+ );
+ }
+ );
+ }
+ );
+});
diff --git a/browser/components/shopping/tests/browser/browser_review_highlights.js b/browser/components/shopping/tests/browser/browser_review_highlights.js
new file mode 100644
index 0000000000..f4f3467a80
--- /dev/null
+++ b/browser/components/shopping/tests/browser/browser_review_highlights.js
@@ -0,0 +1,194 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+async function verifyHighlights(
+ browser,
+ data,
+ productUrl /* optional, set to override */,
+ expectedHighlightTypes,
+ expectedLang
+) {
+ return SpecialPowers.spawn(
+ browser,
+ [{ data, productUrl, expectedHighlightTypes, expectedLang }],
+ async args => {
+ let shoppingContainer =
+ content.document.querySelector("shopping-container").wrappedJSObject;
+ shoppingContainer.data = Cu.cloneInto(args.data, content);
+ if (args.productUrl) {
+ shoppingContainer.productUrl = args.productUrl;
+ }
+ await shoppingContainer.updateComplete;
+
+ let reviewHighlights = shoppingContainer.highlightsEl;
+ ok(reviewHighlights, "Got review-highlights");
+ await reviewHighlights.updateComplete;
+
+ let highlightsList = reviewHighlights.reviewHighlightsListEl;
+ await highlightsList.updateComplete;
+
+ is(
+ highlightsList.children.length,
+ args.expectedHighlightTypes.length,
+ "review-highlights should have the right number of highlight-items"
+ );
+
+ // Verify number of reviews for each available highlight
+ for (let key of args.expectedHighlightTypes) {
+ let highlightEl = highlightsList.querySelector(
+ `#${content.CSS.escape(key)}`
+ );
+
+ ok(highlightEl, "highlight-item for " + key + " exists");
+ is(
+ highlightEl.lang,
+ args.expectedLang,
+ `highlight-item should have lang set to ${args.expectedLang}`
+ );
+
+ let actualNumberOfReviews = highlightEl.shadowRoot.querySelector(
+ ".highlight-details-list"
+ ).children.length;
+ let expectedNumberOfReviews = Object.values(
+ args.data.highlights[key]
+ ).flat().length;
+
+ is(
+ actualNumberOfReviews,
+ expectedNumberOfReviews,
+ "There should be equal number of reviews displayed for " + key
+ );
+ }
+ }
+ );
+}
+
+/**
+ * Tests that the review highlights custom components are visible on the page
+ * if there is valid data.
+ */
+add_task(async function test_review_highlights() {
+ await BrowserTestUtils.withNewTab(
+ {
+ url: "about:shoppingsidebar",
+ gBrowser,
+ },
+ async browser => {
+ let data = MOCK_ANALYZED_PRODUCT_RESPONSE;
+ let expectedHighlightTypes = [
+ "price",
+ "quality",
+ "competitiveness",
+ "packaging/appearance",
+ ];
+
+ info("Testing with default en highlights");
+ await verifyHighlights(
+ browser,
+ data,
+ undefined,
+ expectedHighlightTypes,
+ "en"
+ );
+
+ info("Testing with www.amazon.fr");
+ await verifyHighlights(
+ browser,
+ data,
+ "https://www.amazon.fr",
+ expectedHighlightTypes,
+ "fr"
+ );
+
+ info("Testing with www.amazon.de");
+ await verifyHighlights(
+ browser,
+ data,
+ "https://www.amazon.de",
+ expectedHighlightTypes,
+ "de"
+ );
+ }
+ );
+});
+
+/**
+ * Tests that entire highlights components is still hidden if we receive falsy data.
+ */
+add_task(async function test_review_highlights_no_highlights() {
+ await BrowserTestUtils.withNewTab(
+ {
+ url: "about:shoppingsidebar",
+ gBrowser,
+ },
+ async browser => {
+ await SpecialPowers.spawn(
+ browser,
+ [MOCK_ANALYZED_PRODUCT_RESPONSE],
+ async mockData => {
+ mockData.highlights = null;
+
+ let shoppingContainer =
+ content.document.querySelector(
+ "shopping-container"
+ ).wrappedJSObject;
+ shoppingContainer.data = Cu.cloneInto(mockData, content);
+ await shoppingContainer.updateComplete;
+
+ let reviewHighlights = shoppingContainer.highlightsEl;
+ ok(reviewHighlights, "Got review-highlights");
+ await reviewHighlights.updateComplete;
+
+ ok(
+ ContentTaskUtils.isHidden(reviewHighlights),
+ "review-highlights should not be visible"
+ );
+
+ let highlightsList = reviewHighlights?.reviewHighlightsListEl;
+ ok(!highlightsList, "review-highlights-list should not be visible");
+ }
+ );
+ }
+ );
+});
+
+/**
+ * Tests that we do not show an invalid highlight type and properly filter data.
+ */
+add_task(async function test_review_highlights_invalid_type() {
+ await BrowserTestUtils.withNewTab(
+ {
+ url: "about:shoppingsidebar",
+ gBrowser,
+ },
+ async browser => {
+ const invalidHighlightData = structuredClone(
+ MOCK_ANALYZED_PRODUCT_RESPONSE
+ );
+ invalidHighlightData.highlights = MOCK_INVALID_KEY_OBJ;
+ await SpecialPowers.spawn(
+ browser,
+ [invalidHighlightData],
+ async mockData => {
+ let shoppingContainer =
+ content.document.querySelector(
+ "shopping-container"
+ ).wrappedJSObject;
+ shoppingContainer.data = Cu.cloneInto(mockData, content);
+ await shoppingContainer.updateComplete;
+
+ let reviewHighlights = shoppingContainer.highlightsEl;
+ ok(reviewHighlights, "Got review-highlights");
+ await reviewHighlights.updateComplete;
+
+ ok(
+ ContentTaskUtils.isHidden(reviewHighlights),
+ "review-highlights should not be visible"
+ );
+ }
+ );
+ }
+ );
+});
diff --git a/browser/components/shopping/tests/browser/browser_settings_telemetry.js b/browser/components/shopping/tests/browser/browser_settings_telemetry.js
new file mode 100644
index 0000000000..803630f73d
--- /dev/null
+++ b/browser/components/shopping/tests/browser/browser_settings_telemetry.js
@@ -0,0 +1,102 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests that the settings component is rendered as expected.
+ */
+add_task(async function test_shopping_settings() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["toolkit.telemetry.testing.overridePreRelease", true],
+ ["browser.shopping.experience2023.optedIn", 0],
+ ],
+ });
+
+ let opt_in_status = Services.prefs.getIntPref(
+ "browser.shopping.experience2023.optedIn",
+ undefined
+ );
+ // Values that match how we're defining the metrics
+ let component_opted_out = opt_in_status === 2;
+ let onboarded_status = opt_in_status > 0;
+
+ Assert.equal(
+ component_opted_out,
+ Glean.shoppingSettings.componentOptedOut.testGetValue(),
+ "Component Opted Out metric should correctly reflect the preference value"
+ );
+ Assert.equal(
+ onboarded_status,
+ Glean.shoppingSettings.hasOnboarded.testGetValue(),
+ "Has Onboarded metric should correctly reflect the preference value"
+ );
+
+ await SpecialPowers.popPrefEnv();
+});
+
+add_task(async function test_shopping_setting_update() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["toolkit.telemetry.testing.overridePreRelease", true],
+ ["browser.shopping.experience2023.optedIn", 2],
+ ],
+ });
+
+ Assert.equal(
+ true,
+ Glean.shoppingSettings.componentOptedOut.testGetValue(),
+ "Component Opted Out metric should return True as we've set the value of the preference"
+ );
+
+ await SpecialPowers.popPrefEnv();
+});
+
+add_task(async function test_shopping_settings_ads_enabled() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.shopping.experience2023.optedIn", 1]],
+ });
+ await Services.fog.testFlushAllChildren();
+ Services.fog.testResetFOG();
+
+ await BrowserTestUtils.withNewTab(
+ {
+ url: "about:shoppingsidebar",
+ gBrowser,
+ },
+ async browser => {
+ await SpecialPowers.spawn(
+ browser,
+ [MOCK_ANALYZED_PRODUCT_RESPONSE],
+ async mockData => {
+ let shoppingContainer =
+ content.document.querySelector(
+ "shopping-container"
+ ).wrappedJSObject;
+
+ shoppingContainer.data = Cu.cloneInto(mockData, content);
+ shoppingContainer.adsEnabled = true;
+ await shoppingContainer.updateComplete;
+
+ let shoppingSettings = shoppingContainer.settingsEl;
+ ok(shoppingSettings, "Got the shopping-settings element");
+
+ let optOutButton = shoppingSettings.optOutButtonEl;
+ ok(optOutButton, "There should be an opt-out button");
+
+ optOutButton.click();
+ }
+ );
+ }
+ );
+
+ await Services.fog.testFlushAllChildren();
+ var optOutClickedEvents =
+ Glean.shopping.surfaceOptOutButtonClicked.testGetValue();
+
+ Assert.equal(optOutClickedEvents.length, 1);
+ Assert.equal(optOutClickedEvents[0].category, "shopping");
+ Assert.equal(optOutClickedEvents[0].name, "surface_opt_out_button_clicked");
+ await SpecialPowers.popPrefEnv();
+});
diff --git a/browser/components/shopping/tests/browser/browser_shopping_card.js b/browser/components/shopping/tests/browser/browser_shopping_card.js
new file mode 100644
index 0000000000..ebe35f1cc0
--- /dev/null
+++ b/browser/components/shopping/tests/browser/browser_shopping_card.js
@@ -0,0 +1,50 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests that the chevron button's accessible name and state.
+ */
+add_task(async function test_chevron_button_markup() {
+ await BrowserTestUtils.withNewTab(
+ {
+ url: "about:shoppingsidebar",
+ gBrowser,
+ },
+ async browser => {
+ await SpecialPowers.spawn(
+ browser,
+ [MOCK_UNANALYZED_PRODUCT_RESPONSE],
+ async mockData => {
+ let shoppingContainer =
+ content.document.querySelector(
+ "shopping-container"
+ ).wrappedJSObject;
+ shoppingContainer.data = Cu.cloneInto(mockData, content);
+ await shoppingContainer.updateComplete;
+
+ let shoppingSettings = content.document
+ .querySelector("shopping-container")
+ .shadowRoot.querySelector("shopping-settings");
+ let shoppingCard =
+ shoppingSettings.shadowRoot.querySelector("shopping-card");
+ let detailsEl = shoppingCard.shadowRoot.querySelector("details");
+
+ // Need to wait for different async events to complete on the lit component:
+ await ContentTaskUtils.waitForCondition(() =>
+ detailsEl.querySelector(".chevron-icon")
+ );
+
+ let chevronButton = detailsEl.querySelector(".chevron-icon");
+
+ is(
+ chevronButton.getAttribute("aria-labelledby"),
+ "header",
+ "The chevron button is has an accessible name"
+ );
+ }
+ );
+ }
+ );
+});
diff --git a/browser/components/shopping/tests/browser/browser_shopping_container.js b/browser/components/shopping/tests/browser/browser_shopping_container.js
new file mode 100644
index 0000000000..533f40f33e
--- /dev/null
+++ b/browser/components/shopping/tests/browser/browser_shopping_container.js
@@ -0,0 +1,41 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function test_close_button() {
+ await BrowserTestUtils.withNewTab(
+ {
+ url: "about:shoppingsidebar",
+ gBrowser,
+ },
+ async browser => {
+ // Call SpecialPowers.spawn to make RPMSetPref available on the content window.
+ await SpecialPowers.spawn(
+ browser,
+ [MOCK_ANALYZED_PRODUCT_RESPONSE],
+ async () => {
+ let { sinon } = ChromeUtils.importESModule(
+ "resource://testing-common/Sinon.sys.mjs"
+ );
+
+ let xrayWindow = ChromeUtils.waiveXrays(content);
+ let setPrefSpy = sinon.spy(xrayWindow, "RPMSetPref");
+
+ let closeButton = content.document
+ .querySelector("shopping-container")
+ .shadowRoot.querySelector("#close-button");
+ closeButton.click();
+
+ ok(
+ setPrefSpy.calledOnceWith(
+ "browser.shopping.experience2023.active",
+ false
+ )
+ );
+ setPrefSpy.restore();
+ }
+ );
+ }
+ );
+});
diff --git a/browser/components/shopping/tests/browser/browser_shopping_message_triggers.js b/browser/components/shopping/tests/browser/browser_shopping_message_triggers.js
new file mode 100644
index 0000000000..47e4e2a1a7
--- /dev/null
+++ b/browser/components/shopping/tests/browser/browser_shopping_message_triggers.js
@@ -0,0 +1,315 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ ShoppingUtils: "resource:///modules/ShoppingUtils.sys.mjs",
+});
+
+const { ASRouter } = ChromeUtils.importESModule(
+ "resource:///modules/asrouter/ASRouter.sys.mjs"
+);
+
+const { FeatureCalloutMessages } = ChromeUtils.importESModule(
+ "resource:///modules/asrouter/FeatureCalloutMessages.sys.mjs"
+);
+
+const OPTED_IN_PREF = "browser.shopping.experience2023.optedIn";
+const ACTIVE_PREF = "browser.shopping.experience2023.active";
+const CFR_ENABLED_PREF =
+ "browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features";
+
+const CONTENT_PAGE = "https://example.com";
+const PRODUCT_PAGE = "https://example.com/product/B09TJGHL5F";
+
+add_setup(async function setup() {
+ // disable auto-activation to prevent interference with the tests
+ ShoppingUtils.handledAutoActivate = true;
+ // clean up all the prefs/states modified by this test
+ registerCleanupFunction(() => {
+ ShoppingUtils.handledAutoActivate = false;
+ });
+});
+
+/** Test that the correct callouts show for opted-in users */
+add_task(async function test_fakespot_callouts_opted_in_flow() {
+ // Re-enable feature callouts for this test. This has to be done in each task
+ // because they're disabled in browser.ini.
+ await SpecialPowers.pushPrefEnv({ set: [[CFR_ENABLED_PREF, true]] });
+ let sandbox = sinon.createSandbox();
+ let routeCFRMessageStub = sandbox
+ .stub(ASRouter, "routeCFRMessage")
+ .withArgs(
+ sinon.match.any,
+ sinon.match.any,
+ sinon.match({ id: "shoppingProductPageWithSidebarClosed" })
+ );
+ await ASRouter._updateMessageProviders();
+ await ASRouter.loadMessagesFromAllProviders(
+ ASRouter.state.providers.filter(p => p.id === "onboarding")
+ );
+
+ // Reset opt-in but make the sidebar active so it appears on PDP.
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [ACTIVE_PREF, true],
+ [OPTED_IN_PREF, 0],
+ ],
+ });
+
+ // Visit a product page and wait for the sidebar to open.
+ let pdpTab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ PRODUCT_PAGE
+ );
+ let pdpBrowser = pdpTab.linkedBrowser;
+ let pdpBrowserPanel = gBrowser.getPanel(pdpBrowser);
+ let isSidebarVisible = () => {
+ let sidebar = pdpBrowserPanel.querySelector("shopping-sidebar");
+ return sidebar && BrowserTestUtils.isVisible(sidebar);
+ };
+ await BrowserTestUtils.waitForMutationCondition(
+ pdpBrowserPanel,
+ { childList: true, attributeFilter: ["hidden"] },
+ isSidebarVisible
+ );
+ ok(isSidebarVisible(), "Shopping sidebar should be open on a product page");
+
+ // Visiting the PDP should not cause shoppingProductPageWithSidebarClosed to
+ // fire in this case, because the sidebar is active.
+ ok(
+ routeCFRMessageStub.neverCalledWithMatch(
+ sinon.match.any,
+ sinon.match.any,
+ sinon.match({ id: "shoppingProductPageWithSidebarClosed" })
+ ),
+ "shoppingProductPageWithSidebarClosed should not fire when sidebar is active"
+ );
+
+ // Now opt in...
+ let prefChanged = TestUtils.waitForPrefChange(
+ OPTED_IN_PREF,
+ value => value === 1
+ );
+ await SpecialPowers.pushPrefEnv({ set: [[OPTED_IN_PREF, 1]] });
+ await prefChanged;
+
+ // Close the sidebar by deactivating the global toggle, and wait for the
+ // shoppingProductPageWithSidebarClosed trigger to fire.
+ let shoppingProductPageWithSidebarClosedMsg = new Promise(resolve => {
+ routeCFRMessageStub.callsFake((message, browser, trigger) => {
+ if (
+ trigger.id === "shoppingProductPageWithSidebarClosed" &&
+ trigger.context.isSidebarClosing
+ ) {
+ resolve(message?.id);
+ }
+ });
+ });
+ await SpecialPowers.pushPrefEnv({ set: [[ACTIVE_PREF, false]] });
+ // Assert that the message is the one we expect.
+ is(
+ await shoppingProductPageWithSidebarClosedMsg,
+ "FAKESPOT_CALLOUT_CLOSED_OPTED_IN_DEFAULT",
+ "Should route the expected message: FAKESPOT_CALLOUT_CLOSED_OPTED_IN_DEFAULT"
+ );
+ BrowserTestUtils.removeTab(pdpTab);
+
+ // Now, having seen the on-closed callout, we should expect to see the on-PDP
+ // callout on the next PDP visit, provided it's been at least 24 hours.
+ //
+ // Of course we can't really do that in an automated test, so we'll override
+ // the message impression date to simulate that.
+ //
+ // But first, try opening a PDP so we can test that it _doesn't_ fire if less
+ // than 24hrs has passed.
+
+ // Visit a product page and wait for routeCFRMessage to fire, expecting the
+ // message to be null due to targeting.
+ shoppingProductPageWithSidebarClosedMsg = new Promise(resolve => {
+ routeCFRMessageStub.callsFake((message, browser, trigger) => {
+ if (
+ trigger.id === "shoppingProductPageWithSidebarClosed" &&
+ !trigger.context.isSidebarClosing
+ ) {
+ resolve(message?.id);
+ }
+ });
+ });
+ pdpTab = await BrowserTestUtils.openNewForegroundTab(gBrowser, PRODUCT_PAGE);
+ // Assert that the on-PDP message is not matched, due to targeting.
+ isnot(
+ await shoppingProductPageWithSidebarClosedMsg,
+ "FAKESPOT_CALLOUT_PDP_OPTED_IN_DEFAULT",
+ "Should not route the on-PDP message because the on-close message was seen recently"
+ );
+ BrowserTestUtils.removeTab(pdpTab);
+
+ // Now override the state so it looks like we closed the sidebar 25 hours ago.
+ let lastClosedDate = Date.now() - 25 * 60 * 60 * 1000; // 25 hours ago
+ await ASRouter.setState(state => {
+ const messageImpressions = { ...state.messageImpressions };
+ messageImpressions.FAKESPOT_CALLOUT_CLOSED_OPTED_IN_DEFAULT = [
+ lastClosedDate,
+ ];
+ ASRouter._storage.set("messageImpressions", messageImpressions);
+ return { messageImpressions };
+ });
+
+ // And open a new PDP, expecting the on-PDP message to be routed.
+ shoppingProductPageWithSidebarClosedMsg = new Promise(resolve => {
+ routeCFRMessageStub.callsFake((message, browser, trigger) => {
+ if (
+ trigger.id === "shoppingProductPageWithSidebarClosed" &&
+ !trigger.context.isSidebarClosing
+ ) {
+ resolve(message?.id);
+ }
+ });
+ });
+ pdpTab = await BrowserTestUtils.openNewForegroundTab(gBrowser, PRODUCT_PAGE);
+ // Assert that the on-PDP message is now matched, due to targeting.
+ is(
+ await shoppingProductPageWithSidebarClosedMsg,
+ "FAKESPOT_CALLOUT_PDP_OPTED_IN_DEFAULT",
+ "Should route the on-PDP message"
+ );
+ BrowserTestUtils.removeTab(pdpTab);
+
+ // Clean up.
+ await SpecialPowers.popPrefEnv();
+ await SpecialPowers.popPrefEnv();
+ await SpecialPowers.popPrefEnv();
+ await SpecialPowers.popPrefEnv();
+ await ASRouter.setState(state => {
+ const messageImpressions = { ...state.messageImpressions };
+ delete messageImpressions.FAKESPOT_CALLOUT_CLOSED_OPTED_IN_DEFAULT;
+ ASRouter._storage.set("messageImpressions", messageImpressions);
+ return { messageImpressions };
+ });
+ sandbox.restore();
+ await ASRouter._updateMessageProviders();
+ await ASRouter.loadMessagesFromAllProviders(
+ ASRouter.state.providers.filter(p => p.id === "onboarding")
+ );
+});
+
+/** Test that the correct callouts show for not-opted-in users */
+add_task(async function test_fakespot_callouts_not_opted_in_flow() {
+ await SpecialPowers.pushPrefEnv({ set: [[CFR_ENABLED_PREF, true]] });
+ let sandbox = sinon.createSandbox();
+ let routeCFRMessageStub = sandbox
+ .stub(ASRouter, "routeCFRMessage")
+ .withArgs(
+ sinon.match.any,
+ sinon.match.any,
+ sinon.match({ id: "shoppingProductPageWithSidebarClosed" })
+ );
+ await ASRouter._updateMessageProviders();
+ await ASRouter.loadMessagesFromAllProviders(
+ ASRouter.state.providers.filter(p => p.id === "onboarding")
+ );
+
+ // Reset opt-in but make the sidebar active so it appears on PDP.
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [ACTIVE_PREF, true],
+ [OPTED_IN_PREF, 0],
+ ],
+ });
+
+ // Visit a product page and wait for the sidebar to open.
+ let pdpTab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ PRODUCT_PAGE
+ );
+ let pdpBrowser = pdpTab.linkedBrowser;
+ let pdpBrowserPanel = gBrowser.getPanel(pdpBrowser);
+ let isSidebarVisible = () => {
+ let sidebar = pdpBrowserPanel.querySelector("shopping-sidebar");
+ return sidebar && BrowserTestUtils.isVisible(sidebar);
+ };
+ await BrowserTestUtils.waitForMutationCondition(
+ pdpBrowserPanel,
+ { childList: true, attributeFilter: ["hidden"] },
+ isSidebarVisible
+ );
+ ok(isSidebarVisible(), "Shopping sidebar should be open on a product page");
+
+ // Close the sidebar by deactivating the global toggle, and wait for the
+ // shoppingProductPageWithSidebarClosed trigger to fire.
+ let shoppingProductPageWithSidebarClosedMsg = new Promise(resolve => {
+ routeCFRMessageStub.callsFake((message, browser, trigger) => {
+ if (
+ trigger.id === "shoppingProductPageWithSidebarClosed" &&
+ trigger.context.isSidebarClosing
+ ) {
+ resolve(message?.id);
+ }
+ });
+ });
+ await SpecialPowers.pushPrefEnv({ set: [[ACTIVE_PREF, false]] });
+ // Assert that the message is the one we expect.
+ is(
+ await shoppingProductPageWithSidebarClosedMsg,
+ "FAKESPOT_CALLOUT_CLOSED_NOT_OPTED_IN_DEFAULT",
+ "Should route the expected message: FAKESPOT_CALLOUT_CLOSED_NOT_OPTED_IN_DEFAULT"
+ );
+ BrowserTestUtils.removeTab(pdpTab);
+
+ // Unlike the opted-in flow, at this point we should not expect to see any
+ // more callouts, because the flow ends after the on-closed callout. So we can
+ // test that FAKESPOT_CALLOUT_CLOSED_NOT_OPTED_IN_DEFAULT's targeting excludes us
+ // even if it's been 25 hours since the sidebar was closed.
+
+ // As with the opted-in flow, override the state so it looks like we closed
+ // the sidebar 25 hours ago.
+ let lastClosedDate = Date.now() - 25 * 60 * 60 * 1000; // 25 hours ago
+ await ASRouter.setState(state => {
+ const messageImpressions = { ...state.messageImpressions };
+ messageImpressions.FAKESPOT_CALLOUT_CLOSED_NOT_OPTED_IN_DEFAULT = [
+ lastClosedDate,
+ ];
+ ASRouter._storage.set("messageImpressions", messageImpressions);
+ return { messageImpressions };
+ });
+
+ // Visit a product page and wait for routeCFRMessage to fire, expecting the
+ // message to be null due to targeting.
+ shoppingProductPageWithSidebarClosedMsg = new Promise(resolve => {
+ routeCFRMessageStub.callsFake((message, browser, trigger) => {
+ if (
+ trigger.id === "shoppingProductPageWithSidebarClosed" &&
+ !trigger.context.isSidebarClosing
+ ) {
+ resolve(message?.id);
+ }
+ });
+ });
+ pdpTab = await BrowserTestUtils.openNewForegroundTab(gBrowser, PRODUCT_PAGE);
+ // Assert that the on-PDP message is not matched, due to targeting.
+ isnot(
+ await shoppingProductPageWithSidebarClosedMsg,
+ "FAKESPOT_CALLOUT_PDP_OPTED_IN_DEFAULT",
+ "Should not route the on-PDP message because the user is not opted in"
+ );
+ BrowserTestUtils.removeTab(pdpTab);
+
+ // Clean up. We don't need to verify that the frequency caps work, since
+ // that's a generic ASRouter feature.
+ await SpecialPowers.popPrefEnv();
+ await SpecialPowers.popPrefEnv();
+ await SpecialPowers.popPrefEnv();
+ await ASRouter.setState(state => {
+ const messageImpressions = { ...state.messageImpressions };
+ delete messageImpressions.FAKESPOT_CALLOUT_CLOSED_NOT_OPTED_IN_DEFAULT;
+ ASRouter._storage.set("messageImpressions", messageImpressions);
+ return { messageImpressions };
+ });
+ sandbox.restore();
+ await ASRouter._updateMessageProviders();
+ await ASRouter.loadMessagesFromAllProviders(
+ ASRouter.state.providers.filter(p => p.id === "onboarding")
+ );
+});
diff --git a/browser/components/shopping/tests/browser/browser_shopping_onboarding.js b/browser/components/shopping/tests/browser/browser_shopping_onboarding.js
new file mode 100644
index 0000000000..725ce6d8c2
--- /dev/null
+++ b/browser/components/shopping/tests/browser/browser_shopping_onboarding.js
@@ -0,0 +1,661 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ ShoppingUtils: "resource:///modules/ShoppingUtils.sys.mjs",
+});
+
+const { SpecialMessageActions } = ChromeUtils.importESModule(
+ "resource://messaging-system/lib/SpecialMessageActions.sys.mjs"
+);
+
+/**
+ * Toggle prefs involved in automatically activating the sidebar on PDPs if the
+ * user has not opted in. Onboarding should only try to auto-activate the
+ * sidebar for non-opted-in users once per session at most, no more than once
+ * per day, and no more than two times total.
+ *
+ * @param {object} states An object containing pref states to set. Leave a
+ * property undefined to ignore it.
+ * @param {boolean} [states.active] Global sidebar toggle
+ * @param {number} [states.optedIn] 2: opted out, 1: opted in, 0: not opted in
+ * @param {number} [states.lastAutoActivate] Last auto activate date in seconds
+ * @param {number} [states.autoActivateCount] Number of auto-activations (max 2)
+ * @param {boolean} [states.handledAutoActivate] True if the sidebar handled its
+ * auto-activation logic this session, preventing further auto-activations
+ */
+function setOnboardingPrefs(states = {}) {
+ if (Object.hasOwn(states, "handledAutoActivate")) {
+ ShoppingUtils.handledAutoActivate = !!states.handledAutoActivate;
+ }
+
+ if (Object.hasOwn(states, "lastAutoActivate")) {
+ Services.prefs.setIntPref(
+ "browser.shopping.experience2023.lastAutoActivate",
+ states.lastAutoActivate
+ );
+ }
+
+ if (Object.hasOwn(states, "autoActivateCount")) {
+ Services.prefs.setIntPref(
+ "browser.shopping.experience2023.autoActivateCount",
+ states.autoActivateCount
+ );
+ }
+
+ if (Object.hasOwn(states, "optedIn")) {
+ Services.prefs.setIntPref(
+ "browser.shopping.experience2023.optedIn",
+ states.optedIn
+ );
+ }
+
+ if (Object.hasOwn(states, "active")) {
+ Services.prefs.setBoolPref(
+ "browser.shopping.experience2023.active",
+ states.active
+ );
+ }
+
+ if (Object.hasOwn(states, "telemetryEnabled")) {
+ Services.prefs.setBoolPref(
+ "browser.newtabpage.activity-stream.telemetry",
+ states.telemetryEnabled
+ );
+ }
+}
+
+add_setup(async function setup() {
+ // Block on testFlushAllChildren to ensure Glean is initialized before
+ // running tests.
+ await Services.fog.testFlushAllChildren();
+ Services.fog.testResetFOG();
+ // Set all the prefs/states modified by this test to default values.
+ registerCleanupFunction(() =>
+ setOnboardingPrefs({
+ active: true,
+ optedIn: 1,
+ lastAutoActivate: 0,
+ autoActivateCount: 0,
+ handledAutoActivate: false,
+ telementryEnabled: false,
+ })
+ );
+});
+
+/**
+ * Test to check onboarding message container is rendered
+ * when user is not opted-in
+ */
+add_task(async function test_showOnboarding_notOptedIn() {
+ // OptedIn pref Value is 0 when a user hasn't opted-in
+ setOnboardingPrefs({ active: false, optedIn: 0, telemetryEnabled: true });
+
+ Services.fog.testResetFOG();
+ await Services.fog.testFlushAllChildren();
+
+ await BrowserTestUtils.withNewTab(
+ {
+ url: "about:shoppingsidebar",
+ gBrowser,
+ },
+ async browser => {
+ // Get the actor to update the product URL, since no content will render without one
+ let actor =
+ gBrowser.selectedBrowser.browsingContext.currentWindowGlobal.getExistingActor(
+ "ShoppingSidebar"
+ );
+ actor.updateProductURL("https://example.com/product/B09TJGHL5F");
+
+ await SpecialPowers.spawn(browser, [], async () => {
+ let shoppingContainer = await ContentTaskUtils.waitForCondition(
+ () => content.document.querySelector("shopping-container"),
+ "shopping-container"
+ );
+
+ let containerElem =
+ shoppingContainer.shadowRoot.getElementById("shopping-container");
+ let messageSlot = containerElem.getElementsByTagName("slot");
+
+ // Check multi-stage-message-slot used to show opt-In message is
+ // rendered inside shopping container when user optedIn pref value is 0
+ ok(messageSlot.length, `message slot element exists`);
+ is(
+ messageSlot[0].name,
+ "multi-stage-message-slot",
+ "multi-stage-message-slot showing opt-in message rendered"
+ );
+
+ ok(
+ !content.document.getElementById("multi-stage-message-root").hidden,
+ "message is shown"
+ );
+ });
+ }
+ );
+
+ if (!AppConstants.platform != "linux") {
+ await Services.fog.testFlushAllChildren();
+ const events = Glean.shopping.surfaceOnboardingDisplayed.testGetValue();
+
+ if (events) {
+ Assert.greater(events.length, 0);
+ Assert.equal(events[0].category, "shopping");
+ Assert.equal(events[0].name, "surface_onboarding_displayed");
+ } else {
+ info("Failed to get Glean value due to unknown bug. See bug 1862389.");
+ }
+ }
+});
+
+/**
+ * Test to check onboarding message is not shown for opted-in users
+ */
+add_task(async function test_hideOnboarding_optedIn() {
+ // OptedIn pref value is 1 for opted-in users
+ setOnboardingPrefs({ active: false, optedIn: 1 });
+ await BrowserTestUtils.withNewTab(
+ {
+ url: "about:shoppingsidebar",
+ gBrowser,
+ },
+ async browser => {
+ // Get the actor to update the product URL, since no content will render without one
+ let actor =
+ gBrowser.selectedBrowser.browsingContext.currentWindowGlobal.getExistingActor(
+ "ShoppingSidebar"
+ );
+ actor.updateProductURL("https://example.com/product/B09TJGHL5F");
+
+ await SpecialPowers.spawn(browser, [], async () => {
+ await ContentTaskUtils.waitForCondition(
+ () => content.document.querySelector("shopping-container"),
+ "shopping-container"
+ );
+
+ ok(
+ content.document.getElementById("multi-stage-message-root").hidden,
+ "message is hidden"
+ );
+ });
+ }
+ );
+});
+
+/**
+ * Test to check onboarding message does not show when selecting "not now"
+ *
+ * Also confirms a Glean event was triggered.
+ */
+add_task(async function test_hideOnboarding_onClose() {
+ await Services.fog.testFlushAllChildren();
+ Services.fog.testResetFOG();
+ // OptedIn pref value is 0 when a user has not opted-in
+ setOnboardingPrefs({ active: false, optedIn: 0, telemetryEnabled: true });
+ await BrowserTestUtils.withNewTab(
+ {
+ url: "about:shoppingsidebar",
+ gBrowser,
+ },
+ async browser => {
+ // Get the actor to update the product URL, since no content will render without one
+ let actor =
+ gBrowser.selectedBrowser.browsingContext.currentWindowGlobal.getExistingActor(
+ "ShoppingSidebar"
+ );
+ actor.updateProductURL("https://example.com/product/B09TJGHL5F");
+
+ await SpecialPowers.spawn(browser, [], async () => {
+ let shoppingContainer = await ContentTaskUtils.waitForCondition(
+ () => content.document.querySelector("shopping-container"),
+ "shopping-container"
+ );
+ // "Not now" button
+ let notNowButton = await ContentTaskUtils.waitForCondition(() =>
+ shoppingContainer.querySelector(".additional-cta")
+ );
+
+ notNowButton.click();
+
+ // Does not render shopping container onboarding message
+ ok(
+ !shoppingContainer.length,
+ "Shopping container element does not exist"
+ );
+ });
+ }
+ );
+
+ await Services.fog.testFlushAllChildren();
+ let events = Glean.shopping.surfaceNotNowClicked.testGetValue();
+
+ await BrowserTestUtils.waitForCondition(() => {
+ let _events = Glean.shopping.surfaceNotNowClicked.testGetValue();
+ return _events?.length > 0;
+ });
+
+ Assert.greater(events.length, 0);
+ Assert.equal(events[0].category, "shopping");
+ Assert.equal(events[0].name, "surface_not_now_clicked");
+});
+
+/**
+ * Test to check behavior when selecting 'Yes, try it to opt in to the
+ * shopping experience.
+ *
+ * Also tests if a Glean event was correctly recorded.
+ */
+add_task(async function test_onOptIn() {
+ await Services.fog.testFlushAllChildren();
+ Services.fog.testResetFOG();
+ setOnboardingPrefs({ active: false, optedIn: 0, telemetryEnabled: true });
+
+ await BrowserTestUtils.withNewTab(
+ {
+ url: "about:shoppingsidebar",
+ gBrowser,
+ },
+ async browser => {
+ await SpecialPowers.spawn(browser, [], async () => {
+ await ContentTaskUtils.waitForMutationCondition(
+ content.document,
+ { childList: true, subtree: true },
+ () => !!content.document.querySelector("shopping-container .primary")
+ );
+
+ // "Yes, try it" button
+ let primary = content.document.querySelector(
+ "shopping-container .primary"
+ );
+ primary.click();
+ });
+ }
+ );
+
+ await Services.fog.testFlushAllChildren();
+ let events = Glean.shopping.surfaceOptInClicked.testGetValue();
+
+ await BrowserTestUtils.waitForCondition(() => {
+ let _events = Glean.shopping.surfaceOptInClicked.testGetValue();
+ return _events?.length > 0;
+ });
+
+ Assert.greater(events.length, 0);
+ Assert.equal(events[0].category, "shopping");
+ Assert.equal(events[0].name, "surface_opt_in_clicked");
+});
+
+/**
+ * Helper function to click the links in the Link Paragraph.
+ */
+async function linkParagraphClickLinks() {
+ const sandbox = sinon.createSandbox();
+
+ let handleActionStub = sandbox
+ .stub(SpecialMessageActions, "handleAction")
+ .withArgs(sandbox.match({ type: "OPEN_URL" }));
+
+ let handleActionStubCalled = new Promise(resolve =>
+ handleActionStub.callsFake(resolve)
+ );
+
+ await BrowserTestUtils.withNewTab(
+ {
+ url: "about:shoppingsidebar",
+ gBrowser,
+ },
+ async browser => {
+ await SpecialPowers.spawn(browser, [], async () => {
+ await ContentTaskUtils.waitForMutationCondition(
+ content.document,
+ { childList: true, subtree: true },
+ // Can safely assume that if one of the link exists, they both do.
+ () =>
+ !!content.document.querySelector(
+ ".legal-paragraph a[value='terms_of_use']"
+ )
+ );
+
+ let termsOfUse = content.document.querySelector(
+ "shopping-container .legal-paragraph a[value='terms_of_use']"
+ );
+ termsOfUse.click();
+ });
+ }
+ );
+
+ await handleActionStubCalled;
+
+ handleActionStub.resetHistory();
+
+ handleActionStubCalled = new Promise(resolve =>
+ handleActionStub.callsFake(resolve)
+ );
+
+ await BrowserTestUtils.withNewTab(
+ {
+ url: "about:shoppingsidebar",
+ gBrowser,
+ },
+ async browser => {
+ await SpecialPowers.spawn(browser, [], async () => {
+ await ContentTaskUtils.waitForMutationCondition(
+ content.document,
+ { childList: true, subtree: true },
+ // Can safely assume that if one of the link exists, they both do.
+ () =>
+ !!content.document.querySelector(
+ ".legal-paragraph a[value='terms_of_use']"
+ )
+ );
+ let privacyPolicy = content.document.querySelector(
+ "shopping-container .legal-paragraph a[value='privacy_policy']"
+ );
+ privacyPolicy.click();
+ });
+ }
+ );
+ await handleActionStubCalled;
+
+ handleActionStub.resetHistory();
+
+ handleActionStubCalled = new Promise(resolve =>
+ handleActionStub.callsFake(resolve)
+ );
+
+ await BrowserTestUtils.withNewTab(
+ {
+ url: "about:shoppingsidebar",
+ gBrowser,
+ },
+ async browser => {
+ await SpecialPowers.spawn(browser, [], async () => {
+ await ContentTaskUtils.waitForMutationCondition(
+ content.document,
+ { childList: true, subtree: true },
+ () => content.document.querySelector(".link-paragraph a")
+ );
+ let learnMore = content.document.querySelector(
+ "shopping-container .link-paragraph a[value='learn_more']"
+ );
+ // Learn More link button.
+ learnMore.click();
+ });
+ }
+ );
+ await handleActionStubCalled;
+
+ sandbox.restore();
+}
+
+/**
+ * Test to check behavior when selecting links in the link-paragraph
+ * to opt in to the
+ * shopping experience.
+ *
+ * Also tests if a Glean event was correctly recorded.
+ */
+add_task(async function test_linkParagraph() {
+ await Services.fog.testFlushAllChildren();
+ Services.fog.testResetFOG();
+ setOnboardingPrefs({ active: false, optedIn: 0, telemetryEnabled: true });
+
+ await linkParagraphClickLinks();
+
+ await Services.fog.testFlushAllChildren();
+ let privacyEvents =
+ Glean.shopping.surfaceShowPrivacyPolicyClicked.testGetValue();
+
+ Assert.greater(privacyEvents.length, 0);
+ Assert.equal(privacyEvents[0].category, "shopping");
+ Assert.equal(privacyEvents[0].name, "surface_show_privacy_policy_clicked");
+
+ let tosEvents = Glean.shopping.surfaceShowTermsClicked.testGetValue();
+
+ Assert.greater(tosEvents.length, 0);
+ Assert.equal(tosEvents[0].category, "shopping");
+ Assert.equal(tosEvents[0].name, "surface_show_terms_clicked");
+
+ let learnMoreEvents = Glean.shopping.surfaceLearnMoreClicked.testGetValue();
+
+ Assert.greater(learnMoreEvents.length, 0);
+ Assert.equal(learnMoreEvents[0].category, "shopping");
+ Assert.equal(learnMoreEvents[0].name, "surface_learn_more_clicked");
+});
+
+add_task(async function test_onboarding_auto_activate_opt_in() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features",
+ true,
+ ],
+ ],
+ });
+ // Opt out of the feature
+ setOnboardingPrefs({
+ active: false,
+ optedIn: 0,
+ lastAutoActivate: 0,
+ autoActivateCount: 0,
+ handledAutoActivate: false,
+ });
+ ShoppingUtils.handleAutoActivateOnProduct();
+
+ // User is not opted-in, and auto-activate has not happened yet. So it should
+ // be enabled now.
+ ok(
+ Services.prefs.getBoolPref("browser.shopping.experience2023.active"),
+ "Global toggle should be activated to open the sidebar on PDPs"
+ );
+
+ // Now opt in, deactivate the global toggle, and reset the targeting prefs.
+ // The sidebar should no longer open on PDPs, since the user is opted in and
+ // the global toggle is off.
+
+ setOnboardingPrefs({
+ active: false,
+ optedIn: 1,
+ lastAutoActivate: 0,
+ autoActivateCount: 1,
+ handledAutoActivate: false,
+ });
+ ShoppingUtils.handleAutoActivateOnProduct();
+
+ ok(
+ !Services.prefs.getBoolPref("browser.shopping.experience2023.active"),
+ "Global toggle should not activate again since user is opted in"
+ );
+});
+
+add_task(async function test_onboarding_auto_activate_not_now() {
+ // Opt of the feature so it auto-activates once.
+ setOnboardingPrefs({
+ active: false,
+ optedIn: 0,
+ lastAutoActivate: 0,
+ autoActivateCount: 0,
+ handledAutoActivate: false,
+ });
+ ShoppingUtils.handleAutoActivateOnProduct();
+
+ ok(
+ Services.prefs.getBoolPref("browser.shopping.experience2023.active"),
+ "Global toggle should be activated to open the sidebar on PDPs"
+ );
+
+ // After auto-activating once, we should not auto-activate again in this
+ // session. So when we click "Not now", it should deactivate the global
+ // toggle, closing all sidebars, and sidebars should not open again on PDPs.
+ // Test that handledAutoActivate was set automatically by the previous
+ // auto-activate, and that it prevents the toggle from activating again.
+ setOnboardingPrefs({ active: false });
+ ShoppingUtils.handleAutoActivateOnProduct();
+
+ ok(
+ !Services.prefs.getBoolPref("browser.shopping.experience2023.active"),
+ "Global toggle should not activate again this session"
+ );
+
+ // There are 3 conditions for auto-activating the sidebar before opt-in:
+ // 1. The sidebar has not already been automatically set to `active` twice.
+ // 2. It's been at least 24 hours since the user last saw the sidebar because
+ // of this auto-activation behavior.
+ // 3. This method has not already been called (handledAutoActivate is false)
+ // Let's test each of these conditions, in isolation.
+
+ // Reset the auto-activate count to 0, and set the last auto-activate to never
+ // opened. Leave the handledAutoActivate flag set to true, so we can
+ // test that the sidebar auto-activate is still blocked if we already
+ // auto-activated previously this session.
+ setOnboardingPrefs({
+ active: false,
+ optedIn: 0,
+ lastAutoActivate: 0,
+ autoActivateCount: 0,
+ handledAutoActivate: true,
+ });
+ ShoppingUtils.handleAutoActivateOnProduct();
+
+ ok(
+ !Services.prefs.getBoolPref("browser.shopping.experience2023.active"),
+ "Shopping sidebar should not auto-activate if auto-activated previously this session"
+ );
+
+ // Now test that sidebar auto-activate is blocked if the last auto-activate
+ // was less than 24 hours ago.
+ setOnboardingPrefs({
+ active: false,
+ optedIn: 0,
+ lastAutoActivate: Date.now() / 1000,
+ autoActivateCount: 1,
+ handledAutoActivate: false,
+ });
+ ShoppingUtils.handleAutoActivateOnProduct();
+
+ ok(
+ !Services.prefs.getBoolPref("browser.shopping.experience2023.active"),
+ "Shopping sidebar should not auto-activate if last auto-activation was less than 24 hours ago"
+ );
+
+ // Test that auto-activate is blocked if the sidebar has been auto-activated
+ // twice already.
+ setOnboardingPrefs({
+ active: false,
+ optedIn: 0,
+ lastAutoActivate: 0,
+ autoActivateCount: 2,
+ handledAutoActivate: false,
+ });
+ ShoppingUtils.handleAutoActivateOnProduct();
+
+ ok(
+ !Services.prefs.getBoolPref("browser.shopping.experience2023.active"),
+ "Shopping sidebar should not auto-activate if it has already been auto-activated twice"
+ );
+
+ // Now test that auto-activate is unblocked if all 3 conditions are met.
+ setOnboardingPrefs({
+ active: false,
+ optedIn: 0,
+ lastAutoActivate: Date.now() / 1000 - 2 * 24 * 60 * 60, // 2 days ago
+ autoActivateCount: 1,
+ handledAutoActivate: false,
+ });
+ ShoppingUtils.handleAutoActivateOnProduct();
+
+ ok(
+ Services.prefs.getBoolPref("browser.shopping.experience2023.active"),
+ "Shopping sidebar should auto-activate a second time if all conditions are met"
+ );
+});
+
+/**
+ * Test to check onboarding message is not shown for user
+ * after a user opt-out and opt back in after seeing survey
+ */
+
+add_task(async function test_hideOnboarding_OptIn_AfterSurveySeen() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["browser.shopping.experience2023.optedIn", 0],
+ ["browser.shopping.experience2023.survey.enabled", true],
+ ["browser.shopping.experience2023.survey.hasSeen", true],
+ ["browser.shopping.experience2023.survey.pdpVisits", 5],
+ ],
+ });
+
+ await BrowserTestUtils.withNewTab(
+ {
+ url: "about:shoppingsidebar",
+ gBrowser,
+ },
+ async browser => {
+ let actor =
+ gBrowser.selectedBrowser.browsingContext.currentWindowGlobal.getExistingActor(
+ "ShoppingSidebar"
+ );
+ actor.updateProductURL("https://example.com/product/B09TJGHL5F");
+
+ await SpecialPowers.spawn(browser, [], async () => {
+ let shoppingContainer = await ContentTaskUtils.waitForCondition(
+ () => content.document.querySelector("shopping-container"),
+ "shopping-container"
+ );
+
+ ok(
+ !content.document.getElementById("multi-stage-message-root").hidden,
+ "opt-in message is shown"
+ );
+
+ const { TestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TestUtils.sys.mjs"
+ );
+
+ let optedInPrefChanged = TestUtils.waitForPrefChange(
+ "browser.shopping.experience2023.optedIn",
+ value => value === 1
+ );
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.shopping.experience2023.optedIn", 1]],
+ });
+ await optedInPrefChanged;
+ await shoppingContainer.wrappedJSObject.updateComplete;
+
+ ok(
+ content.document.getElementById("multi-stage-message-root").hidden,
+ "opt-in message is hidden"
+ );
+ await SpecialPowers.popPrefEnv();
+ });
+ }
+ );
+ await SpecialPowers.popPrefEnv();
+});
+
+add_task(async function test_deactivate_sidebar_if_user_turns_off_cfr() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "browser.newtabpage.activity-stream.asrouter.userprefs.cfr.features",
+ false,
+ ],
+ ],
+ });
+ // Opt out of the feature
+ setOnboardingPrefs({
+ active: false,
+ optedIn: 0,
+ lastAutoActivate: 0,
+ autoActivateCount: 0,
+ handledAutoActivate: false,
+ });
+ ShoppingUtils.handleAutoActivateOnProduct();
+
+ ok(
+ !Services.prefs.getBoolPref("browser.shopping.experience2023.active"),
+ "Shopping sidebar should not auto-activate if Recommended features is turned off"
+ );
+});
diff --git a/browser/components/shopping/tests/browser/browser_shopping_settings.js b/browser/components/shopping/tests/browser/browser_shopping_settings.js
new file mode 100644
index 0000000000..2508be05c7
--- /dev/null
+++ b/browser/components/shopping/tests/browser/browser_shopping_settings.js
@@ -0,0 +1,642 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests that the fakespot link has the expected url and utm parameters.
+ */
+add_task(async function test_shopping_settings_fakespot_learn_more() {
+ await BrowserTestUtils.withNewTab(
+ {
+ url: "about:shoppingsidebar",
+ gBrowser,
+ },
+ async browser => {
+ await SpecialPowers.spawn(
+ browser,
+ [MOCK_ANALYZED_PRODUCT_RESPONSE],
+ async mockData => {
+ let shoppingContainer =
+ content.document.querySelector(
+ "shopping-container"
+ ).wrappedJSObject;
+
+ let href = shoppingContainer.settingsEl.fakespotLearnMoreLinkEl.href;
+ let url = new URL(href);
+ is(url.pathname, "/our-mission");
+ is(url.origin, "https://www.fakespot.com");
+
+ let qs = url.searchParams;
+ is(qs.get("utm_source"), "review-checker");
+ is(qs.get("utm_campaign"), "fakespot-by-mozilla");
+ is(qs.get("utm_medium"), "inproduct");
+ is(qs.get("utm_term"), "core-sidebar");
+ }
+ );
+ }
+ );
+});
+
+/**
+ * Tests that the ads link has the expected utm parameters.
+ */
+add_task(async function test_shopping_settings_ads_learn_more() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.shopping.experience2023.ads.enabled", true]],
+ });
+
+ await BrowserTestUtils.withNewTab(
+ {
+ url: "about:shoppingsidebar",
+ gBrowser,
+ },
+ async browser => {
+ await SpecialPowers.spawn(
+ browser,
+ [MOCK_ANALYZED_PRODUCT_RESPONSE],
+ async mockData => {
+ let shoppingContainer =
+ content.document.querySelector(
+ "shopping-container"
+ ).wrappedJSObject;
+
+ let href = shoppingContainer.settingsEl.adsLearnMoreLinkEl.href;
+ let qs = new URL(href).searchParams;
+
+ is(qs.get("utm_campaign"), "learn-more");
+ is(qs.get("utm_medium"), "inproduct");
+ is(qs.get("utm_term"), "core-sidebar");
+ }
+ );
+ }
+ );
+});
+
+/**
+ * Tests that the settings component is rendered as expected when
+ * `browser.shopping.experience2023.ads.enabled` is true.
+ */
+add_task(async function test_shopping_settings_ads_enabled() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.shopping.experience2023.ads.enabled", true]],
+ });
+
+ await BrowserTestUtils.withNewTab(
+ {
+ url: "about:shoppingsidebar",
+ gBrowser,
+ },
+ async browser => {
+ await SpecialPowers.spawn(
+ browser,
+ [MOCK_ANALYZED_PRODUCT_RESPONSE],
+ async mockData => {
+ let shoppingContainer =
+ content.document.querySelector(
+ "shopping-container"
+ ).wrappedJSObject;
+
+ shoppingContainer.data = Cu.cloneInto(mockData, content);
+ // Note (Bug 1876878): Until we have proper mocks of data passed from ShoppingSidebarChild,
+ // hardcode `adsEnabled` to be passed to settings.mjs so that we can test
+ // toggle for ad visibility.
+ shoppingContainer.adsEnabled = true;
+ await shoppingContainer.updateComplete;
+
+ let shoppingSettings = shoppingContainer.settingsEl;
+ ok(shoppingSettings, "Got the shopping-settings element");
+
+ let adsToggle = shoppingSettings.recommendationsToggleEl;
+ ok(adsToggle, "There should be an ads toggle");
+
+ let optOutButton = shoppingSettings.optOutButtonEl;
+ ok(optOutButton, "There should be an opt-out button");
+ }
+ );
+ }
+ );
+
+ await SpecialPowers.popPrefEnv();
+});
+
+/**
+ * Tests that the settings component is rendered as expected when
+ * `browser.shopping.experience2023.ads.enabled` is false.
+ */
+add_task(async function test_shopping_settings_ads_disabled() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.shopping.experience2023.ads.enabled", false]],
+ });
+
+ await BrowserTestUtils.withNewTab(
+ {
+ url: "about:shoppingsidebar",
+ gBrowser,
+ },
+ async browser => {
+ let shoppingSettings = await getSettingsDetails(
+ browser,
+ MOCK_POPULATED_DATA
+ );
+ ok(shoppingSettings.settingsEl, "Got the shopping-settings element");
+
+ let adsToggle = shoppingSettings.recommendationsToggleEl;
+ ok(!adsToggle, "There should be no ads toggle");
+
+ let optOutButton = shoppingSettings.optOutButtonEl;
+ ok(optOutButton, "There should be an opt-out button");
+ }
+ );
+
+ await SpecialPowers.popPrefEnv();
+});
+
+/**
+ * Tests that the shopping-settings ads toggle and ad render correctly, even with
+ * multiple tabs. If `browser.shopping.experience2023.ads.userEnabled`
+ * is false in one tab, it should be false for all other tabs with the shopping sidebar open.
+ */
+add_task(async function test_settings_toggle_ad_and_multiple_tabs() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["browser.shopping.experience2023.ads.enabled", true],
+ ["browser.shopping.experience2023.ads.userEnabled", true],
+ ],
+ });
+
+ // Tab 1 - ad is visible at first and then toggle is selected to set ads.userEnabled to false.
+ let tab1 = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "about:shoppingsidebar"
+ );
+ let browser1 = tab1.linkedBrowser;
+
+ let mockArgs = {
+ mockData: MOCK_ANALYZED_PRODUCT_RESPONSE,
+ mockRecommendationData: MOCK_RECOMMENDED_ADS_RESPONSE,
+ };
+ await SpecialPowers.spawn(browser1, [mockArgs], async args => {
+ const { mockData, mockRecommendationData } = args;
+ let shoppingContainer =
+ content.document.querySelector("shopping-container").wrappedJSObject;
+
+ let adVisiblePromise = ContentTaskUtils.waitForCondition(() => {
+ return (
+ shoppingContainer.recommendedAdEl &&
+ ContentTaskUtils.isVisible(shoppingContainer.recommendedAdEl)
+ );
+ }, "Waiting for recommended-ad to be visible");
+
+ shoppingContainer.data = Cu.cloneInto(mockData, content);
+ shoppingContainer.recommendationData = Cu.cloneInto(
+ mockRecommendationData,
+ content
+ );
+ // Note (Bug 1876878): Until we have proper mocks of data passed from ShoppingSidebarChild,
+ // hardcode `adsEnabled` and `adsEnabledByUser` so that we can test ad visibility.
+ shoppingContainer.adsEnabled = true;
+ shoppingContainer.adsEnabledByUser = true;
+
+ await shoppingContainer.updateComplete;
+ await adVisiblePromise;
+
+ let adEl = shoppingContainer.recommendedAdEl;
+ await adEl.updateComplete;
+ is(
+ adEl.priceEl.textContent,
+ "$" + mockRecommendationData[0].price,
+ "Price is shown correctly"
+ );
+ is(
+ adEl.linkEl.title,
+ mockRecommendationData[0].name,
+ "Title in link is shown correctly"
+ );
+ is(
+ adEl.linkEl.href,
+ mockRecommendationData[0].url,
+ "URL for link is correct"
+ );
+ is(
+ adEl.ratingEl.rating,
+ mockRecommendationData[0].adjusted_rating,
+ "MozFiveStar rating is shown correctly"
+ );
+ is(
+ adEl.letterGradeEl.letter,
+ mockRecommendationData[0].grade,
+ "LetterGrade letter is shown correctly"
+ );
+
+ let shoppingSettings = shoppingContainer.settingsEl;
+ ok(shoppingSettings, "Got the shopping-settings element");
+
+ let adsToggle = shoppingSettings.recommendationsToggleEl;
+ ok(adsToggle, "There should be a toggle");
+ ok(adsToggle.hasAttribute("pressed"), "Toggle should have enabled state");
+
+ ok(
+ SpecialPowers.getBoolPref(
+ "browser.shopping.experience2023.ads.userEnabled"
+ ),
+ "ads userEnabled pref should be true"
+ );
+
+ let adRemovedPromise = ContentTaskUtils.waitForCondition(() => {
+ return !shoppingContainer.recommendedAdEl;
+ }, "Waiting for recommended-ad to be removed");
+
+ adsToggle.click();
+
+ await adRemovedPromise;
+
+ ok(!adsToggle.hasAttribute("pressed"), "Toggle should have disabled state");
+ ok(
+ !SpecialPowers.getBoolPref(
+ "browser.shopping.experience2023.ads.userEnabled"
+ ),
+ "ads userEnabled pref should be false"
+ );
+ });
+
+ // Tab 2 - ads.userEnabled should still be false and ad should not be visible.
+ let tab2 = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "about:shoppingsidebar"
+ );
+ let browser2 = tab2.linkedBrowser;
+
+ await SpecialPowers.spawn(browser2, [mockArgs], async args => {
+ const { mockData, mockRecommendationData } = args;
+ let shoppingContainer =
+ content.document.querySelector("shopping-container").wrappedJSObject;
+
+ shoppingContainer.data = Cu.cloneInto(mockData, content);
+ shoppingContainer.recommendationData = Cu.cloneInto(
+ mockRecommendationData,
+ content
+ );
+ // Note (Bug 1876878): Until we have proper mocks of data passed from ShoppingSidebarChild,
+ // hardcode `adsEnabled` so that we can test ad visibility.
+ shoppingContainer.adsEnabled = true;
+
+ await shoppingContainer.updateComplete;
+
+ ok(
+ !shoppingContainer.recommendedAdEl,
+ "There should be no ads in the new tab"
+ );
+ ok(
+ !SpecialPowers.getBoolPref(
+ "browser.shopping.experience2023.ads.userEnabled"
+ ),
+ "ads userEnabled pref should be false"
+ );
+ });
+
+ BrowserTestUtils.removeTab(tab1);
+ BrowserTestUtils.removeTab(tab2);
+
+ await SpecialPowers.popPrefEnv();
+ await SpecialPowers.popPrefEnv();
+});
+
+/**
+ * Tests that the settings component is rendered as expected when
+ * `browser.shopping.experience2023.autoOpen.enabled` is false.
+ */
+add_task(async function test_shopping_settings_experiment_auto_open_disabled() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.shopping.experience2023.autoOpen.enabled", false]],
+ });
+
+ await BrowserTestUtils.withNewTab(
+ {
+ url: PRODUCT_TEST_URL,
+ gBrowser,
+ },
+ async browser => {
+ let sidebar = gBrowser
+ .getPanel(browser)
+ .querySelector("shopping-sidebar");
+ await promiseSidebarUpdated(sidebar, PRODUCT_TEST_URL);
+
+ await SpecialPowers.spawn(
+ sidebar.querySelector("browser"),
+ [MOCK_ANALYZED_PRODUCT_RESPONSE],
+ async mockData => {
+ let shoppingContainer =
+ content.document.querySelector(
+ "shopping-container"
+ ).wrappedJSObject;
+
+ shoppingContainer.data = Cu.cloneInto(mockData, content);
+ await shoppingContainer.updateComplete;
+
+ let shoppingSettings = shoppingContainer.settingsEl;
+ ok(shoppingSettings, "Got the shopping-settings element");
+ ok(
+ !shoppingSettings.wrapperEl.className.includes(
+ "shopping-settings-auto-open-ui-enabled"
+ ),
+ "Settings card should not have a special classname with autoOpen pref disabled"
+ );
+ is(
+ shoppingSettings.shoppingCardEl?.type,
+ "accordion",
+ "shopping-card type should be accordion"
+ );
+
+ /* Verify control treatment UI */
+ ok(
+ !shoppingSettings.autoOpenToggleEl,
+ "There should be no auto-open toggle"
+ );
+ ok(
+ !shoppingSettings.autoOpenToggleDescriptionEl,
+ "There should be no description for the auto-open toggle"
+ );
+ ok(!shoppingSettings.dividerEl, "There should be no divider");
+ ok(
+ !shoppingSettings.sidebarEnabledStateEl,
+ "There should be no message about the sidebar active state"
+ );
+
+ ok(
+ shoppingSettings.optOutButtonEl,
+ "There should be an opt-out button"
+ );
+ }
+ );
+ }
+ );
+
+ await SpecialPowers.popPrefEnv();
+});
+
+/**
+ * Tests that the settings component is rendered as expected when
+ * `browser.shopping.experience2023.autoOpen.enabled` is true and
+ * `browser.shopping.experience2023.ads.enabled is true`.
+ */
+add_task(
+ async function test_shopping_settings_experiment_auto_open_enabled_with_ads() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["browser.shopping.experience2023.autoOpen.enabled", true],
+ ["browser.shopping.experience2023.autoOpen.userEnabled", true],
+ ["browser.shopping.experience2023.ads.enabled", true],
+ ],
+ });
+
+ await BrowserTestUtils.withNewTab(
+ {
+ url: PRODUCT_TEST_URL,
+ gBrowser,
+ },
+ async browser => {
+ let sidebar = gBrowser
+ .getPanel(browser)
+ .querySelector("shopping-sidebar");
+ await promiseSidebarUpdated(sidebar, PRODUCT_TEST_URL);
+
+ await SpecialPowers.spawn(
+ sidebar.querySelector("browser"),
+ [MOCK_ANALYZED_PRODUCT_RESPONSE],
+ async mockData => {
+ let shoppingContainer =
+ content.document.querySelector(
+ "shopping-container"
+ ).wrappedJSObject;
+
+ await shoppingContainer.updateComplete;
+
+ let shoppingSettings = shoppingContainer.settingsEl;
+ ok(shoppingSettings, "Got the shopping-settings element");
+ ok(
+ shoppingSettings.wrapperEl.className.includes(
+ "shopping-settings-auto-open-ui-enabled"
+ ),
+ "Settings card should have a special classname with autoOpen pref enabled"
+ );
+ is(
+ shoppingSettings.shoppingCardEl?.type,
+ "",
+ "shopping-card type should be default"
+ );
+
+ ok(
+ shoppingSettings.recommendationsToggleEl,
+ "There should be an ads toggle"
+ );
+
+ /* Verify auto-open experiment UI */
+ ok(
+ shoppingSettings.autoOpenToggleEl,
+ "There should be an auto-open toggle"
+ );
+ ok(
+ shoppingSettings.autoOpenToggleDescriptionEl,
+ "There should be a description for the auto-open toggle"
+ );
+ ok(shoppingSettings.dividerEl, "There should be a divider");
+ ok(
+ shoppingSettings.sidebarEnabledStateEl,
+ "There should be a message about the sidebar active state"
+ );
+
+ ok(
+ shoppingSettings.optOutButtonEl,
+ "There should be an opt-out button"
+ );
+ }
+ );
+ }
+ );
+
+ await SpecialPowers.popPrefEnv();
+ await SpecialPowers.popPrefEnv();
+ await SpecialPowers.popPrefEnv();
+ }
+);
+
+/**
+ * Tests that the settings component is rendered as expected when
+ * `browser.shopping.experience2023.autoOpen.enabled` is true and
+ * `browser.shopping.experience2023.ads.enabled is false`.
+ */
+add_task(
+ async function test_shopping_settings_experiment_auto_open_enabled_no_ads() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["browser.shopping.experience2023.autoOpen.enabled", true],
+ ["browser.shopping.experience2023.autoOpen.userEnabled", true],
+ ["browser.shopping.experience2023.ads.enabled", false],
+ ],
+ });
+
+ await BrowserTestUtils.withNewTab(
+ {
+ url: PRODUCT_TEST_URL,
+ gBrowser,
+ },
+ async browser => {
+ let sidebar = gBrowser
+ .getPanel(browser)
+ .querySelector("shopping-sidebar");
+ await promiseSidebarUpdated(sidebar, PRODUCT_TEST_URL);
+
+ await SpecialPowers.spawn(
+ sidebar.querySelector("browser"),
+ [MOCK_ANALYZED_PRODUCT_RESPONSE],
+ async mockData => {
+ let shoppingContainer =
+ content.document.querySelector(
+ "shopping-container"
+ ).wrappedJSObject;
+
+ await shoppingContainer.updateComplete;
+
+ let shoppingSettings = shoppingContainer.settingsEl;
+ ok(shoppingSettings, "Got the shopping-settings element");
+ ok(
+ shoppingSettings.wrapperEl.className.includes(
+ "shopping-settings-auto-open-ui-enabled"
+ ),
+ "Settings card should have a special classname with autoOpen pref enabled"
+ );
+ is(
+ shoppingSettings.shoppingCardEl?.type,
+ "",
+ "shopping-card type should be default"
+ );
+
+ ok(
+ !shoppingSettings.recommendationsToggleEl,
+ "There should be no ads toggle"
+ );
+
+ /* Verify auto-open experiment UI */
+ ok(
+ shoppingSettings.autoOpenToggleEl,
+ "There should be an auto-open toggle"
+ );
+ ok(
+ shoppingSettings.autoOpenToggleDescriptionEl,
+ "There should be a description for the auto-open toggle"
+ );
+ ok(shoppingSettings.dividerEl, "There should be a divider");
+ ok(
+ shoppingSettings.sidebarEnabledStateEl,
+ "There should be a message about the sidebar active state"
+ );
+
+ ok(
+ shoppingSettings.optOutButtonEl,
+ "There should be an opt-out button"
+ );
+ }
+ );
+ }
+ );
+
+ await SpecialPowers.popPrefEnv();
+ await SpecialPowers.popPrefEnv();
+ await SpecialPowers.popPrefEnv();
+ }
+);
+
+/**
+ * Tests that auto-open toggle state and autoOpen.userEnabled pref update correctly.
+ */
+add_task(async function test_settings_auto_open_toggle() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["browser.shopping.experience2023.autoOpen.enabled", true],
+ ["browser.shopping.experience2023.autoOpen.userEnabled", true],
+ ],
+ });
+
+ let tab1 = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ PRODUCT_TEST_URL
+ );
+ let browser = tab1.linkedBrowser;
+
+ let mockArgs = {
+ mockData: MOCK_ANALYZED_PRODUCT_RESPONSE,
+ };
+
+ let sidebar = gBrowser.getPanel(browser).querySelector("shopping-sidebar");
+ await promiseSidebarUpdated(sidebar, PRODUCT_TEST_URL);
+
+ await SpecialPowers.spawn(
+ sidebar.querySelector("browser"),
+ [mockArgs],
+ async args => {
+ const { mockData } = args;
+ let shoppingContainer =
+ content.document.querySelector("shopping-container").wrappedJSObject;
+
+ shoppingContainer.data = Cu.cloneInto(mockData, content);
+ await shoppingContainer.updateComplete;
+
+ let shoppingSettings = shoppingContainer.settingsEl;
+ ok(shoppingSettings, "Got the shopping-settings element");
+
+ let autoOpenToggle = shoppingSettings.autoOpenToggleEl;
+ ok(autoOpenToggle, "There should be an auto-open toggle");
+ ok(
+ autoOpenToggle.hasAttribute("pressed"),
+ "Toggle should have enabled state"
+ );
+
+ let toggleStateChangePromise = ContentTaskUtils.waitForCondition(() => {
+ return !autoOpenToggle.hasAttribute("pressed");
+ }, "Waiting for auto-open toggle state to be disabled");
+
+ autoOpenToggle.click();
+
+ await toggleStateChangePromise;
+
+ ok(
+ !SpecialPowers.getBoolPref(
+ "browser.shopping.experience2023.autoOpen.userEnabled"
+ ),
+ "autoOpen.userEnabled pref should be false"
+ );
+ ok(
+ SpecialPowers.getBoolPref(
+ "browser.shopping.experience2023.autoOpen.enabled"
+ ),
+ "autoOpen.enabled pref should still be true"
+ );
+ ok(
+ !SpecialPowers.getBoolPref("browser.shopping.experience2023.active"),
+ "Sidebar active pref should be false after pressing auto-open toggle to close the sidebar"
+ );
+
+ // Now try updating the pref directly to see if toggle will change state immediately
+ await SpecialPowers.popPrefEnv();
+ toggleStateChangePromise = ContentTaskUtils.waitForCondition(() => {
+ return autoOpenToggle.hasAttribute("pressed");
+ }, "Waiting for auto-open toggle to be enabled");
+
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["browser.shopping.experience2023.autoOpen.userEnabled", true],
+ ["browser.shopping.experience2023.active", true],
+ ],
+ });
+
+ await toggleStateChangePromise;
+ }
+ );
+
+ BrowserTestUtils.removeTab(tab1);
+
+ await SpecialPowers.popPrefEnv();
+ await SpecialPowers.popPrefEnv();
+ await SpecialPowers.popPrefEnv();
+});
diff --git a/browser/components/shopping/tests/browser/browser_shopping_sidebar.js b/browser/components/shopping/tests/browser/browser_shopping_sidebar.js
new file mode 100644
index 0000000000..31cbc6d732
--- /dev/null
+++ b/browser/components/shopping/tests/browser/browser_shopping_sidebar.js
@@ -0,0 +1,66 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const SHOPPING_SIDEBAR_WIDTH_PREF =
+ "browser.shopping.experience2023.sidebarWidth";
+
+add_task(async function test_sidebar_opens_correct_size() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["toolkit.shopping.ohttpRelayURL", ""],
+ ["toolkit.shopping.ohttpConfigURL", ""],
+ ["browser.shopping.experience2023.active", true],
+ [SHOPPING_SIDEBAR_WIDTH_PREF, 0],
+ ],
+ });
+
+ let tab = await BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ opening: PRODUCT_TEST_URL,
+ });
+
+ let browserPanel = gBrowser.getPanel(tab.linkedBrowser);
+ let sidebar = browserPanel.querySelector("shopping-sidebar");
+
+ await TestUtils.waitForCondition(() => sidebar.scrollWidth === 320);
+
+ is(sidebar.scrollWidth, 320, "Shopping sidebar should default to 320px");
+
+ let prefChangedPromise = TestUtils.waitForPrefChange(
+ SHOPPING_SIDEBAR_WIDTH_PREF
+ );
+ sidebar.style.width = "345px";
+ await TestUtils.waitForCondition(() => sidebar.scrollWidth === 345);
+ await prefChangedPromise;
+
+ let shoppingButton = document.getElementById("shopping-sidebar-button");
+ shoppingButton.click();
+ await BrowserTestUtils.waitForMutationCondition(
+ shoppingButton,
+ {
+ attributeFilter: ["shoppingsidebaropen"],
+ },
+ () => shoppingButton.getAttribute("shoppingsidebaropen") == "false"
+ );
+
+ shoppingButton.click();
+ await BrowserTestUtils.waitForMutationCondition(
+ shoppingButton,
+ {
+ attributeFilter: ["shoppingsidebaropen"],
+ },
+ () => shoppingButton.getAttribute("shoppingsidebaropen") == "true"
+ );
+
+ await TestUtils.waitForCondition(() => sidebar.scrollWidth === 345);
+
+ is(
+ sidebar.scrollWidth,
+ 345,
+ "Shopping sidebar should open to previous set width of 345"
+ );
+
+ gBrowser.removeTab(tab);
+});
diff --git a/browser/components/shopping/tests/browser/browser_shopping_survey.js b/browser/components/shopping/tests/browser/browser_shopping_survey.js
new file mode 100644
index 0000000000..aebe6e9dcf
--- /dev/null
+++ b/browser/components/shopping/tests/browser/browser_shopping_survey.js
@@ -0,0 +1,337 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const currentTime = Date.now() / 1000;
+const time25HrsAgo = currentTime - 25 * 60 * 60;
+const time1HrAgo = currentTime - 1 * 60 * 60;
+
+add_task(async function test_setup() {
+ await BrowserTestUtils.withNewTab(
+ {
+ url: "about:shoppingsidebar",
+ gBrowser,
+ },
+ async browser => {
+ await SpecialPowers.spawn(browser, [], async () => {
+ let childActor = content.windowGlobalChild.getExistingActor(
+ "AboutWelcomeShopping"
+ );
+ childActor.resetChildStates();
+ });
+ }
+ );
+});
+
+/**
+ * Test to check survey renders when show survey conditions are met
+ */
+add_task(async function test_showSurvey_Enabled() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["browser.shopping.experience2023.optedIn", 1],
+ ["browser.shopping.experience2023.survey.enabled", true],
+ ["browser.shopping.experience2023.survey.hasSeen", false],
+ ["browser.shopping.experience2023.survey.pdpVisits", 5],
+ ["browser.shopping.experience2023.survey.optedInTime", time25HrsAgo],
+ ],
+ });
+ await BrowserTestUtils.withNewTab(
+ {
+ url: "about:shoppingsidebar",
+ gBrowser,
+ },
+ async browser => {
+ await SpecialPowers.spawn(
+ browser,
+ [MOCK_ANALYZED_PRODUCT_RESPONSE],
+ async mockData => {
+ const { TestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TestUtils.sys.mjs"
+ );
+ let surveyPrefChanged = TestUtils.waitForPrefChange(
+ "browser.shopping.experience2023.survey.hasSeen"
+ );
+ let shoppingContainer =
+ content.document.querySelector(
+ "shopping-container"
+ ).wrappedJSObject;
+ shoppingContainer.data = Cu.cloneInto(mockData, content);
+
+ // Manually send data update event, as it isn't set due to the lack of mock APIs.
+ // TODO: Support for the mocks will be added in Bug 1853474.
+ let mockObj = {
+ data: mockData,
+ productUrl: "https://example.com/product/1234",
+ };
+ let evt = new content.CustomEvent("Update", {
+ bubbles: true,
+ detail: Cu.cloneInto(mockObj, content),
+ });
+ content.document.dispatchEvent(evt);
+
+ await shoppingContainer.updateComplete;
+ await surveyPrefChanged;
+
+ let childActor = content.windowGlobalChild.getExistingActor(
+ "AboutWelcomeShopping"
+ );
+
+ ok(childActor.surveyEnabled, "Survey is Enabled");
+
+ let surveyScreen = await ContentTaskUtils.waitForCondition(
+ () =>
+ content.document.querySelector(
+ "shopping-container .screen.SHOPPING_MICROSURVEY_SCREEN_1"
+ ),
+ "survey-screen"
+ );
+
+ ok(surveyScreen, "Survey screen is rendered");
+
+ ok(
+ childActor.showMicroSurvey,
+ "Show Survey targeting conditions met"
+ );
+ Assert.strictEqual(
+ content.document
+ .getElementById("steps")
+ .getAttribute("data-l10n-id"),
+ "shopping-onboarding-welcome-steps-indicator-label",
+ "Steps indicator has appropriate fluent ID"
+ );
+ ok(
+ !content.document.getElementById("multi-stage-message-root").hidden,
+ "Survey Message container is shown"
+ );
+ ok(
+ content.document.querySelector(".dismiss-button"),
+ "Dismiss button is shown"
+ );
+
+ let survey_seen_status = Services.prefs.getBoolPref(
+ "browser.shopping.experience2023.survey.hasSeen",
+ false
+ );
+ ok(survey_seen_status, "Survey pref state is updated");
+ childActor.resetChildStates();
+ }
+ );
+ }
+ );
+ await SpecialPowers.popPrefEnv();
+});
+
+/**
+ * Test to check survey is hidden when survey enabled pref is false
+ */
+add_task(async function test_showSurvey_Disabled() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["browser.shopping.experience2023.optedIn", 1],
+ ["browser.shopping.experience2023.survey.enabled", false],
+ ["browser.shopping.experience2023.survey.hasSeen", false],
+ ["browser.shopping.experience2023.survey.pdpVisits", 5],
+ ["browser.shopping.experience2023.survey.optedInTime", time25HrsAgo],
+ ],
+ });
+ await BrowserTestUtils.withNewTab(
+ {
+ url: "about:shoppingsidebar",
+ gBrowser,
+ },
+ async browser => {
+ await SpecialPowers.spawn(
+ browser,
+ [MOCK_ANALYZED_PRODUCT_RESPONSE],
+ async mockData => {
+ let shoppingContainer =
+ content.document.querySelector(
+ "shopping-container"
+ ).wrappedJSObject;
+ shoppingContainer.data = Cu.cloneInto(mockData, content);
+
+ // Manually send data update event, as it isn't set due to the lack of mock APIs.
+ // TODO: Support for the mocks will be added in Bug 1853474.
+ let mockObj = {
+ data: mockData,
+ productUrl: "https://example.com/product/1234",
+ };
+ let evt = new content.CustomEvent("Update", {
+ bubbles: true,
+ detail: Cu.cloneInto(mockObj, content),
+ });
+ content.document.dispatchEvent(evt);
+
+ await shoppingContainer.updateComplete;
+
+ let childActor = content.windowGlobalChild.getExistingActor(
+ "AboutWelcomeShopping"
+ );
+
+ ok(!childActor.surveyEnabled, "Survey is disabled");
+
+ let surveyScreen = content.document.querySelector(
+ "shopping-container .screen.SHOPPING_MICROSURVEY_SCREEN_1"
+ );
+
+ ok(!surveyScreen, "Survey screen is not rendered");
+ ok(
+ !childActor.showMicroSurvey,
+ "Show Survey targeting conditions are not met"
+ );
+ ok(
+ content.document.getElementById("multi-stage-message-root").hidden,
+ "Survey Message container is hidden"
+ );
+
+ childActor.resetChildStates();
+ }
+ );
+ }
+ );
+ await SpecialPowers.popPrefEnv();
+});
+
+/**
+ * Test to check survey display logic respects 24 hours after Opt-in rule
+ */
+add_task(async function test_24_hr_since_optin_rule() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["browser.shopping.experience2023.optedIn", 1],
+ ["browser.shopping.experience2023.survey.enabled", true],
+ ["browser.shopping.experience2023.survey.hasSeen", false],
+ ["browser.shopping.experience2023.survey.pdpVisits", 5],
+ ["browser.shopping.experience2023.survey.optedInTime", time1HrAgo],
+ ],
+ });
+ await BrowserTestUtils.withNewTab(
+ {
+ url: "about:shoppingsidebar",
+ gBrowser,
+ },
+ async browser => {
+ await SpecialPowers.spawn(
+ browser,
+ [MOCK_ANALYZED_PRODUCT_RESPONSE],
+ async mockData => {
+ let shoppingContainer =
+ content.document.querySelector(
+ "shopping-container"
+ ).wrappedJSObject;
+ shoppingContainer.data = Cu.cloneInto(mockData, content);
+ await shoppingContainer.updateComplete;
+
+ let childActor = content.windowGlobalChild.getExistingActor(
+ "AboutWelcomeShopping"
+ );
+
+ let surveyScreen = content.document.querySelector(
+ "shopping-container .screen.SHOPPING_MICROSURVEY_SCREEN_1"
+ );
+
+ ok(!surveyScreen, "Survey screen is not rendered");
+ ok(
+ !childActor.showMicroSurvey,
+ "Show Survey 24 hours after opt in conditions are not met"
+ );
+ ok(
+ content.document.getElementById("multi-stage-message-root").hidden,
+ "Survey Message container is hidden"
+ );
+
+ childActor.resetChildStates();
+ }
+ );
+ }
+ );
+ await SpecialPowers.popPrefEnv();
+});
+
+add_task(async function test_confirmation_screen() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["browser.shopping.experience2023.optedIn", 1],
+ ["browser.shopping.experience2023.survey.enabled", true],
+ ["browser.shopping.experience2023.survey.hasSeen", false],
+ ["browser.shopping.experience2023.survey.pdpVisits", 5],
+ ["browser.shopping.experience2023.survey.optedInTime", time25HrsAgo],
+ ],
+ });
+ await BrowserTestUtils.withNewTab(
+ {
+ url: "about:shoppingsidebar",
+ gBrowser,
+ },
+ async browser => {
+ await SpecialPowers.spawn(
+ browser,
+ [MOCK_ANALYZED_PRODUCT_RESPONSE],
+ async mockData => {
+ async function clickVisibleElement(selector) {
+ await ContentTaskUtils.waitForCondition(
+ () => content.document.querySelector(selector),
+ `waiting for selector ${selector}`,
+ 200, // interval
+ 100 // maxTries
+ );
+ content.document.querySelector(selector).click();
+ }
+
+ let shoppingContainer =
+ content.document.querySelector(
+ "shopping-container"
+ ).wrappedJSObject;
+ shoppingContainer.data = Cu.cloneInto(mockData, content);
+
+ // Manually send data update event, as it isn't set due to the lack of mock APIs.
+ // TODO: Support for the mocks will be added in Bug 1853474.
+ let mockObj = {
+ data: mockData,
+ productUrl: "https://example.com/product/1234",
+ };
+ let evt = new content.CustomEvent("Update", {
+ bubbles: true,
+ detail: Cu.cloneInto(mockObj, content),
+ });
+ content.document.dispatchEvent(evt);
+
+ await shoppingContainer.updateComplete;
+
+ let surveyScreen1 = await ContentTaskUtils.waitForCondition(
+ () =>
+ content.document.querySelector(
+ "shopping-container .screen.SHOPPING_MICROSURVEY_SCREEN_1"
+ ),
+ "survey-screen"
+ );
+
+ ok(surveyScreen1, "Survey screen 1 is rendered");
+ clickVisibleElement("#radio-1");
+ clickVisibleElement("button.primary");
+
+ let surveyScreen2 = await ContentTaskUtils.waitForCondition(
+ () =>
+ content.document.querySelector(
+ "shopping-container .screen.SHOPPING_MICROSURVEY_SCREEN_2"
+ ),
+ "survey-screen"
+ );
+ ok(surveyScreen2, "Survey screen 2 is rendered");
+ clickVisibleElement("#radio-1");
+ clickVisibleElement("button.primary");
+
+ let confirmationScreen = await ContentTaskUtils.waitForCondition(
+ () => content.document.querySelector("shopping-message-bar"),
+ "survey-screen"
+ );
+
+ ok(confirmationScreen, "Survey confirmation screen is rendered");
+ }
+ );
+ }
+ );
+ await SpecialPowers.popPrefEnv();
+});
diff --git a/browser/components/shopping/tests/browser/browser_shopping_urlbar.js b/browser/components/shopping/tests/browser/browser_shopping_urlbar.js
new file mode 100644
index 0000000000..9eb396e846
--- /dev/null
+++ b/browser/components/shopping/tests/browser/browser_shopping_urlbar.js
@@ -0,0 +1,427 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const CONTENT_PAGE = "https://example.com";
+const PRODUCT_PAGE = "https://example.com/product/B09TJGHL5F";
+
+add_task(async function test_button_hidden() {
+ await BrowserTestUtils.withNewTab(CONTENT_PAGE, async function (browser) {
+ let shoppingButton = document.getElementById("shopping-sidebar-button");
+ ok(
+ BrowserTestUtils.isHidden(shoppingButton),
+ "Shopping Button should be hidden on a content page"
+ );
+ });
+});
+
+add_task(async function test_button_shown() {
+ await BrowserTestUtils.withNewTab(PRODUCT_PAGE, async function (browser) {
+ let shoppingButton = document.getElementById("shopping-sidebar-button");
+ ok(
+ BrowserTestUtils.isVisible(shoppingButton),
+ "Shopping Button should be visible on a product page"
+ );
+ });
+});
+
+// Button is hidden on navigation to a content page
+add_task(async function test_button_changes_with_location() {
+ await BrowserTestUtils.withNewTab(CONTENT_PAGE, async function (browser) {
+ let shoppingButton = document.getElementById("shopping-sidebar-button");
+ ok(
+ BrowserTestUtils.isHidden(shoppingButton),
+ "Shopping Button should be hidden on a content page"
+ );
+ BrowserTestUtils.startLoadingURIString(browser, PRODUCT_PAGE);
+ await BrowserTestUtils.browserLoaded(browser);
+ ok(
+ BrowserTestUtils.isVisible(shoppingButton),
+ "Shopping Button should be visible on a product page"
+ );
+ BrowserTestUtils.startLoadingURIString(browser, CONTENT_PAGE);
+ await BrowserTestUtils.browserLoaded(browser);
+ ok(
+ BrowserTestUtils.isHidden(shoppingButton),
+ "Shopping Button should be hidden on a content page"
+ );
+ });
+});
+
+add_task(async function test_button_active() {
+ Services.prefs.setBoolPref("browser.shopping.experience2023.active", true);
+
+ await BrowserTestUtils.withNewTab(PRODUCT_PAGE, async function (browser) {
+ let shoppingButton = document.getElementById("shopping-sidebar-button");
+ Assert.equal(
+ shoppingButton.getAttribute("shoppingsidebaropen"),
+ "true",
+ "Shopping Button should be active when sidebar is open"
+ );
+ });
+});
+
+add_task(async function test_button_inactive() {
+ Services.prefs.setBoolPref("browser.shopping.experience2023.active", false);
+
+ await BrowserTestUtils.withNewTab(PRODUCT_PAGE, async function (browser) {
+ let shoppingButton = document.getElementById("shopping-sidebar-button");
+ Assert.equal(
+ shoppingButton.getAttribute("shoppingsidebaropen"),
+ "false",
+ "Shopping Button should be inactive when sidebar is closed"
+ );
+ });
+});
+
+// Switching Tabs shows and hides the button
+add_task(async function test_button_changes_with_tabswitch() {
+ Services.prefs.setBoolPref("browser.shopping.experience2023.active", true);
+
+ let shoppingButton = document.getElementById("shopping-sidebar-button");
+
+ let productTab = await BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ opening: PRODUCT_PAGE,
+ });
+ let contentTab = await BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ opening: CONTENT_PAGE,
+ });
+
+ await BrowserTestUtils.switchTab(gBrowser, productTab);
+ ok(
+ BrowserTestUtils.isVisible(shoppingButton),
+ "Shopping Button should be visible on a product page"
+ );
+
+ await BrowserTestUtils.switchTab(gBrowser, contentTab);
+ ok(
+ BrowserTestUtils.isHidden(shoppingButton),
+ "Shopping Button should be hidden on a content page"
+ );
+
+ await BrowserTestUtils.removeTab(productTab);
+ await BrowserTestUtils.removeTab(contentTab);
+});
+
+add_task(async function test_button_toggles_sidebars() {
+ Services.prefs.setBoolPref("browser.shopping.experience2023.active", false);
+
+ await BrowserTestUtils.withNewTab(CONTENT_PAGE, async function (browser) {
+ let shoppingButton = document.getElementById("shopping-sidebar-button");
+ let browserPanel = gBrowser.getPanel(browser);
+
+ BrowserTestUtils.startLoadingURIString(browser, PRODUCT_PAGE);
+ await BrowserTestUtils.browserLoaded(browser);
+
+ let sidebar = browserPanel.querySelector("shopping-sidebar");
+
+ is(sidebar, null, "Shopping sidebar should be closed");
+
+ // open
+ shoppingButton.click();
+ await BrowserTestUtils.waitForMutationCondition(
+ shoppingButton,
+ {
+ attributeFilter: ["shoppingsidebaropen"],
+ },
+ () => shoppingButton.getAttribute("shoppingsidebaropen") == "true"
+ );
+
+ sidebar = browserPanel.querySelector("shopping-sidebar");
+ ok(BrowserTestUtils.isVisible(sidebar), "Shopping sidebar should be open");
+
+ // close
+ shoppingButton.click();
+ await BrowserTestUtils.waitForMutationCondition(
+ shoppingButton,
+ {
+ attributeFilter: ["shoppingsidebaropen"],
+ },
+ () => shoppingButton.getAttribute("shoppingsidebaropen") == "false"
+ );
+
+ ok(BrowserTestUtils.isHidden(sidebar), "Shopping sidebar should be closed");
+ });
+});
+
+// Button changes all Windows
+add_task(async function test_button_toggles_all_windows() {
+ Services.prefs.setBoolPref("browser.shopping.experience2023.active", false);
+
+ let shoppingButton = document.getElementById("shopping-sidebar-button");
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, PRODUCT_PAGE);
+
+ let browserPanelA = gBrowser.getPanel(gBrowser.selectedBrowser);
+ let sidebarA = browserPanelA.querySelector("shopping-sidebar");
+
+ let newWindow = await BrowserTestUtils.openNewBrowserWindow();
+
+ BrowserTestUtils.startLoadingURIString(
+ newWindow.gBrowser.selectedBrowser,
+ PRODUCT_PAGE
+ );
+ await BrowserTestUtils.browserLoaded(newWindow.gBrowser.selectedBrowser);
+
+ let browserPanelB = newWindow.gBrowser.getPanel(
+ newWindow.gBrowser.selectedBrowser
+ );
+ let sidebarB = browserPanelB.querySelector("shopping-sidebar");
+
+ is(
+ sidebarA,
+ null,
+ "Shopping sidebar should not exist yet for new tab in current window"
+ );
+ is(sidebarB, null, "Shopping sidebar closed in new window");
+
+ // open
+ shoppingButton.click();
+ await BrowserTestUtils.waitForMutationCondition(
+ shoppingButton,
+ {
+ attributeFilter: ["shoppingsidebaropen"],
+ },
+ () => shoppingButton.getAttribute("shoppingsidebaropen") == "true"
+ );
+ sidebarA = browserPanelA.querySelector("shopping-sidebar");
+ ok(
+ BrowserTestUtils.isVisible(sidebarA),
+ "Shopping sidebar should be open in current window"
+ );
+ sidebarB = browserPanelB.querySelector("shopping-sidebar");
+ ok(
+ BrowserTestUtils.isVisible(sidebarB),
+ "Shopping sidebar should be open in new window"
+ );
+
+ // close
+ shoppingButton.click();
+ await BrowserTestUtils.waitForMutationCondition(
+ shoppingButton,
+ {
+ attributeFilter: ["shoppingsidebaropen"],
+ },
+ () => shoppingButton.getAttribute("shoppingsidebaropen") == "false"
+ );
+
+ ok(
+ BrowserTestUtils.isHidden(sidebarA),
+ "Shopping sidebar should be closed in current window"
+ );
+ ok(
+ BrowserTestUtils.isHidden(sidebarB),
+ "Shopping sidebar should be closed in new window"
+ );
+
+ BrowserTestUtils.removeTab(tab);
+ await BrowserTestUtils.closeWindow(newWindow);
+});
+
+add_task(async function test_button_right_click_doesnt_affect_sidebars() {
+ Services.prefs.setBoolPref("browser.shopping.experience2023.active", false);
+
+ await BrowserTestUtils.withNewTab(CONTENT_PAGE, async function (browser) {
+ let shoppingButton = document.getElementById("shopping-sidebar-button");
+ let browserPanel = gBrowser.getPanel(browser);
+
+ BrowserTestUtils.startLoadingURIString(browser, PRODUCT_PAGE);
+ await BrowserTestUtils.browserLoaded(browser);
+
+ let sidebar = browserPanel.querySelector("shopping-sidebar");
+
+ is(sidebar, null, "Shopping sidebar should be closed");
+ EventUtils.synthesizeMouseAtCenter(shoppingButton, { button: 1 });
+ // Wait a tick.
+ await new Promise(executeSoon);
+ sidebar = browserPanel.querySelector("shopping-sidebar");
+ is(sidebar, null, "Shopping sidebar should still be closed");
+ });
+});
+
+add_task(async function test_button_deals_with_tabswitches() {
+ Services.prefs.setBoolPref("browser.shopping.experience2023.active", true);
+
+ await BrowserTestUtils.withNewTab(CONTENT_PAGE, async function (browser) {
+ let shoppingButton = document.getElementById("shopping-sidebar-button");
+
+ ok(
+ BrowserTestUtils.isHidden(shoppingButton),
+ "The shopping button is hidden on a non product page"
+ );
+
+ let newProductTab = BrowserTestUtils.addTab(gBrowser, PRODUCT_PAGE);
+ let newProductBrowser = newProductTab.linkedBrowser;
+ await BrowserTestUtils.browserLoaded(
+ newProductBrowser,
+ false,
+ PRODUCT_PAGE
+ );
+
+ ok(
+ BrowserTestUtils.isHidden(shoppingButton),
+ "The shopping button is still hidden after opening a background product tab"
+ );
+
+ let shoppingButtonVisiblePromise =
+ BrowserTestUtils.waitForMutationCondition(
+ shoppingButton,
+ { attributes: true, attributeFilter: ["hidden"] },
+ () => !shoppingButton.hidden
+ );
+ await BrowserTestUtils.switchTab(gBrowser, newProductTab);
+ await shoppingButtonVisiblePromise;
+
+ ok(
+ BrowserTestUtils.isVisible(shoppingButton),
+ "The shopping button is now visible"
+ );
+
+ let newProductTab2 = BrowserTestUtils.addTab(gBrowser, PRODUCT_PAGE);
+ let newProductBrowser2 = newProductTab2.linkedBrowser;
+ await BrowserTestUtils.browserLoaded(
+ newProductBrowser2,
+ false,
+ PRODUCT_PAGE
+ );
+
+ ok(
+ BrowserTestUtils.isVisible(shoppingButton),
+ "The shopping button is still visible after opening background product tab"
+ );
+
+ shoppingButtonVisiblePromise = BrowserTestUtils.waitForMutationCondition(
+ shoppingButton,
+ { attributes: true, attributeFilter: ["hidden"] },
+ () => !shoppingButton.hidden
+ );
+ await BrowserTestUtils.switchTab(gBrowser, newProductTab2);
+ await shoppingButtonVisiblePromise;
+
+ ok(
+ BrowserTestUtils.isVisible(shoppingButton),
+ "The shopping button is still visible"
+ );
+
+ BrowserTestUtils.removeTab(newProductTab2);
+
+ BrowserTestUtils.removeTab(newProductTab);
+ });
+});
+
+add_task(async function test_button_deals_with_tabswitches_post_optout() {
+ Services.prefs.setBoolPref("browser.shopping.experience2023.active", true);
+
+ await BrowserTestUtils.withNewTab(CONTENT_PAGE, async function (browser) {
+ let shoppingButton = document.getElementById("shopping-sidebar-button");
+
+ ok(
+ BrowserTestUtils.isHidden(shoppingButton),
+ "The shopping button is hidden on a non product page"
+ );
+
+ let newProductTab = BrowserTestUtils.addTab(gBrowser, PRODUCT_PAGE);
+ let newProductBrowser = newProductTab.linkedBrowser;
+ await BrowserTestUtils.browserLoaded(
+ newProductBrowser,
+ false,
+ PRODUCT_PAGE
+ );
+
+ ok(
+ BrowserTestUtils.isHidden(shoppingButton),
+ "The shopping button is still hidden after opening a background product tab"
+ );
+
+ let shoppingButtonVisiblePromise =
+ BrowserTestUtils.waitForMutationCondition(
+ shoppingButton,
+ { attributes: true, attributeFilter: ["hidden"] },
+ () => !shoppingButton.hidden
+ );
+ await BrowserTestUtils.switchTab(gBrowser, newProductTab);
+ await shoppingButtonVisiblePromise;
+
+ ok(
+ BrowserTestUtils.isVisible(shoppingButton),
+ "The shopping button is now visible"
+ );
+
+ let newProductTab2 = BrowserTestUtils.addTab(gBrowser, PRODUCT_PAGE);
+ let newProductBrowser2 = newProductTab2.linkedBrowser;
+ await BrowserTestUtils.browserLoaded(
+ newProductBrowser2,
+ false,
+ PRODUCT_PAGE
+ );
+
+ ok(
+ BrowserTestUtils.isVisible(shoppingButton),
+ "The shopping button is still visible after opening background product tab"
+ );
+
+ shoppingButtonVisiblePromise = BrowserTestUtils.waitForMutationCondition(
+ shoppingButton,
+ { attributes: true, attributeFilter: ["hidden"] },
+ () => !shoppingButton.hidden
+ );
+ await BrowserTestUtils.switchTab(gBrowser, newProductTab2);
+ await shoppingButtonVisiblePromise;
+
+ ok(
+ BrowserTestUtils.isVisible(shoppingButton),
+ "The shopping button is still visible"
+ );
+
+ // Simulate opt-out
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["browser.shopping.experience2023.active", false],
+ ["browser.shopping.experience2023.optedIn", 2],
+ ],
+ });
+
+ ok(
+ BrowserTestUtils.isVisible(shoppingButton),
+ "The shopping button is still visible after opting out."
+ );
+ Assert.equal(
+ shoppingButton.getAttribute("shoppingsidebaropen"),
+ "false",
+ "Button not marked as open."
+ );
+
+ // Switch to non-product tab.
+ await BrowserTestUtils.switchTab(
+ gBrowser,
+ gBrowser.getTabForBrowser(browser)
+ );
+ ok(
+ BrowserTestUtils.isHidden(shoppingButton),
+ "The shopping button is hidden on non-product page."
+ );
+ Assert.equal(
+ shoppingButton.getAttribute("shoppingsidebaropen"),
+ "false",
+ "Button not marked as open."
+ );
+ // Switch to non-product tab.
+ await BrowserTestUtils.switchTab(gBrowser, newProductTab);
+ ok(
+ BrowserTestUtils.isVisible(shoppingButton),
+ "The shopping button is still visible on a different product tab after opting out."
+ );
+ Assert.equal(
+ shoppingButton.getAttribute("shoppingsidebaropen"),
+ "false",
+ "Button not marked as open."
+ );
+
+ BrowserTestUtils.removeTab(newProductTab2);
+
+ BrowserTestUtils.removeTab(newProductTab);
+ });
+});
diff --git a/browser/components/shopping/tests/browser/browser_stale_product.js b/browser/components/shopping/tests/browser/browser_stale_product.js
new file mode 100644
index 0000000000..45bed46a6b
--- /dev/null
+++ b/browser/components/shopping/tests/browser/browser_stale_product.js
@@ -0,0 +1,36 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests that the correct shopping-message-bar component appears if a product analysis is stale.
+ * Other analysis details should be visible.
+ */
+add_task(async function test_stale_product() {
+ await BrowserTestUtils.withNewTab(
+ {
+ url: "about:shoppingsidebar",
+ gBrowser,
+ },
+ async browser => {
+ let shoppingContainer = await getAnalysisDetails(
+ browser,
+ MOCK_STALE_PRODUCT_RESPONSE
+ );
+
+ ok(
+ shoppingContainer.shoppingMessageBarEl,
+ "Got shopping-message-bar element"
+ );
+ is(
+ shoppingContainer.shoppingMessageBarType,
+ "stale",
+ "shopping-message-bar type should be correct"
+ );
+
+ verifyAnalysisDetailsVisible(shoppingContainer);
+ verifyFooterVisible(shoppingContainer);
+ }
+ );
+});
diff --git a/browser/components/shopping/tests/browser/browser_ui_telemetry.js b/browser/components/shopping/tests/browser/browser_ui_telemetry.js
new file mode 100644
index 0000000000..b97aca1963
--- /dev/null
+++ b/browser/components/shopping/tests/browser/browser_ui_telemetry.js
@@ -0,0 +1,762 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const CONTENT_PAGE = "https://example.com";
+const PRODUCT_PAGE = "https://example.com/product/B09TJGHL5F";
+
+function assertEventMatches(gleanEvent, requiredValues) {
+ let limitedEvent = Object.assign({}, gleanEvent);
+ for (let k of Object.keys(limitedEvent)) {
+ if (!requiredValues.hasOwnProperty(k)) {
+ delete limitedEvent[k];
+ }
+ }
+ return Assert.deepEqual(limitedEvent, requiredValues);
+}
+
+add_task(async function test_shopping_reanalysis_event() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.shopping.experience2023.active", true]],
+ });
+
+ // testFlushAllChildren() is necessary to deal with the event being
+ // recorded in content, but calling testGetValue() in parent.
+ await Services.fog.testFlushAllChildren();
+ Services.fog.testResetFOG();
+
+ await BrowserTestUtils.withNewTab(
+ {
+ url: "about:shoppingsidebar",
+ gBrowser,
+ },
+ async browser => {
+ await clickReAnalyzeLink(browser, MOCK_STALE_PRODUCT_RESPONSE);
+ }
+ );
+
+ await Services.fog.testFlushAllChildren();
+ var staleAnalysisEvents =
+ Glean.shopping.surfaceStaleAnalysisShown.testGetValue();
+
+ assertEventMatches(staleAnalysisEvents[0], {
+ category: "shopping",
+ name: "surface_stale_analysis_shown",
+ });
+
+ var reanalysisRequestedEvents =
+ Glean.shopping.surfaceReanalyzeClicked.testGetValue();
+
+ assertEventMatches(reanalysisRequestedEvents[0], {
+ category: "shopping",
+ name: "surface_reanalyze_clicked",
+ });
+
+ await SpecialPowers.popPrefEnv();
+});
+
+add_task(async function test_reactivated_product_button_click() {
+ await Services.fog.testFlushAllChildren();
+ Services.fog.testResetFOG();
+
+ await BrowserTestUtils.withNewTab(
+ {
+ url: "about:shoppingsidebar",
+ gBrowser,
+ },
+ async browser => {
+ await clickProductAvailableLink(browser, MOCK_STALE_PRODUCT_RESPONSE);
+ }
+ );
+
+ await Services.fog.testFlushAllChildren();
+
+ var reanalysisEvents =
+ Glean.shopping.surfaceReactivatedButtonClicked.testGetValue();
+ assertEventMatches(reanalysisEvents[0], {
+ category: "shopping",
+ name: "surface_reactivated_button_clicked",
+ });
+});
+
+add_task(async function test_no_reliability_available_request_click() {
+ await Services.fog.testFlushAllChildren();
+ Services.fog.testResetFOG();
+
+ await BrowserTestUtils.withNewTab(
+ {
+ url: "about:shoppingsidebar",
+ gBrowser,
+ },
+ async browser => {
+ await clickCheckReviewQualityButton(
+ browser,
+ MOCK_UNANALYZED_PRODUCT_RESPONSE
+ );
+ }
+ );
+
+ await Services.fog.testFlushAllChildren();
+ var requestEvents =
+ Glean.shopping.surfaceAnalyzeReviewsNoneAvailableClicked.testGetValue();
+
+ assertEventMatches(requestEvents[0], {
+ category: "shopping",
+ name: "surface_analyze_reviews_none_available_clicked",
+ });
+});
+
+add_task(async function test_shopping_sidebar_displayed() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.shopping.experience2023.active", true]],
+ });
+
+ Services.fog.testResetFOG();
+
+ await BrowserTestUtils.withNewTab(PRODUCT_PAGE, async function (browser) {
+ let shoppingButton = document.getElementById("shopping-sidebar-button");
+ await BrowserTestUtils.waitForMutationCondition(
+ shoppingButton,
+ {
+ attributeFilter: ["shoppingsidebaropen"],
+ },
+ () => shoppingButton.getAttribute("shoppingsidebaropen") == "true"
+ );
+ let sidebar = gBrowser.getPanel(browser).querySelector("shopping-sidebar");
+ Assert.ok(
+ BrowserTestUtils.isVisible(sidebar),
+ "Sidebar should be visible."
+ );
+
+ // open a new tab onto a page where sidebar is not visible.
+ let contentTab = await BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ url: CONTENT_PAGE,
+ });
+
+ // change the focused tab a few times to ensure we don't increment on tab
+ // switch.
+ await BrowserTestUtils.switchTab(gBrowser, gBrowser.tabs[0]);
+ await BrowserTestUtils.switchTab(gBrowser, contentTab);
+ await BrowserTestUtils.switchTab(gBrowser, gBrowser.tabs[0]);
+
+ BrowserTestUtils.removeTab(contentTab);
+ });
+
+ await Services.fog.testFlushAllChildren();
+
+ var displayedEvents = Glean.shopping.surfaceDisplayed.testGetValue();
+ Assert.equal(1, displayedEvents.length);
+ assertEventMatches(displayedEvents[0], {
+ category: "shopping",
+ name: "surface_displayed",
+ });
+
+ var addressBarIconDisplayedEvents =
+ Glean.shopping.addressBarIconDisplayed.testGetValue();
+ assertEventMatches(addressBarIconDisplayedEvents[0], {
+ category: "shopping",
+ name: "address_bar_icon_displayed",
+ });
+
+ // reset FOG and check a page that should NOT have these events
+ Services.fog.testResetFOG();
+
+ await BrowserTestUtils.withNewTab(CONTENT_PAGE, async function (browser) {
+ let sidebar = gBrowser.getPanel(browser).querySelector("shopping-sidebar");
+
+ Assert.equal(sidebar, null);
+ });
+
+ var emptyDisplayedEvents = Glean.shopping.surfaceDisplayed.testGetValue();
+ var emptyAddressBarIconDisplayedEvents =
+ Glean.shopping.addressBarIconDisplayed.testGetValue();
+
+ Assert.equal(emptyDisplayedEvents, null);
+ Assert.equal(emptyAddressBarIconDisplayedEvents, null);
+
+ // Open a product page in a background tab, verify telemetry is not recorded.
+ let backgroundTab = await BrowserTestUtils.addTab(gBrowser, PRODUCT_PAGE);
+ await Services.fog.testFlushAllChildren();
+ let tabSwitchEvents = Glean.shopping.surfaceDisplayed.testGetValue();
+ Assert.equal(tabSwitchEvents, null);
+ Services.fog.testResetFOG();
+
+ // Next, switch tabs to the backgrounded product tab and verify telemetry is
+ // recorded.
+ await BrowserTestUtils.switchTab(gBrowser, backgroundTab);
+ await Services.fog.testFlushAllChildren();
+ tabSwitchEvents = Glean.shopping.surfaceDisplayed.testGetValue();
+ Assert.equal(1, tabSwitchEvents.length);
+ assertEventMatches(tabSwitchEvents[0], {
+ category: "shopping",
+ name: "surface_displayed",
+ });
+ Services.fog.testResetFOG();
+
+ // Finally, switch tabs again and verify telemetry is not recorded for the
+ // background tab after it has been foregrounded once.
+ await BrowserTestUtils.switchTab(gBrowser, gBrowser.tabs[0]);
+ await BrowserTestUtils.switchTab(gBrowser, backgroundTab);
+ await Services.fog.testFlushAllChildren();
+ tabSwitchEvents = Glean.shopping.surfaceDisplayed.testGetValue();
+ Assert.equal(tabSwitchEvents, null);
+ Services.fog.testResetFOG();
+ BrowserTestUtils.removeTab(backgroundTab);
+ await SpecialPowers.popPrefEnv();
+});
+
+add_task(async function test_shopping_card_clicks() {
+ await Services.fog.testFlushAllChildren();
+ Services.fog.testResetFOG();
+
+ await BrowserTestUtils.withNewTab(
+ {
+ url: "about:shoppingsidebar",
+ gBrowser,
+ },
+ async browser => {
+ await clickShowMoreButton(browser, MOCK_ANALYZED_PRODUCT_RESPONSE);
+ }
+ );
+
+ await Services.fog.testFlushAllChildren();
+ var learnMoreButtonEvents =
+ Glean.shopping.surfaceShowMoreReviewsButtonClicked.testGetValue();
+
+ assertEventMatches(learnMoreButtonEvents[0], {
+ category: "shopping",
+ name: "surface_show_more_reviews_button_clicked",
+ });
+});
+
+add_task(async function test_close_telemetry_recorded() {
+ await Services.fog.testFlushAllChildren();
+ Services.fog.testResetFOG();
+
+ await BrowserTestUtils.withNewTab(
+ {
+ url: "about:shoppingsidebar",
+ gBrowser,
+ },
+ async browser => {
+ await clickCloseButton(browser, MOCK_ANALYZED_PRODUCT_RESPONSE);
+ }
+ );
+
+ await Services.fog.testFlushAllChildren();
+
+ var closeEvents = Glean.shopping.surfaceClosed.testGetValue();
+ assertEventMatches(closeEvents[0], {
+ category: "shopping",
+ name: "surface_closed",
+ extra: { source: "closeButton" },
+ });
+
+ // Ensure that the sidebar is open so we confirm the icon click closes it.
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.shopping.experience2023.active", true]],
+ });
+
+ await BrowserTestUtils.withNewTab(PRODUCT_PAGE, async function (browser) {
+ let shoppingButton = document.getElementById("shopping-sidebar-button");
+ shoppingButton.click();
+ });
+
+ await Services.fog.testFlushAllChildren();
+ var urlBarIconEvents = Glean.shopping.addressBarIconClicked.testGetValue();
+ assertEventMatches(urlBarIconEvents[0], {
+ category: "shopping",
+ name: "address_bar_icon_clicked",
+ extra: { action: "closed" },
+ });
+
+ var closeSurfaceEvents = Glean.shopping.surfaceClosed.testGetValue();
+ assertEventMatches(closeSurfaceEvents[0], {
+ category: "shopping",
+ name: "surface_closed",
+ extra: { source: "closeButton" },
+ });
+
+ assertEventMatches(closeSurfaceEvents[1], {
+ category: "shopping",
+ name: "surface_closed",
+ extra: { source: "addressBarIcon" },
+ });
+
+ await SpecialPowers.popPrefEnv();
+});
+
+add_task(async function test_powered_by_fakespot_link() {
+ await Services.fog.testFlushAllChildren();
+ Services.fog.testResetFOG();
+
+ await BrowserTestUtils.withNewTab(
+ {
+ url: "about:shoppingsidebar",
+ gBrowser,
+ },
+ async browser => {
+ await clickPoweredByFakespotLink(browser, MOCK_ANALYZED_PRODUCT_RESPONSE);
+ }
+ );
+
+ await Services.fog.testFlushAllChildren();
+
+ let fakespotLinkEvents =
+ Glean.shopping.surfacePoweredByFakespotLinkClicked.testGetValue();
+ assertEventMatches(fakespotLinkEvents[0], {
+ category: "shopping",
+ name: "surface_powered_by_fakespot_link_clicked",
+ });
+});
+
+add_task(async function test_review_quality_explainer_link() {
+ await Services.fog.testFlushAllChildren();
+ Services.fog.testResetFOG();
+
+ await BrowserTestUtils.withNewTab(
+ {
+ url: "about:shoppingsidebar",
+ gBrowser,
+ },
+ async browser => {
+ await clickReviewQualityExplainerLink(
+ browser,
+ MOCK_ANALYZED_PRODUCT_RESPONSE
+ );
+ }
+ );
+
+ await Services.fog.testFlushAllChildren();
+
+ let qualityExplainerEvents =
+ Glean.shopping.surfaceShowQualityExplainerUrlClicked.testGetValue();
+ assertEventMatches(qualityExplainerEvents[0], {
+ category: "shopping",
+ name: "surface_show_quality_explainer_url_clicked",
+ });
+});
+
+// Start with ads user enabled, then disable them, and verify telemetry.
+add_task(async function test_ads_disable_button_click() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["browser.shopping.experience2023.adsEnabled", true],
+ ["browser.shopping.experience2023.ads.userEnabled", true],
+ ],
+ });
+
+ await Services.fog.testFlushAllChildren();
+ Services.fog.testResetFOG();
+
+ await BrowserTestUtils.withNewTab(
+ {
+ url: "about:shoppingsidebar",
+ gBrowser,
+ },
+ async browser => {
+ let mockArgs = {
+ mockData: MOCK_ANALYZED_PRODUCT_RESPONSE,
+ mockRecommendationData: MOCK_RECOMMENDED_ADS_RESPONSE,
+ };
+
+ await clickAdsToggle(browser, mockArgs);
+
+ await Services.fog.testFlushAllChildren();
+
+ // Verify the ads state was changed to disabled.
+ let toggledEvents =
+ Glean.shopping.surfaceAdsSettingToggled.testGetValue();
+ assertEventMatches(toggledEvents[0], {
+ category: "shopping",
+ name: "surface_ads_setting_toggled",
+ extra: { action: "disabled" },
+ });
+
+ // Verify the ads disabled state is set to true.
+ Assert.equal(
+ Glean.shoppingSettings.disabledAds.testGetValue(),
+ true,
+ "Ads should be marked as disabled"
+ );
+ }
+ );
+});
+
+// Start with ads user disabled, then enable them, and verify telemetry.
+add_task(async function test_ads_enable_button_click() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["browser.shopping.experience2023.adsEnabled", true],
+ ["browser.shopping.experience2023.ads.userEnabled", false],
+ ],
+ });
+
+ await Services.fog.testFlushAllChildren();
+ Services.fog.testResetFOG();
+
+ await BrowserTestUtils.withNewTab(
+ {
+ url: "about:shoppingsidebar",
+ gBrowser,
+ },
+ async browser => {
+ let mockArgs = {
+ mockData: MOCK_ANALYZED_PRODUCT_RESPONSE,
+ mockRecommendationData: MOCK_RECOMMENDED_ADS_RESPONSE,
+ };
+
+ await clickAdsToggle(browser, mockArgs);
+
+ await Services.fog.testFlushAllChildren();
+
+ // Verify the ads state was changed to enabled.
+ let toggledEvents =
+ Glean.shopping.surfaceAdsSettingToggled.testGetValue();
+ assertEventMatches(toggledEvents[0], {
+ category: "shopping",
+ name: "surface_ads_setting_toggled",
+ extra: { action: "enabled" },
+ });
+
+ // Verify the ads disabled state is set to false.
+ Assert.equal(
+ Glean.shoppingSettings.disabledAds.testGetValue(),
+ false,
+ "Ads should be marked as enabled"
+ );
+ }
+ );
+});
+
+add_task(async function test_auto_open_settings_toggle() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["browser.shopping.experience2023.autoOpen.enabled", true],
+ ["browser.shopping.experience2023.autoOpen.userEnabled", true],
+ ],
+ });
+ await Services.fog.testFlushAllChildren();
+ Services.fog.testResetFOG();
+ await BrowserTestUtils.withNewTab(
+ {
+ url: "about:shoppingsidebar",
+ gBrowser,
+ },
+ async browser => {
+ let mockData = MOCK_ANALYZED_PRODUCT_RESPONSE;
+ await clickAutoOpenToggle(browser, mockData);
+ await Services.fog.testFlushAllChildren();
+ let toggledEvents =
+ Glean.shopping.surfaceAutoOpenSettingToggled.testGetValue();
+ assertEventMatches(toggledEvents[0], {
+ category: "shopping",
+ name: "surface_auto_open_setting_toggled",
+ extra: { action: "disabled" },
+ });
+
+ Services.fog.testResetFOG();
+
+ // Toggle back in the other direction.
+ await clickAutoOpenToggle(browser, mockData);
+ await Services.fog.testFlushAllChildren();
+ toggledEvents =
+ Glean.shopping.surfaceAutoOpenSettingToggled.testGetValue();
+ assertEventMatches(toggledEvents[0], {
+ category: "shopping",
+ name: "surface_auto_open_setting_toggled",
+ extra: { action: "enabled" },
+ });
+ }
+ );
+
+ await SpecialPowers.popPrefEnv();
+});
+
+add_task(async function test_auto_open_no_thanks_button_click() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["browser.shopping.experience2023.autoOpen.enabled", true],
+ ["browser.shopping.experience2023.autoOpen.userEnabled", true],
+ ["browser.shopping.experience2023.showKeepSidebarClosedMessage", true],
+ ],
+ });
+
+ await Services.fog.testFlushAllChildren();
+ Services.fog.testResetFOG();
+
+ await BrowserTestUtils.withNewTab(
+ {
+ url: "about:shoppingsidebar",
+ gBrowser,
+ },
+ async browser => {
+ let mockArgs = {
+ mockData: MOCK_ANALYZED_PRODUCT_RESPONSE,
+ };
+
+ await clickNoThanksButton(browser, mockArgs);
+
+ await Services.fog.testFlushAllChildren();
+
+ let noThanksButtonEvents =
+ Glean.shopping.surfaceNoThanksButtonClicked.testGetValue();
+
+ assertEventMatches(noThanksButtonEvents[0], {
+ category: "shopping",
+ name: "surface_no_thanks_button_clicked",
+ });
+ }
+ );
+});
+
+add_task(async function test_auto_open_yes_keep_closed_button() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["browser.shopping.experience2023.autoOpen.enabled", true],
+ ["browser.shopping.experience2023.autoOpen.userEnabled", true],
+ ["browser.shopping.experience2023.showKeepSidebarClosedMessage", true],
+ ],
+ });
+
+ await Services.fog.testFlushAllChildren();
+ Services.fog.testResetFOG();
+
+ await BrowserTestUtils.withNewTab(
+ {
+ url: "about:shoppingsidebar",
+ gBrowser,
+ },
+ async browser => {
+ let mockArgs = {
+ mockData: MOCK_ANALYZED_PRODUCT_RESPONSE,
+ };
+
+ await clickYesKeepClosedButton(browser, mockArgs);
+
+ await Services.fog.testFlushAllChildren();
+
+ let yesKeepClosedButtonEvents =
+ Glean.shopping.surfaceYesKeepClosedButtonClicked.testGetValue();
+
+ assertEventMatches(yesKeepClosedButtonEvents[0], {
+ category: "shopping",
+ name: "surface_yes_keep_closed_button_clicked",
+ });
+ }
+ );
+});
+
+add_task(async function test_auto_open_user_disabled() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["browser.shopping.experience2023.autoOpen.enabled", true],
+ ["browser.shopping.experience2023.autoOpen.userEnabled", true],
+ ],
+ });
+
+ await Services.fog.testFlushAllChildren();
+ Services.fog.testResetFOG();
+
+ Services.prefs.setBoolPref(
+ "browser.shopping.experience2023.autoOpen.userEnabled",
+ false
+ );
+
+ await Services.fog.testFlushAllChildren();
+
+ Assert.equal(
+ Glean.shoppingSettings.autoOpenUserDisabled.testGetValue(),
+ true,
+ "Auto open should be marked as disabled"
+ );
+});
+
+function clickAdsToggle(browser, data) {
+ return SpecialPowers.spawn(browser, [data], async args => {
+ const { mockData, mockRecommendationData } = args;
+ let shoppingContainer =
+ content.document.querySelector("shopping-container").wrappedJSObject;
+ shoppingContainer.data = Cu.cloneInto(mockData, content);
+ shoppingContainer.recommendationData = Cu.cloneInto(
+ mockRecommendationData,
+ content
+ );
+
+ await shoppingContainer.updateComplete;
+
+ let shoppingSettings = shoppingContainer.settingsEl;
+ let toggle = shoppingSettings.recommendationsToggleEl;
+ toggle.click();
+
+ await shoppingContainer.updateComplete;
+ });
+}
+
+function clickAutoOpenToggle(browser, data) {
+ return SpecialPowers.spawn(browser, [data], async mockData => {
+ let shoppingContainer =
+ content.document.querySelector("shopping-container").wrappedJSObject;
+ shoppingContainer.data = Cu.cloneInto(mockData, content);
+
+ await shoppingContainer.updateComplete;
+
+ let shoppingSettings = shoppingContainer.settingsEl;
+ let toggle = shoppingSettings.autoOpenToggleEl;
+ toggle.click();
+
+ await shoppingContainer.updateComplete;
+ });
+}
+
+function clickReAnalyzeLink(browser, data) {
+ return SpecialPowers.spawn(browser, [data], async mockData => {
+ let shoppingContainer =
+ content.document.querySelector("shopping-container").wrappedJSObject;
+ shoppingContainer.data = Cu.cloneInto(mockData, content);
+ await shoppingContainer.updateComplete;
+
+ let shoppingMessageBar = shoppingContainer.shoppingMessageBarEl;
+ await shoppingMessageBar.updateComplete;
+
+ await shoppingMessageBar.onClickAnalysisButton();
+
+ return "clicked";
+ });
+}
+
+function clickCloseButton(browser, data) {
+ return SpecialPowers.spawn(browser, [data], async mockData => {
+ let shoppingContainer =
+ content.document.querySelector("shopping-container").wrappedJSObject;
+ shoppingContainer.data = Cu.cloneInto(mockData, content);
+ await shoppingContainer.updateComplete;
+
+ let closeButton =
+ shoppingContainer.shadowRoot.querySelector("#close-button");
+ await closeButton.updateComplete;
+
+ closeButton.click();
+ });
+}
+
+function clickProductAvailableLink(browser, data) {
+ return SpecialPowers.spawn(browser, [data], async mockData => {
+ let shoppingContainer =
+ content.document.querySelector("shopping-container").wrappedJSObject;
+ shoppingContainer.data = Cu.cloneInto(mockData, content);
+ await shoppingContainer.updateComplete;
+
+ let shoppingMessageBar = shoppingContainer.shoppingMessageBarEl;
+ await shoppingMessageBar.updateComplete;
+
+ // calling onClickProductAvailable will fail quietly in cases where this is
+ // not possible to call, so assure it exists first.
+ Assert.notEqual(shoppingMessageBar, null);
+ await shoppingMessageBar.onClickProductAvailable();
+ });
+}
+
+function clickShowMoreButton(browser, data) {
+ return SpecialPowers.spawn(browser, [data], async mockData => {
+ let shoppingContainer =
+ content.document.querySelector("shopping-container").wrappedJSObject;
+ shoppingContainer.data = Cu.cloneInto(mockData, content);
+ await shoppingContainer.updateComplete;
+
+ let highlights = shoppingContainer.highlightsEl;
+ let card = highlights.shadowRoot.querySelector("shopping-card");
+ let button = card.shadowRoot.querySelector("article footer button");
+
+ button.click();
+ });
+}
+
+function clickCheckReviewQualityButton(browser, data) {
+ return SpecialPowers.spawn(browser, [data], async mockData => {
+ let shoppingContainer =
+ content.document.querySelector("shopping-container").wrappedJSObject;
+ shoppingContainer.data = Cu.cloneInto(mockData, content);
+ await shoppingContainer.updateComplete;
+
+ let button = shoppingContainer.unanalyzedProductEl.shadowRoot
+ .querySelector("shopping-card")
+ .querySelector("button");
+
+ button.click();
+ });
+}
+
+function clickPoweredByFakespotLink(browser, data) {
+ return SpecialPowers.spawn(browser, [data], async mockData => {
+ let shoppingContainer =
+ content.document.querySelector("shopping-container").wrappedJSObject;
+ shoppingContainer.data = Cu.cloneInto(mockData, content);
+ await shoppingContainer.updateComplete;
+
+ let settingsEl = shoppingContainer.settingsEl;
+ await settingsEl.updateComplete;
+ let fakespotLink = settingsEl.fakespotLearnMoreLinkEl;
+
+ // Prevent link navigation for test.
+ fakespotLink.href = undefined;
+ await fakespotLink.updateComplete;
+
+ fakespotLink.click();
+ });
+}
+
+function clickReviewQualityExplainerLink(browser, data) {
+ return SpecialPowers.spawn(browser, [data], async mockData => {
+ let shoppingContainer =
+ content.document.querySelector("shopping-container").wrappedJSObject;
+ shoppingContainer.data = Cu.cloneInto(mockData, content);
+ await shoppingContainer.updateComplete;
+
+ let analysisExplainerEl = shoppingContainer.analysisExplainerEl;
+ await analysisExplainerEl.updateComplete;
+ let reviewQualityLink = analysisExplainerEl.reviewQualityExplainerLink;
+
+ // Prevent link navigation for test.
+ reviewQualityLink.href = undefined;
+ await reviewQualityLink.updateComplete;
+
+ reviewQualityLink.click();
+ });
+}
+
+function clickNoThanksButton(browser, data) {
+ return SpecialPowers.spawn(browser, [data], async mockData => {
+ let shoppingContainer =
+ content.document.querySelector("shopping-container").wrappedJSObject;
+ shoppingContainer.data = Cu.cloneInto(mockData, content);
+ // Force the "keep closed" to appear
+ shoppingContainer.showingKeepClosedMessage = true;
+ await shoppingContainer.updateComplete;
+
+ let shoppingMessageBar = shoppingContainer.keepClosedMessageBarEl;
+ await shoppingMessageBar.updateComplete;
+
+ let button = shoppingMessageBar.noThanksButtonEl;
+ button.click();
+ });
+}
+
+function clickYesKeepClosedButton(browser, data) {
+ return SpecialPowers.spawn(browser, [data], async mockData => {
+ let shoppingContainer =
+ content.document.querySelector("shopping-container").wrappedJSObject;
+ shoppingContainer.data = Cu.cloneInto(mockData, content);
+ // Force the "keep closed" to appear
+ shoppingContainer.showingKeepClosedMessage = true;
+ await shoppingContainer.updateComplete;
+
+ let shoppingMessageBar = shoppingContainer.keepClosedMessageBarEl;
+ await shoppingMessageBar.updateComplete;
+
+ let button = shoppingMessageBar.yesKeepClosedButtonEl;
+ button.click();
+ });
+}
diff --git a/browser/components/shopping/tests/browser/browser_unanalyzed_product.js b/browser/components/shopping/tests/browser/browser_unanalyzed_product.js
new file mode 100644
index 0000000000..a28611cdbf
--- /dev/null
+++ b/browser/components/shopping/tests/browser/browser_unanalyzed_product.js
@@ -0,0 +1,86 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests that the unanalyzed product card appears if a product has no analysis yet.
+ * Settings should be the only other component that is visible.
+ */
+add_task(async function test_unanalyzed_product() {
+ await BrowserTestUtils.withNewTab(
+ {
+ url: "about:shoppingsidebar",
+ gBrowser,
+ },
+ async browser => {
+ let shoppingContainer = await getAnalysisDetails(
+ browser,
+ MOCK_UNANALYZED_PRODUCT_RESPONSE
+ );
+
+ ok(
+ shoppingContainer.unanalyzedProductEl,
+ "Got the unanalyzed-product-card element"
+ );
+
+ verifyAnalysisDetailsHidden(shoppingContainer);
+ verifyFooterVisible(shoppingContainer);
+ }
+ );
+});
+
+/**
+ * Tests that the unanalyzed product card is hidden if a product already has an up-to-date analysis.
+ * Other analysis details should be visible.
+ */
+add_task(async function test_analyzed_product() {
+ await BrowserTestUtils.withNewTab(
+ {
+ url: "about:shoppingsidebar",
+ gBrowser,
+ },
+ async browser => {
+ let shoppingContainer = await getAnalysisDetails(
+ browser,
+ MOCK_ANALYZED_PRODUCT_RESPONSE
+ );
+
+ ok(
+ !shoppingContainer.unanalyzedProductEl,
+ "unanalyzed-product-card should not be visible"
+ );
+
+ verifyAnalysisDetailsVisible(shoppingContainer);
+ verifyFooterVisible(shoppingContainer);
+ }
+ );
+});
+
+/**
+ * Tests that the unanalyzed product card appears if a product has no grade,
+ * even if a product id is available.
+ * Settings should be the only other component that is visible.
+ */
+add_task(async function test_ungraded_product() {
+ await BrowserTestUtils.withNewTab(
+ {
+ url: "about:shoppingsidebar",
+ gBrowser,
+ },
+ async browser => {
+ let shoppingContainer = await getAnalysisDetails(
+ browser,
+ MOCK_UNGRADED_PRODUCT_RESPONSE
+ );
+
+ ok(
+ shoppingContainer.unanalyzedProductEl,
+ "Got the unanalyzed-product-card element"
+ );
+
+ verifyAnalysisDetailsHidden(shoppingContainer);
+ verifyFooterVisible(shoppingContainer);
+ }
+ );
+});
diff --git a/browser/components/shopping/tests/browser/browser_unavailable_product.js b/browser/components/shopping/tests/browser/browser_unavailable_product.js
new file mode 100644
index 0000000000..96f82ae296
--- /dev/null
+++ b/browser/components/shopping/tests/browser/browser_unavailable_product.js
@@ -0,0 +1,102 @@
+/* Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests that the correct shopping-message-bar component appears if a product was marked as unavailable.
+ */
+add_task(async function test_unavailable_product() {
+ await BrowserTestUtils.withNewTab(
+ {
+ url: "about:shoppingsidebar",
+ gBrowser,
+ },
+ async browser => {
+ await SpecialPowers.spawn(
+ browser,
+ [MOCK_UNAVAILABLE_PRODUCT_RESPONSE],
+ async mockData => {
+ let shoppingContainer =
+ content.document.querySelector(
+ "shopping-container"
+ ).wrappedJSObject;
+ shoppingContainer.data = Cu.cloneInto(mockData, content);
+ await shoppingContainer.updateComplete;
+
+ let productNotAvailableMessageBar =
+ shoppingContainer.shoppingMessageBarEl;
+
+ ok(productNotAvailableMessageBar, "Got shopping-message-bar element");
+ is(
+ productNotAvailableMessageBar?.getAttribute("type"),
+ "product-not-available",
+ "shopping-message-bar type should be correct"
+ );
+
+ let productAvailableBtn =
+ productNotAvailableMessageBar?.productAvailableBtnEl;
+
+ ok(productAvailableBtn, "Got report product available button");
+
+ let thanksForReportMessageBarVisible =
+ ContentTaskUtils.waitForCondition(() => {
+ return (
+ !!shoppingContainer.shoppingMessageBarEl &&
+ ContentTaskUtils.isVisible(
+ shoppingContainer.shoppingMessageBarEl
+ )
+ );
+ }, "Waiting for shopping-message-bar to be visible");
+
+ productAvailableBtn.click();
+
+ await thanksForReportMessageBarVisible;
+
+ is(
+ shoppingContainer.shoppingMessageBarEl?.getAttribute("type"),
+ "thanks-for-reporting",
+ "shopping-message-bar type should be correct"
+ );
+ }
+ );
+ }
+ );
+});
+
+/**
+ * Tests that the correct shopping-message-bar component appears if a product marked as unavailable
+ * was reported to be back in stock by another user.
+ */
+add_task(async function test_unavailable_product_reported() {
+ await BrowserTestUtils.withNewTab(
+ {
+ url: "about:shoppingsidebar",
+ gBrowser,
+ },
+ async browser => {
+ await SpecialPowers.spawn(
+ browser,
+ [MOCK_UNAVAILABLE_PRODUCT_REPORTED_RESPONSE],
+ async mockData => {
+ let shoppingContainer =
+ content.document.querySelector(
+ "shopping-container"
+ ).wrappedJSObject;
+ shoppingContainer.data = Cu.cloneInto(mockData, content);
+ await shoppingContainer.updateComplete;
+
+ ok(
+ shoppingContainer.shoppingMessageBarEl,
+ "Got shopping-message-bar element"
+ );
+ is(
+ shoppingContainer.shoppingMessageBarEl?.getAttribute("type"),
+ "product-not-available-reported",
+ "shopping-message-bar type should be correct"
+ );
+ }
+ );
+ }
+ );
+});
diff --git a/browser/components/shopping/tests/browser/head.js b/browser/components/shopping/tests/browser/head.js
new file mode 100644
index 0000000000..49367fd58b
--- /dev/null
+++ b/browser/components/shopping/tests/browser/head.js
@@ -0,0 +1,225 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+Services.scriptloader.loadSubScript(
+ "chrome://mochitests/content/browser/toolkit/components/shopping/test/browser/head.js",
+ this
+);
+
+const { sinon } = ChromeUtils.importESModule(
+ "resource://testing-common/Sinon.sys.mjs"
+);
+
+const MOCK_UNPOPULATED_DATA = {
+ adjusted_rating: null,
+ grade: null,
+ highlights: null,
+};
+
+const MOCK_POPULATED_DATA = {
+ adjusted_rating: 5,
+ grade: "B",
+ highlights: {
+ price: {
+ positive: ["This watch is great and the price was even better."],
+ negative: [],
+ neutral: [],
+ },
+ quality: {
+ positive: [
+ "Other than that, I am very impressed with the watch and it’s capabilities.",
+ "This watch performs above expectations in every way with the exception of the heart rate monitor.",
+ ],
+ negative: [
+ "Battery life is no better than the 3 even with the solar gimmick, probably worse.",
+ ],
+ neutral: [
+ "I have small wrists and still went with the 6X and glad I did.",
+ "I can deal with the looks, as Im now retired.",
+ ],
+ },
+ competitiveness: {
+ positive: [
+ "Bought this to replace my vivoactive 3.",
+ "I like that this watch has so many features, especially those that monitor health like SP02, respiration, sleep, HRV status, stress, and heart rate.",
+ ],
+ negative: [
+ "I do not use it for sleep or heartrate monitoring so not sure how accurate they are.",
+ ],
+ neutral: [
+ "I've avoided getting a smartwatch for so long due to short battery life on most of them.",
+ ],
+ },
+ "packaging/appearance": {
+ positive: ["Great cardboard box."],
+ negative: [],
+ neutral: [],
+ },
+ shipping: {
+ positive: [],
+ negative: [],
+ neutral: [],
+ },
+ },
+};
+
+const MOCK_INVALID_KEY_OBJ = {
+ invalidHighlight: {
+ negative: ["This is an invalid highlight and should not be visible"],
+ },
+ shipping: {
+ positive: [],
+ negative: [],
+ neutral: [],
+ },
+};
+
+const MOCK_UNANALYZED_PRODUCT_RESPONSE = {
+ ...MOCK_UNPOPULATED_DATA,
+ product_id: null,
+ needs_analysis: true,
+};
+
+const MOCK_STALE_PRODUCT_RESPONSE = {
+ ...MOCK_POPULATED_DATA,
+ product_id: "ABCD123",
+ grade: "A",
+ needs_analysis: true,
+};
+
+const MOCK_UNGRADED_PRODUCT_RESPONSE = {
+ ...MOCK_UNPOPULATED_DATA,
+ product_id: "ABCD123",
+ needs_analysis: true,
+};
+
+const MOCK_NOT_ENOUGH_REVIEWS_PRODUCT_RESPONSE = {
+ ...MOCK_UNPOPULATED_DATA,
+ product_id: "ABCD123",
+ needs_analysis: false,
+ not_enough_reviews: true,
+};
+
+const MOCK_ANALYZED_PRODUCT_RESPONSE = {
+ ...MOCK_POPULATED_DATA,
+ product_id: "ABCD123",
+ needs_analysis: false,
+};
+
+const MOCK_UNAVAILABLE_PRODUCT_RESPONSE = {
+ ...MOCK_POPULATED_DATA,
+ product_id: "ABCD123",
+ deleted_product: true,
+};
+
+const MOCK_UNAVAILABLE_PRODUCT_REPORTED_RESPONSE = {
+ ...MOCK_UNAVAILABLE_PRODUCT_RESPONSE,
+ deleted_product_reported: true,
+};
+
+const MOCK_PAGE_NOT_SUPPORTED_RESPONSE = {
+ ...MOCK_UNPOPULATED_DATA,
+ page_not_supported: true,
+};
+
+const MOCK_RECOMMENDED_ADS_RESPONSE = [
+ {
+ name: "VIVO Electric 60 x 24 inch Stand Up Desk | Black Table Top, Black Frame, Height Adjustable Standing Workstation with Memory Preset Controller (DESK-KIT-1B6B)",
+ url: "www.example.com",
+ price: "249.99",
+ currency: "USD",
+ grade: "A",
+ adjusted_rating: 4.6,
+ sponsored: true,
+ image_blob: new Blob(new Uint8Array(), { type: "image/jpeg" }),
+ },
+];
+
+function verifyAnalysisDetailsVisible(shoppingContainer) {
+ ok(
+ shoppingContainer.reviewReliabilityEl,
+ "review-reliability should be visible"
+ );
+ ok(shoppingContainer.adjustedRatingEl, "adjusted-rating should be visible");
+ ok(shoppingContainer.highlightsEl, "review-highlights should be visible");
+}
+
+function verifyAnalysisDetailsHidden(shoppingContainer) {
+ ok(
+ !shoppingContainer.reviewReliabilityEl,
+ "review-reliability should not be visible"
+ );
+ ok(
+ !shoppingContainer.adjustedRatingEl,
+ "adjusted-rating should not be visible"
+ );
+ ok(
+ !shoppingContainer.highlightsEl,
+ "review-highlights should not be visible"
+ );
+}
+
+function verifyFooterVisible(shoppingContainer) {
+ ok(shoppingContainer.settingsEl, "Got the shopping-settings element");
+ ok(
+ shoppingContainer.analysisExplainerEl,
+ "Got the analysis-explainer element"
+ );
+}
+
+function verifyFooterHidden(shoppingContainer) {
+ ok(!shoppingContainer.settingsEl, "Do not render shopping-settings element");
+ ok(
+ !shoppingContainer.analysisExplainerEl,
+ "Do not render the analysis-explainer element"
+ );
+}
+
+function getAnalysisDetails(browser, data) {
+ return SpecialPowers.spawn(browser, [data], async mockData => {
+ let shoppingContainer =
+ content.document.querySelector("shopping-container").wrappedJSObject;
+ shoppingContainer.data = Cu.cloneInto(mockData, content);
+ await shoppingContainer.updateComplete;
+ let returnState = {};
+ for (let el of [
+ "unanalyzedProductEl",
+ "reviewReliabilityEl",
+ "analysisExplainerEl",
+ "adjustedRatingEl",
+ "highlightsEl",
+ "settingsEl",
+ "shoppingMessageBarEl",
+ "loadingEl",
+ ]) {
+ returnState[el] =
+ !!shoppingContainer[el] &&
+ ContentTaskUtils.isVisible(shoppingContainer[el]);
+ }
+ returnState.shoppingMessageBarType =
+ shoppingContainer.shoppingMessageBarEl?.getAttribute("type");
+ returnState.isOffline = shoppingContainer.isOffline;
+ return returnState;
+ });
+}
+
+function getSettingsDetails(browser, data) {
+ return SpecialPowers.spawn(browser, [data], async mockData => {
+ let shoppingContainer =
+ content.document.querySelector("shopping-container").wrappedJSObject;
+ shoppingContainer.data = Cu.cloneInto(mockData, content);
+ await shoppingContainer.updateComplete;
+ let shoppingSettings = shoppingContainer.settingsEl;
+ await shoppingSettings.updateComplete;
+ let returnState = {
+ settingsEl:
+ !!shoppingSettings && ContentTaskUtils.isVisible(shoppingSettings),
+ };
+ for (let el of ["recommendationsToggleEl", "optOutButtonEl"]) {
+ returnState[el] =
+ !!shoppingSettings[el] &&
+ ContentTaskUtils.isVisible(shoppingSettings[el]);
+ }
+ return returnState;
+ });
+}