chromium/chrome/browser/resources/commerce/product_specifications/app.ts

// Copyright 2024 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import '../strings.m.js';
import './header.js';
import './loading_state.js';
import './new_column_selector.js';
import './product_selector.js';
import './table.js';
import './horizontal_carousel.js';
import 'chrome://resources/cr_elements/cr_hidden_style.css.js';
import 'chrome://resources/cr_elements/cr_feedback_buttons/cr_feedback_buttons.js';
import 'chrome://resources/cr_elements/cr_toast/cr_toast.js';

import {ColorChangeUpdater} from 'chrome://resources/cr_components/color_change_listener/colors_css_updater.js';
import type {BrowserProxy} from 'chrome://resources/cr_components/commerce/browser_proxy.js';
import {BrowserProxyImpl} from 'chrome://resources/cr_components/commerce/browser_proxy.js';
import type {PageCallbackRouter, ProductSpecificationsFeatureState, ProductSpecificationsSet} from 'chrome://resources/cr_components/commerce/shopping_service.mojom-webui.js';
import type {CrButtonElement} from 'chrome://resources/cr_elements/cr_button/cr_button.js';
import {CrFeedbackOption} from 'chrome://resources/cr_elements/cr_feedback_buttons/cr_feedback_buttons.js';
import type {CrToastElement} from 'chrome://resources/cr_elements/cr_toast/cr_toast.js';
import {assert} from 'chrome://resources/js/assert.js';
import {EventTracker} from 'chrome://resources/js/event_tracker.js';
import {loadTimeData} from 'chrome://resources/js/load_time_data.js';
import {OpenWindowProxyImpl} from 'chrome://resources/js/open_window_proxy.js';
import type {Uuid} from 'chrome://resources/mojo/mojo/public/mojom/base/uuid.mojom-webui.js';
import {PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';

import {getTemplate} from './app.html.js';
import type {BuyingOptionsLink} from './buying_options_section.js';
import type {ProductDescription} from './description_section.js';
import type {HeaderElement} from './header.js';
import type {NewColumnSelectorElement} from './new_column_selector.js';
import {SectionType} from './product_selection_menu.js';
import type {ProductSelectorElement} from './product_selector.js';
import {Router} from './router.js';
import type {PriceInsightsInfo, ProductInfo, ProductSpecifications, ProductSpecificationsProduct} from './shopping_service.mojom-webui.js';
import {UserFeedback} from './shopping_service.mojom-webui.js';
import type {TableElement} from './table.js';
import type {UrlListEntry} from './utils.js';
import {WindowProxy} from './window_proxy.js';

interface AggregatedProductData {
  priceInsightsInfo: PriceInsightsInfo|null;
  productInfo: ProductInfo|null;
  spec: ProductSpecificationsProduct|null;
}

interface LoadingState {
  loading: boolean;
  urlCount: number;
}

export type Content = string|ProductDescription|BuyingOptionsLink|null;

interface ProductDetail {
  title: string|null;
  content: Content;
}

export interface TableColumn {
  selectedItem: UrlListEntry;
  productDetails: ProductDetail[]|null;
}

export interface ProductSpecificationsElement {
  $: {
    empty: HTMLElement,
    error: HTMLElement,
    header: HeaderElement,
    loading: HTMLElement,
    newColumnSelector: NewColumnSelectorElement,
    offlineToast: CrToastElement,
    productSelector: ProductSelectorElement,
    specs: HTMLElement,
    summaryTable: TableElement,
    syncPromo: HTMLElement,
    turnOnSyncButton: CrButtonElement,
  };
}

// This enum is used for metrics and should be kept in sync with the enum of
// the same name in enums.xml.
export enum CompareTableColumnAction {
  REMOVE = 0,
  CHANGE_ORDER_DRAG_AND_DROP = 1,
  ADD_FROM_SUGGESTED = 2,
  UPDATE_FROM_SUGGESTED = 3,
  ADD_FROM_RECENTLY_VIEWED = 4,
  UPDATE_FROM_RECENTLY_VIEWED = 5,
  // Must be last:
  MAX_VALUE = 6,
}

export const COLUMN_MODIFICATION_HISTOGRAM_NAME: string =
    'Commerce.Compare.Table.ColumnModification';

enum AppState {
  ERROR = 0,
  TABLE_EMPTY = 1,
  SYNC_SCREEN = 2,
  TABLE_POPULATED = 3,
  LOADING = 4,
}

function getProductDetails(
    product: ProductSpecificationsProduct|null,
    productSpecs: ProductSpecifications, productInfo: ProductInfo|null,
    priceInsightsInfo: PriceInsightsInfo|null): ProductDetail[] {
  const productDetails: ProductDetail[] = [];

  // First add rows that don't come directly from the product
  // specifications backend.
  productDetails.push({
    title: loadTimeData.getString('priceRowTitle'),
    content: productInfo?.currentPrice || null,
  });

  // The second row is the product-level summary.
  productDetails.push({
    title: loadTimeData.getString('productSummaryRowTitle'),
    content: {
      attributes: [],
      summary: product?.summary || [],
    },
  });

  productSpecs.productDimensionMap.forEach((title: string, key: bigint) => {
    if (!product) {
      // Fill in missing product details to ensure uniform table row count.
      productDetails.push({title, content: null});
    } else {
      const value = product.productDimensionValues.get(key);
      const attributes =
          (value?.specificationDescriptions || []).flatMap(description => {
            return {
              label: description.label,
              value: description.options.flatMap(option => option.descriptions)
                         .flatMap(desc => desc.text)
                         .join(', '),
            };
          }) ||
          [];
      const summary = value?.summary || [];
      productDetails.push({
        title,
        content: {
          attributes,
          summary,
        },
      });
    }
  });

  // The last row is buying options.
  productDetails.push({
    title: null,
    content: {jackpotUrl: priceInsightsInfo?.jackpot.url ?? ''},
  });

  return productDetails;
}

function areStatesEqual(
    firstState: ProductSpecificationsFeatureState,
    secondState: ProductSpecificationsFeatureState) {
  return firstState.isSyncingTabCompare === secondState.isSyncingTabCompare &&
      firstState.canLoadFullPageUi === secondState.canLoadFullPageUi &&
      firstState.canManageSets === secondState.canManageSets &&
      firstState.canFetchData === secondState.canFetchData &&
      firstState.isAllowedForEnterprise === secondState.isAllowedForEnterprise;
}

function findProductInResults(clusterId: bigint, specs: ProductSpecifications):
    ProductSpecificationsProduct|null {
  if (!specs) {
    return null;
  }

  for (const product of specs.products) {
    if (product.productClusterId.toString() === clusterId.toString()) {
      return product;
    }
  }

  return null;
}

export class ProductSpecificationsElement extends PolymerElement {
  static get is() {
    return 'product-specifications-app';
  }

  static get template() {
    return getTemplate();
  }

  static get properties() {
    return {
      appState_: {
        type: Object,
        computed: 'computeAppState_(productSpecificationsFeatureState_.*,' +
            ' loadingState_.loading, showEmptyState_)',
      },
      loadingState_: Object,
      setName_: String,
      showTableDataUnavailableContainer_: {
        type: Boolean,
        computed: 'computeShowTableDataUnavailableContainer_(appState_)',
        reflectToAttribute: true,
      },
      tableColumns_: Object,
    };
  }

  private appState_: AppState = AppState.LOADING;
  private loadingState_: LoadingState = {loading: false, urlCount: 0};
  private setName_: string|null = null;
  private showTableDataUnavailableContainer_: boolean;
  private tableColumns_: TableColumn[] = [];

  private callbackRouter_: PageCallbackRouter;
  private eventTracker_: EventTracker = new EventTracker();
  private id_: Uuid|null = null;
  private listenerIds_: number[] = [];
  private minLoadingAnimationMs_: number = 500;
  private productSpecificationsFeatureState_: ProductSpecificationsFeatureState;
  private shoppingApi_: BrowserProxy = BrowserProxyImpl.getInstance();
  private showEmptyState_: boolean;

  constructor() {
    super();
    this.callbackRouter_ = this.shoppingApi_.getCallbackRouter();
    ColorChangeUpdater.forDocument().start();
  }

  override async connectedCallback() {
    super.connectedCallback();

    this.listenerIds_.push(
        this.callbackRouter_.onProductSpecificationsSetRemoved.addListener(
            (uuid: Uuid) => this.onSetRemoved_(uuid)),
        this.callbackRouter_.onProductSpecificationsSetUpdated.addListener(
            (set: ProductSpecificationsSet) => this.onSetUpdated_(set)));

    // TODO: b/358131415 - use listeners to update. Temporary workaround uses
    // window focus to update the feature state, to check signin.
    window.addEventListener('focus', async () => {
      const previousState = this.productSpecificationsFeatureState_;
      const {state} =
          await this.shoppingApi_.getProductSpecificationsFeatureState();
      if (!state || areStatesEqual(previousState, state)) {
        return;
      }

      // States have changed, so we need to reload the table.
      // Update the featureState after loadTable_(), so that the loading
      // state will animate first.
      await this.loadTable_(state);
      this.productSpecificationsFeatureState_ = state;
    });

    this.eventTracker_.add(
        this, 'click',
        () => {
          this.$.offlineToast.hide();
        },
        /*useCapture=*/ true);
    this.eventTracker_.add(window, 'online', () => {
      this.$.offlineToast.hide();
    });

    if (this.isOffline_) {
      this.showOfflineToast_();
      return;
    }

    // TODO(b/358131415): update after we use listener/ observers and no longer
    // need the featureState
    const {state} =
        await this.shoppingApi_.getProductSpecificationsFeatureState();
    if (!state) {
      return;
    }
    await this.loadTable_(state);
    this.productSpecificationsFeatureState_ = state;
  }

  override disconnectedCallback() {
    super.disconnectedCallback();
    this.listenerIds_.forEach(id => this.callbackRouter_.removeListener(id));
    this.eventTracker_.removeAll();
  }

  resetMinLoadingAnimationMsForTesting(newValue = 0) {
    this.minLoadingAnimationMs_ = newValue;
  }

  private async loadTable_(state: ProductSpecificationsFeatureState) {
    // Don't load the table if access conditions are not met.
    if (!(state.isSyncingTabCompare && state.canLoadFullPageUi &&
          state.canFetchData && state.isAllowedForEnterprise)) {
      return;
    }

    const router = Router.getInstance();
    const params = new URLSearchParams(router.getCurrentQuery());
    const idParam = params.get('id');
    if (idParam) {
      this.id_ = {value: idParam};
      const {set} = await this.shoppingApi_.getProductSpecificationsSetByUuid(
          {value: idParam});
      if (set) {
        document.title = set.name;
        this.setName_ = set.name;
        this.populateTable_(set.urls.map(url => (url.url)));
        return;
      }
    }

    const urlsParam = params.get('urls');
    if (!urlsParam) {
      this.showEmptyState_ = true;
      return;
    }

    let urls: string[] = [];
    try {
      urls = JSON.parse(urlsParam);
    } catch (_) {
      return;
    }

    // TODO(b/346601645): Detect if a set already exists
    await this.createNewSet_(urls);
  }

  private computeAppState_() {
    if (this.productSpecificationsFeatureState_) {
      if (!this.productSpecificationsFeatureState_.isSyncingTabCompare) {
        return AppState.SYNC_SCREEN;
      }
      if (!(this.productSpecificationsFeatureState_.canLoadFullPageUi &&
            this.productSpecificationsFeatureState_.canFetchData &&
            this.productSpecificationsFeatureState_.isAllowedForEnterprise)) {
        return AppState.ERROR;
      }
      if (this.loadingState_.loading) {
        return AppState.LOADING;
      }
      if (this.showEmptyState_) {
        return AppState.TABLE_EMPTY;
      }
      return AppState.TABLE_POPULATED;
    }
    return AppState.ERROR;
  }

  private isAppStateError_() {
    return this.appState_ === AppState.ERROR;
  }

  private isAppStateTableEmpty_() {
    return this.appState_ === AppState.TABLE_EMPTY;
  }

  private isAppStateSyncScreen_() {
    return this.appState_ === AppState.SYNC_SCREEN;
  }

  private isAppStateTablePopulated_() {
    return this.appState_ === AppState.TABLE_POPULATED;
  }

  private isAppStateLoading_() {
    return this.appState_ === AppState.LOADING;
  }

  private computeShowTableDataUnavailableContainer_() {
    return this.appState_ === AppState.ERROR ||
        this.appState_ === AppState.TABLE_EMPTY ||
        this.appState_ === AppState.SYNC_SCREEN;
  }

  private canShowFeedbackButtons_() {
    return Boolean(
        this.productSpecificationsFeatureState_?.isQualityLoggingAllowed);
  }

  private showSyncSetupFlow_() {
    assert(this.productSpecificationsFeatureState_);
    assert(!this.productSpecificationsFeatureState_.isSyncingTabCompare);

    // If user's already signed in at the account level, then user needs to turn
    // on the compare-specific sync from settings.
    if (this.productSpecificationsFeatureState_.isSignedIn) {
      OpenWindowProxyImpl.getInstance().openUrl(
          'chrome://settings/syncSetup/advanced');
      return;
    }
    this.shoppingApi_.showSyncSetupFlow();
  }

  private showOfflineToast_() {
    this.$.offlineToast.show();
  }

  private async populateTable_(urls: string[]) {
    const start = Date.now();
    this.showEmptyState_ = false;
    this.loadingState_ = {loading: true, urlCount: urls.length};

    const tableColumns: TableColumn[] = [];
    if (urls.length) {
      const {productSpecs} =
          await this.shoppingApi_.getProductSpecificationsForUrls(
              urls.map(url => ({url})));
      const aggregatedDataByUrl =
          await this.aggregateProductDataByUrl_(urls, productSpecs);


      await Promise.all(urls.map(async (url: string) => {
        const info = aggregatedDataByUrl.get(url)?.productInfo;
        const product = aggregatedDataByUrl.get(url)?.spec;
        const title = product?.title || info?.title ||
            (await this.shoppingApi_.getPageTitleFromHistory({url})).title;

        tableColumns.push({
          selectedItem: {
            title,
            url,
            imageUrl: info?.imageUrl?.url || product?.imageUrl?.url || '',
          },
          productDetails: getProductDetails(
              product || null, productSpecs, info || null,
              aggregatedDataByUrl.get(url)?.priceInsightsInfo || null),
        });
      }));
    }

    // Enforce a minimum amount of time in the loading state to avoid it
    // appearing like an unintentional flash.
    const delta = Date.now() - start;
    if (delta < this.minLoadingAnimationMs_ && urls.length > 0) {
      await new Promise(
          res => setTimeout(res, this.minLoadingAnimationMs_ - delta));
    }

    this.tableColumns_ = tableColumns;
    this.showEmptyState_ = this.tableColumns_.length === 0;
    this.loadingState_ = {loading: false, urlCount: 0};
  }

  private get isOffline_(): boolean {
    return !WindowProxy.getInstance().onLine;
  }

  private async getPriceInsightsInfoForUrls_(urls: string[]):
      Promise<Map<string, PriceInsightsInfo>> {
    const infoMap: Map<string, PriceInsightsInfo> = new Map();
    for (const url of urls) {
      const {priceInsightsInfo} =
          await this.shoppingApi_.getPriceInsightsInfoForUrl({url});
      if (priceInsightsInfo && priceInsightsInfo.clusterId) {
        infoMap.set(url, priceInsightsInfo);
      }
    }
    return infoMap;
  }

  private async getProductInfoForUrls_(urls: string[]):
      Promise<Map<string, ProductInfo>> {
    const infoMap: Map<string, ProductInfo> = new Map();
    for (const url of urls) {
      const {productInfo} = await this.shoppingApi_.getProductInfoForUrl({url});
      if (productInfo && productInfo.clusterId) {
        infoMap.set(url, productInfo);
      }
    }
    return infoMap;
  }

  private async aggregateProductDataByUrl_(
      urls: string[], specs: ProductSpecifications):
      Promise<Map<string, AggregatedProductData>> {
    const urlToPriceInsightsInfoMap: Map<string, PriceInsightsInfo> =
        await this.getPriceInsightsInfoForUrls_(urls);
    const urlToProductInfoMap: Map<string, ProductInfo> =
        await this.getProductInfoForUrls_(urls);
    const specProductMap: Map<string, ProductSpecificationsProduct> = new Map();
    urlToProductInfoMap.forEach((value, key) => {
      const product = findProductInResults(value.clusterId, specs);
      if (product) {
        specProductMap.set(key, product);
      }
    });

    const aggregatedDatas: Map<string, AggregatedProductData> = new Map();
    urls.forEach((url) => {
      const priceInsightsInfo = urlToPriceInsightsInfoMap.get(url);
      const productInfo = urlToProductInfoMap.get(url);
      const productSpecs = specProductMap.get(url);
      aggregatedDatas.set(url, {
        priceInsightsInfo: priceInsightsInfo ?? null,
        productInfo: productInfo ?? null,
        spec: productSpecs ?? null,
      });
    });
    return aggregatedDatas;
  }

  private deleteSet_() {
    if (this.isOffline_) {
      this.showOfflineToast_();
      return;
    }

    if (this.id_) {
      this.shoppingApi_.deleteProductSpecificationsSet(this.id_);
    }
  }

  private updateSetName_(e: CustomEvent<{name: string}>) {
    if (this.isOffline_) {
      this.showOfflineToast_();
      return;
    }

    if (this.id_) {
      this.shoppingApi_.setNameForProductSpecificationsSet(
          this.id_, e.detail.name);
    }
  }

  private seeAllSets_() {
    OpenWindowProxyImpl.getInstance().openUrl(
        loadTimeData.getString('productSpecificationsManagementUrl'));
  }

  private async onUrlAdd_(
      e: CustomEvent<{url: string, urlSection: SectionType}>) {
    if (this.isOffline_) {
      this.showOfflineToast_();
      return;
    }

    let recordValue = CompareTableColumnAction.MAX_VALUE;
    switch (e.detail.urlSection) {
      case SectionType.RECENT:
        recordValue = CompareTableColumnAction.ADD_FROM_RECENTLY_VIEWED;
        break;
      case SectionType.SUGGESTED:
        recordValue = CompareTableColumnAction.ADD_FROM_SUGGESTED;
        break;
    }
    chrome.metricsPrivate.recordEnumerationValue(
        COLUMN_MODIFICATION_HISTOGRAM_NAME, recordValue,
        CompareTableColumnAction.MAX_VALUE);

    const urls = this.getTableUrls_();
    urls.push(e.detail.url);
    // If there is already a current set, we won't be showing the disclosure and
    // we can modify the set directly; otherwise, user is trying to add a url
    // from empty state, and we'll try to show the disclosure.
    if (this.id_) {
      this.modifyUrls_(urls);
      return;
    }
    const {disclosureShown} =
        await this.shoppingApi_.maybeShowProductSpecificationDisclosure(
            urls.map(url => ({url})), this.setName_ ? this.setName_ : '');
    // If the disclosure is shown, we won't update the current set.
    if (!disclosureShown) {
      this.modifyUrls_(urls);
    }
  }

  private onUrlChange_(
      e: CustomEvent<{url: string, urlSection: SectionType, index: number}>) {
    if (this.isOffline_) {
      this.showOfflineToast_();
      return;
    }

    let recordValue = CompareTableColumnAction.MAX_VALUE;
    switch (e.detail.urlSection) {
      case SectionType.RECENT:
        recordValue = CompareTableColumnAction.UPDATE_FROM_RECENTLY_VIEWED;
        break;
      case SectionType.SUGGESTED:
        recordValue = CompareTableColumnAction.UPDATE_FROM_SUGGESTED;
        break;
    }
    chrome.metricsPrivate.recordEnumerationValue(
        COLUMN_MODIFICATION_HISTOGRAM_NAME, recordValue,
        CompareTableColumnAction.MAX_VALUE);

    const urls = this.getTableUrls_();
    urls[e.detail.index] = e.detail.url;
    this.modifyUrls_(urls);
  }

  private onUrlOrderUpdate_() {
    if (this.isOffline_) {
      this.showOfflineToast_();
      return;
    }

    chrome.metricsPrivate.recordEnumerationValue(
        COLUMN_MODIFICATION_HISTOGRAM_NAME,
        CompareTableColumnAction.CHANGE_ORDER_DRAG_AND_DROP,
        CompareTableColumnAction.MAX_VALUE);

    const urls = this.getTableUrls_();
    this.modifyUrls_(urls);
  }

  private onUrlRemove_(e: CustomEvent<{index: number}>) {
    if (this.isOffline_) {
      this.showOfflineToast_();
      return;
    }

    chrome.metricsPrivate.recordEnumerationValue(
        COLUMN_MODIFICATION_HISTOGRAM_NAME, CompareTableColumnAction.REMOVE,
        CompareTableColumnAction.MAX_VALUE);

    const urls = this.getTableUrls_();
    urls.splice(e.detail.index, 1);
    this.modifyUrls_(urls);
  }

  private modifyUrls_(urls: string[]) {
    if (this.id_) {
      this.shoppingApi_.setUrlsForProductSpecificationsSet(
          this.id_!, urls.map(url => ({url})));
    } else {
      this.createNewSet_(urls);
    }
  }

  private async createNewSet_(urls: string[]) {
    if (this.isOffline_) {
      this.showOfflineToast_();
      return;
    }

    assert(!this.id_ && !this.setName_);
    this.setName_ = loadTimeData.getString('defaultTableTitle');
    const {createdSet} = await this.shoppingApi_.addProductSpecificationsSet(
        this.setName_, urls.map(url => ({url})));
    if (createdSet) {
      this.id_ = createdSet.uuid;
      window.history.replaceState(undefined, '', `?id=${this.id_.value}`);
    }
    this.populateTable_(urls);
  }

  private getTableUrls_(): string[] {
    return this.tableColumns_.map(
        (column: TableColumn) => column.selectedItem.url);
  }

  private onSetUpdated_(set: ProductSpecificationsSet) {
    if (set.uuid.value !== this.id_?.value) {
      return;
    }
    document.title = set.name;
    this.setName_ = set.name;

    let urlSetChanged = false;
    let orderChanged = false;
    const tableUrls = this.getTableUrls_();

    if (tableUrls.length === set.urls.length) {
      for (const [i, setUrl] of set.urls.entries()) {
        if (setUrl.url !== tableUrls[i]) {
          orderChanged = true;
        }

        if (!tableUrls.includes(setUrl.url)) {
          urlSetChanged = true;
          break;
        }
      }
    } else {
      urlSetChanged = true;
    }

    if (urlSetChanged) {
      this.populateTable_(set.urls.map(url => url.url));
    } else if (orderChanged) {
      const newCols: TableColumn[] = [];

      for (const [_, setUrl] of set.urls.entries()) {
        const existingIndex = tableUrls.indexOf(setUrl.url);
        assert(existingIndex >= 0, 'Did not find column to reorder!');

        newCols.push(this.tableColumns_[existingIndex]);
      }

      this.tableColumns_ = newCols;
    }
  }

  private onSetRemoved_(id: Uuid) {
    if (id.value === this.id_?.value) {
      window.location.replace(window.location.origin);
    }
  }

  private onFeedbackSelectedOptionChanged_(
      e: CustomEvent<{value: CrFeedbackOption}>) {
    switch (e.detail.value) {
      case CrFeedbackOption.UNSPECIFIED:
        this.shoppingApi_.setProductSpecificationsUserFeedback(
            UserFeedback.kUnspecified);
        return;
      case CrFeedbackOption.THUMBS_UP:
        this.shoppingApi_.setProductSpecificationsUserFeedback(
            UserFeedback.kThumbsUp);
        return;
      case CrFeedbackOption.THUMBS_DOWN:
        this.shoppingApi_.setProductSpecificationsUserFeedback(
            UserFeedback.kThumbsDown);
        return;
    }
  }

  private getDisclaimerText_(): string {
    return loadTimeData.getStringF(
        'experimentalFeatureDisclaimer', loadTimeData.getString('userEmail'));
  }
}

declare global {
  interface HTMLElementTagNameMap {
    'product-specifications-app': ProductSpecificationsElement;
  }
}

customElements.define(
    ProductSpecificationsElement.is, ProductSpecificationsElement);