summaryrefslogtreecommitdiffstats
path: root/browser/components/payments/res/containers
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-28 14:29:10 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-28 14:29:10 +0000
commit2aa4a82499d4becd2284cdb482213d541b8804dd (patch)
treeb80bf8bf13c3766139fbacc530efd0dd9d54394c /browser/components/payments/res/containers
parentInitial commit. (diff)
downloadfirefox-2aa4a82499d4becd2284cdb482213d541b8804dd.tar.xz
firefox-2aa4a82499d4becd2284cdb482213d541b8804dd.zip
Adding upstream version 86.0.1.upstream/86.0.1upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to '')
-rw-r--r--browser/components/payments/res/containers/address-form.css55
-rw-r--r--browser/components/payments/res/containers/address-form.js447
-rw-r--r--browser/components/payments/res/containers/address-picker.js282
-rw-r--r--browser/components/payments/res/containers/basic-card-form.css43
-rw-r--r--browser/components/payments/res/containers/basic-card-form.js507
-rw-r--r--browser/components/payments/res/containers/billing-address-picker.js33
-rw-r--r--browser/components/payments/res/containers/completion-error-page.js112
-rw-r--r--browser/components/payments/res/containers/cvv-hint-image-back.svg27
-rw-r--r--browser/components/payments/res/containers/cvv-hint-image-front.svg25
-rw-r--r--browser/components/payments/res/containers/error-page.css42
-rw-r--r--browser/components/payments/res/containers/order-details.css55
-rw-r--r--browser/components/payments/res/containers/order-details.js143
-rw-r--r--browser/components/payments/res/containers/payment-dialog.js593
-rw-r--r--browser/components/payments/res/containers/payment-method-picker.js199
-rw-r--r--browser/components/payments/res/containers/rich-picker.css83
-rw-r--r--browser/components/payments/res/containers/rich-picker.js114
-rw-r--r--browser/components/payments/res/containers/shipping-option-picker.js72
-rw-r--r--browser/components/payments/res/containers/timeout.svg84
-rw-r--r--browser/components/payments/res/containers/warning.svg32
19 files changed, 2948 insertions, 0 deletions
diff --git a/browser/components/payments/res/containers/address-form.css b/browser/components/payments/res/containers/address-form.css
new file mode 100644
index 0000000000..484610414c
--- /dev/null
+++ b/browser/components/payments/res/containers/address-form.css
@@ -0,0 +1,55 @@
+/* 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/. */
+
+.error-text {
+ color: #fff;
+ background-color: #d70022;
+ border-radius: 2px;
+ margin: 5px 3px 0 3px;
+ /* The padding-top and padding-bottom are referenced by address-form.js */ /* TODO */
+ padding: 5px 12px;
+ position: absolute;
+ z-index: 1;
+ pointer-events: none;
+ top: 100%;
+ visibility: hidden;
+}
+
+/* ::before is the error on the error text panel */
+:is(input, textarea, select) ~ .error-text::before {
+ background-color: #d70022;
+ top: -7px;
+ content: '.';
+ height: 16px;
+ position: absolute;
+ text-indent: -999px;
+ transform: rotate(45deg);
+ white-space: nowrap;
+ width: 16px;
+ z-index: -1
+}
+
+/* Position the arrow */
+.error-text:dir(ltr)::before {
+ left: 12px
+}
+
+.error-text:dir(rtl)::before {
+ right: 12px
+}
+
+:is(input, textarea, select):-moz-ui-invalid:focus ~ .error-text {
+ visibility: visible;
+}
+
+address-form > footer > .cancel-button {
+ /* When cancel is shown (during onboarding), it should always be on the left with a space after it */
+ margin-right: auto;
+}
+
+address-form > footer > .back-button {
+ /* When back is shown (outside onboarding) we want "Back <space> Add/Save" */
+ /* Bug 1468153 may change the button ordering to match platform conventions */
+ margin-right: auto;
+}
diff --git a/browser/components/payments/res/containers/address-form.js b/browser/components/payments/res/containers/address-form.js
new file mode 100644
index 0000000000..287251ea7d
--- /dev/null
+++ b/browser/components/payments/res/containers/address-form.js
@@ -0,0 +1,447 @@
+/* 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-globals-from ../../../../../browser/extensions/formautofill/content/autofillEditForms.js*/
+import LabelledCheckbox from "../components/labelled-checkbox.js";
+import PaymentRequestPage from "../components/payment-request-page.js";
+import PaymentStateSubscriberMixin from "../mixins/PaymentStateSubscriberMixin.js";
+import paymentRequest from "../paymentRequest.js";
+import HandleEventMixin from "../mixins/HandleEventMixin.js";
+/* import-globals-from ../unprivileged-fallbacks.js */
+
+/**
+ * <address-form></address-form>
+ *
+ * Don't use document.getElementById or document.querySelector* to access form
+ * elements, use querySelector on `this` or `this.form` instead so that elements
+ * can be found before the element is connected.
+ *
+ * XXX: Bug 1446164 - This form isn't localized when used via this custom element
+ * as it will be much easier to share the logic once we switch to Fluent.
+ */
+
+export default class AddressForm extends HandleEventMixin(
+ PaymentStateSubscriberMixin(PaymentRequestPage)
+) {
+ constructor() {
+ super();
+
+ this.genericErrorText = document.createElement("div");
+ this.genericErrorText.setAttribute("aria-live", "polite");
+ this.genericErrorText.classList.add("page-error");
+
+ this.cancelButton = document.createElement("button");
+ this.cancelButton.className = "cancel-button";
+ this.cancelButton.addEventListener("click", this);
+
+ this.backButton = document.createElement("button");
+ this.backButton.className = "back-button";
+ this.backButton.addEventListener("click", this);
+
+ this.saveButton = document.createElement("button");
+ this.saveButton.className = "save-button primary";
+ this.saveButton.addEventListener("click", this);
+
+ this.persistCheckbox = new LabelledCheckbox();
+ this.persistCheckbox.className = "persist-checkbox";
+
+ // Combination of AddressErrors and PayerErrors as keys
+ this._errorFieldMap = {
+ addressLine: "#street-address",
+ city: "#address-level2",
+ country: "#country",
+ dependentLocality: "#address-level3",
+ email: "#email",
+ // Bug 1472283 is on file to support
+ // additional-name and family-name.
+ // XXX: For now payer name errors go on the family-name and address-errors
+ // go on the given-name so they don't overwrite each other.
+ name: "#family-name",
+ organization: "#organization",
+ phone: "#tel",
+ postalCode: "#postal-code",
+ // Bug 1472283 is on file to support
+ // additional-name and family-name.
+ recipient: "#given-name",
+ region: "#address-level1",
+ // Bug 1474905 is on file to properly support regionCode. See
+ // full note in paymentDialogWrapper.js
+ regionCode: "#address-level1",
+ };
+
+ // The markup is shared with form autofill preferences.
+ let url = "formautofill/editAddress.xhtml";
+ this.promiseReady = this._fetchMarkup(url).then(doc => {
+ this.form = doc.getElementById("form");
+ return this.form;
+ });
+ }
+
+ _fetchMarkup(url) {
+ return new Promise((resolve, reject) => {
+ let xhr = new XMLHttpRequest();
+ xhr.responseType = "document";
+ xhr.addEventListener("error", reject);
+ xhr.addEventListener("load", evt => {
+ resolve(xhr.response);
+ });
+ xhr.open("GET", url);
+ xhr.send();
+ });
+ }
+
+ connectedCallback() {
+ this.promiseReady.then(form => {
+ this.body.appendChild(form);
+
+ let record = undefined;
+ this.formHandler = new EditAddress(
+ {
+ form,
+ },
+ record,
+ {
+ DEFAULT_REGION: PaymentDialogUtils.DEFAULT_REGION,
+ getFormFormat: PaymentDialogUtils.getFormFormat,
+ findAddressSelectOption: PaymentDialogUtils.findAddressSelectOption,
+ countries: PaymentDialogUtils.countries,
+ }
+ );
+
+ // The EditAddress constructor adds `input` event listeners on the same element,
+ // which update field validity. By adding our event listeners after this constructor,
+ // validity will be updated before our handlers get the event
+ this.form.addEventListener("input", this);
+ this.form.addEventListener("invalid", this);
+ this.form.addEventListener("change", this);
+
+ // The "invalid" event does not bubble and needs to be listened for on each
+ // form element.
+ for (let field of this.form.elements) {
+ field.addEventListener("invalid", this);
+ }
+
+ this.body.appendChild(this.persistCheckbox);
+ this.body.appendChild(this.genericErrorText);
+
+ this.footer.appendChild(this.cancelButton);
+ this.footer.appendChild(this.backButton);
+ this.footer.appendChild(this.saveButton);
+ // Only call the connected super callback(s) once our markup is fully
+ // connected, including the shared form fetched asynchronously.
+ super.connectedCallback();
+ });
+ }
+
+ render(state) {
+ if (!this.id) {
+ throw new Error("AddressForm without an id");
+ }
+ let record;
+ let { page, [this.id]: addressPage } = state;
+
+ if (this.id && page && page.id !== this.id) {
+ log.debug(`${this.id}: no need to further render inactive page`);
+ return;
+ }
+
+ let editing = !!addressPage.guid;
+ this.cancelButton.textContent = this.dataset.cancelButtonLabel;
+ this.backButton.textContent = this.dataset.backButtonLabel;
+ if (editing) {
+ this.saveButton.textContent = this.dataset.updateButtonLabel;
+ } else {
+ this.saveButton.textContent = this.dataset.nextButtonLabel;
+ }
+
+ this.persistCheckbox.label = this.dataset.persistCheckboxLabel;
+ this.persistCheckbox.infoTooltip = this.dataset.persistCheckboxInfoTooltip;
+
+ this.backButton.hidden = page.onboardingWizard;
+ this.cancelButton.hidden = !page.onboardingWizard;
+
+ this.pageTitleHeading.textContent = editing
+ ? this.dataset.titleEdit
+ : this.dataset.titleAdd;
+ this.genericErrorText.textContent = page.error;
+
+ let addresses = paymentRequest.getAddresses(state);
+
+ // If an address is selected we want to edit it.
+ if (editing) {
+ record = addresses[addressPage.guid];
+ if (!record) {
+ throw new Error(
+ "Trying to edit a non-existing address: " + addressPage.guid
+ );
+ }
+ // When editing an existing record, prevent changes to persistence
+ this.persistCheckbox.hidden = true;
+ } else {
+ let {
+ saveAddressDefaultChecked,
+ } = PaymentDialogUtils.getDefaultPreferences();
+ if (typeof saveAddressDefaultChecked != "boolean") {
+ throw new Error(`Unexpected non-boolean value for saveAddressDefaultChecked from
+ PaymentDialogUtils.getDefaultPreferences(): ${typeof saveAddressDefaultChecked}`);
+ }
+ // Adding a new record: default persistence to the pref value when in a not-private session
+ this.persistCheckbox.hidden = false;
+ this.persistCheckbox.checked = state.isPrivate
+ ? false
+ : saveAddressDefaultChecked;
+ }
+
+ let selectedStateKey = this.getAttribute("selected-state-key").split("|");
+ log.debug(`${this.id}#render got selectedStateKey: ${selectedStateKey}`);
+
+ if (addressPage.addressFields) {
+ this.form.dataset.addressFields = addressPage.addressFields;
+ } else {
+ this.form.dataset.addressFields = "mailing-address tel";
+ }
+ this.formHandler.loadRecord(record);
+
+ // Add validation to some address fields
+ this.updateRequiredState();
+
+ // Show merchant errors for the appropriate address form.
+ let merchantFieldErrors = AddressForm.merchantFieldErrorsForForm(
+ state,
+ selectedStateKey
+ );
+ for (let [errorName, errorSelector] of Object.entries(
+ this._errorFieldMap
+ )) {
+ let errorText = "";
+ // Never show errors on an 'add' screen as they would be for a different address.
+ if (editing && merchantFieldErrors) {
+ if (errorName == "region" || errorName == "regionCode") {
+ errorText =
+ merchantFieldErrors.regionCode || merchantFieldErrors.region || "";
+ } else {
+ errorText = merchantFieldErrors[errorName] || "";
+ }
+ }
+ let container = this.form.querySelector(errorSelector + "-container");
+ let field = this.form.querySelector(errorSelector);
+ field.setCustomValidity(errorText);
+ let span = paymentRequest.maybeCreateFieldErrorElement(container);
+ span.textContent = errorText;
+ }
+
+ this.updateSaveButtonState();
+ }
+
+ onChange(event) {
+ if (event.target.id == "country") {
+ this.updateRequiredState();
+ }
+ this.updateSaveButtonState();
+ }
+
+ onInvalid(event) {
+ if (event.target instanceof HTMLFormElement) {
+ this.onInvalidForm(event);
+ } else {
+ this.onInvalidField(event);
+ }
+ }
+
+ onClick(evt) {
+ switch (evt.target) {
+ case this.cancelButton: {
+ paymentRequest.cancel();
+ break;
+ }
+ case this.backButton: {
+ let currentState = this.requestStore.getState();
+ const previousId = currentState.page.previousId;
+ let state = {
+ page: {
+ id: previousId || "payment-summary",
+ },
+ };
+ if (previousId) {
+ state[previousId] = Object.assign({}, currentState[previousId], {
+ preserveFieldValues: true,
+ });
+ }
+ this.requestStore.setState(state);
+ break;
+ }
+ case this.saveButton: {
+ if (this.form.checkValidity()) {
+ this.saveRecord();
+ }
+ break;
+ }
+ default: {
+ throw new Error("Unexpected click target");
+ }
+ }
+ }
+
+ onInput(event) {
+ event.target.setCustomValidity("");
+ this.updateSaveButtonState();
+ }
+
+ /**
+ * @param {Event} event - "invalid" event
+ * Note: Keep this in-sync with the equivalent version in basic-card-form.js
+ */
+ onInvalidField(event) {
+ let field = event.target;
+ let container = field.closest(`#${field.id}-container`);
+ let errorTextSpan = paymentRequest.maybeCreateFieldErrorElement(container);
+ errorTextSpan.textContent = field.validationMessage;
+ }
+
+ onInvalidForm() {
+ this.saveButton.disabled = true;
+ }
+
+ updateRequiredState() {
+ for (let field of this.form.elements) {
+ let container = field.closest(`#${field.id}-container`);
+ if (field.localName == "button" || !container) {
+ continue;
+ }
+ let span = container.querySelector(".label-text");
+ span.setAttribute(
+ "fieldRequiredSymbol",
+ this.dataset.fieldRequiredSymbol
+ );
+ container.toggleAttribute("required", field.required && !field.disabled);
+ }
+ }
+
+ updateSaveButtonState() {
+ this.saveButton.disabled = !this.form.checkValidity();
+ }
+
+ async saveRecord() {
+ let record = this.formHandler.buildFormObject();
+ let currentState = this.requestStore.getState();
+ let {
+ page,
+ tempAddresses,
+ savedBasicCards,
+ [this.id]: addressPage,
+ } = currentState;
+ let editing = !!addressPage.guid;
+
+ if (
+ editing
+ ? addressPage.guid in tempAddresses
+ : !this.persistCheckbox.checked
+ ) {
+ record.isTemporary = true;
+ }
+
+ let successStateChange;
+ const previousId = page.previousId;
+ if (page.onboardingWizard && !Object.keys(savedBasicCards).length) {
+ successStateChange = {
+ "basic-card-page": {
+ selectedStateKey: "selectedPaymentCard",
+ // Preserve field values as the user may have already edited the card
+ // page and went back to the address page to make a correction.
+ preserveFieldValues: true,
+ },
+ page: {
+ id: "basic-card-page",
+ previousId: this.id,
+ onboardingWizard: page.onboardingWizard,
+ },
+ };
+ } else {
+ successStateChange = {
+ page: {
+ id: previousId || "payment-summary",
+ onboardingWizard: page.onboardingWizard,
+ },
+ };
+ }
+
+ if (previousId) {
+ successStateChange[previousId] = Object.assign(
+ {},
+ currentState[previousId]
+ );
+ successStateChange[previousId].preserveFieldValues = true;
+ }
+
+ try {
+ let { guid } = await paymentRequest.updateAutofillRecord(
+ "addresses",
+ record,
+ addressPage.guid
+ );
+ let selectedStateKey = this.getAttribute("selected-state-key").split("|");
+
+ if (selectedStateKey.length == 1) {
+ Object.assign(successStateChange, {
+ [selectedStateKey[0]]: guid,
+ });
+ } else if (selectedStateKey.length == 2) {
+ // Need to keep properties like preserveFieldValues from getting removed.
+ let subObj = Object.assign({}, successStateChange[selectedStateKey[0]]);
+ subObj[selectedStateKey[1]] = guid;
+ Object.assign(successStateChange, {
+ [selectedStateKey[0]]: subObj,
+ });
+ } else {
+ throw new Error(
+ `selectedStateKey not supported: '${selectedStateKey}'`
+ );
+ }
+
+ this.requestStore.setState(successStateChange);
+ } catch (ex) {
+ log.warn("saveRecord: error:", ex);
+ this.requestStore.setState({
+ page: {
+ id: this.id,
+ onboardingWizard: page.onboardingWizard,
+ error: this.dataset.errorGenericSave,
+ },
+ });
+ }
+ }
+
+ /**
+ * Get the dictionary of field-specific merchant errors relevant to the
+ * specific form identified by the state key.
+ * @param {object} state The application state
+ * @param {string[]} stateKey The key in state to return address errors for.
+ * @returns {object} with keys as PaymentRequest field names and values of
+ * merchant-provided error strings.
+ */
+ static merchantFieldErrorsForForm(state, stateKey) {
+ let { paymentDetails } = state.request;
+ switch (stateKey.join("|")) {
+ case "selectedShippingAddress": {
+ return paymentDetails.shippingAddressErrors;
+ }
+ case "selectedPayerAddress": {
+ return paymentDetails.payerErrors;
+ }
+ case "basic-card-page|billingAddressGUID": {
+ // `paymentMethod` can be null.
+ return (
+ (paymentDetails.paymentMethodErrors &&
+ paymentDetails.paymentMethodErrors.billingAddress) ||
+ {}
+ );
+ }
+ default: {
+ throw new Error("Unknown selectedStateKey");
+ }
+ }
+ }
+}
+
+customElements.define("address-form", AddressForm);
diff --git a/browser/components/payments/res/containers/address-picker.js b/browser/components/payments/res/containers/address-picker.js
new file mode 100644
index 0000000000..b76a0f5d02
--- /dev/null
+++ b/browser/components/payments/res/containers/address-picker.js
@@ -0,0 +1,282 @@
+/* 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 AddressForm from "./address-form.js";
+import AddressOption from "../components/address-option.js";
+import RichPicker from "./rich-picker.js";
+import paymentRequest from "../paymentRequest.js";
+import HandleEventMixin from "../mixins/HandleEventMixin.js";
+
+/**
+ * <address-picker></address-picker>
+ * Container around add/edit links and <rich-select> with
+ * <address-option> listening to savedAddresses & tempAddresses.
+ */
+
+export default class AddressPicker extends HandleEventMixin(RichPicker) {
+ static get pickerAttributes() {
+ return ["address-fields", "break-after-nth-field", "data-field-separator"];
+ }
+
+ static get observedAttributes() {
+ return RichPicker.observedAttributes.concat(AddressPicker.pickerAttributes);
+ }
+
+ constructor() {
+ super();
+ this.dropdown.setAttribute("option-type", "address-option");
+ }
+
+ attributeChangedCallback(name, oldValue, newValue) {
+ super.attributeChangedCallback(name, oldValue, newValue);
+ // connectedCallback may add and adjust elements & values
+ // so avoid calling render before the element is connected
+ if (
+ this.isConnected &&
+ AddressPicker.pickerAttributes.includes(name) &&
+ oldValue !== newValue
+ ) {
+ this.render(this.requestStore.getState());
+ }
+ }
+
+ get fieldNames() {
+ if (this.hasAttribute("address-fields")) {
+ let names = this.getAttribute("address-fields")
+ .trim()
+ .split(/\s+/);
+ if (names.length) {
+ return names;
+ }
+ }
+
+ return [
+ // "address-level1", // TODO: bug 1481481 - not required for some countries e.g. DE
+ "address-level2",
+ "country",
+ "name",
+ "postal-code",
+ "street-address",
+ ];
+ }
+
+ /**
+ * De-dupe and filter addresses for the given set of fields that will be visible
+ *
+ * @param {object} addresses
+ * @param {array?} fieldNames - optional list of field names that be used when
+ * de-duping and excluding entries
+ * @returns {object} filtered copy of given addresses
+ */
+ filterAddresses(addresses, fieldNames = this.fieldNames) {
+ let uniques = new Set();
+ let result = {};
+ for (let [guid, address] of Object.entries(addresses)) {
+ let addressCopy = {};
+ let isMatch = false;
+ // exclude addresses that are missing all of the requested fields
+ for (let name of fieldNames) {
+ if (address[name]) {
+ isMatch = true;
+ addressCopy[name] = address[name];
+ }
+ }
+ if (isMatch) {
+ let key = JSON.stringify(addressCopy);
+ // exclude duplicated addresses
+ if (!uniques.has(key)) {
+ uniques.add(key);
+ result[guid] = address;
+ }
+ }
+ }
+ return result;
+ }
+
+ get options() {
+ return this.dropdown.popupBox.options;
+ }
+
+ /**
+ * @param {object} state - See `PaymentsStore.setState`
+ * The value of the picker is retrieved from state store rather than the DOM
+ * @returns {string} guid
+ */
+ getCurrentValue(state) {
+ let [selectedKey, selectedLeaf] = this.selectedStateKey.split("|");
+ let guid = state[selectedKey];
+ if (selectedLeaf) {
+ guid = guid[selectedLeaf];
+ }
+ return guid;
+ }
+
+ render(state) {
+ let selectedAddressGUID = this.getCurrentValue(state) || "";
+ let addresses = paymentRequest.getAddresses(state);
+ let desiredOptions = [];
+ let filteredAddresses = this.filterAddresses(addresses, this.fieldNames);
+ for (let [guid, address] of Object.entries(filteredAddresses)) {
+ let optionEl = this.dropdown.getOptionByValue(guid);
+ if (!optionEl) {
+ optionEl = document.createElement("option");
+ optionEl.value = guid;
+ }
+
+ for (let key of AddressOption.recordAttributes) {
+ let val = address[key];
+ if (val) {
+ optionEl.setAttribute(key, val);
+ } else {
+ optionEl.removeAttribute(key);
+ }
+ }
+
+ optionEl.dataset.fieldSeparator = this.dataset.fieldSeparator;
+
+ if (this.hasAttribute("address-fields")) {
+ optionEl.setAttribute(
+ "address-fields",
+ this.getAttribute("address-fields")
+ );
+ } else {
+ optionEl.removeAttribute("address-fields");
+ }
+
+ if (this.hasAttribute("break-after-nth-field")) {
+ optionEl.setAttribute(
+ "break-after-nth-field",
+ this.getAttribute("break-after-nth-field")
+ );
+ } else {
+ optionEl.removeAttribute("break-after-nth-field");
+ }
+
+ // fieldNames getter is not used here because it returns a default array with
+ // attributes even when "address-fields" observed attribute is null.
+ let addressFields = this.getAttribute("address-fields");
+ optionEl.textContent = AddressOption.formatSingleLineLabel(
+ address,
+ addressFields
+ );
+ desiredOptions.push(optionEl);
+ }
+
+ this.dropdown.popupBox.textContent = "";
+
+ if (this._allowEmptyOption) {
+ let optionEl = document.createElement("option");
+ optionEl.value = "";
+ desiredOptions.unshift(optionEl);
+ }
+
+ for (let option of desiredOptions) {
+ this.dropdown.popupBox.appendChild(option);
+ }
+
+ // Update selectedness after the options are updated
+ this.dropdown.value = selectedAddressGUID;
+
+ if (selectedAddressGUID && selectedAddressGUID !== this.dropdown.value) {
+ throw new Error(
+ `${this.selectedStateKey} option ${selectedAddressGUID} ` +
+ `does not exist in the address picker`
+ );
+ }
+
+ super.render(state);
+ }
+
+ get selectedStateKey() {
+ return this.getAttribute("selected-state-key");
+ }
+
+ errorForSelectedOption(state) {
+ let superError = super.errorForSelectedOption(state);
+ if (superError) {
+ return superError;
+ }
+
+ if (!this.selectedOption) {
+ return "";
+ }
+
+ let merchantFieldErrors = AddressForm.merchantFieldErrorsForForm(
+ state,
+ this.selectedStateKey.split("|")
+ );
+ // TODO: errors in priority order.
+ return (
+ Object.values(merchantFieldErrors).find(msg => {
+ return typeof msg == "string" && msg.length;
+ }) || ""
+ );
+ }
+
+ onChange(event) {
+ let [selectedKey, selectedLeaf] = this.selectedStateKey.split("|");
+ if (!selectedKey) {
+ return;
+ }
+ // selectedStateKey can be a '|' delimited string indicating a path into the state object
+ // to update with the new value
+ let newState = {};
+
+ if (selectedLeaf) {
+ let currentState = this.requestStore.getState();
+ newState[selectedKey] = Object.assign({}, currentState[selectedKey], {
+ [selectedLeaf]: this.dropdown.value,
+ });
+ } else {
+ newState[selectedKey] = this.dropdown.value;
+ }
+ this.requestStore.setState(newState);
+ }
+
+ onClick({ target }) {
+ let pageId;
+ let currentState = this.requestStore.getState();
+ let nextState = {
+ page: {},
+ };
+
+ switch (this.selectedStateKey) {
+ case "selectedShippingAddress":
+ pageId = "shipping-address-page";
+ break;
+ case "selectedPayerAddress":
+ pageId = "payer-address-page";
+ break;
+ case "basic-card-page|billingAddressGUID":
+ pageId = "billing-address-page";
+ break;
+ default: {
+ throw new Error(
+ "onClick, un-matched selectedStateKey: " + this.selectedStateKey
+ );
+ }
+ }
+ nextState.page.id = pageId;
+ let addressFields = this.getAttribute("address-fields");
+ nextState[pageId] = { addressFields };
+
+ switch (target) {
+ case this.addLink: {
+ nextState[pageId].guid = null;
+ break;
+ }
+ case this.editLink: {
+ nextState[pageId].guid = this.getCurrentValue(currentState);
+ break;
+ }
+ default: {
+ throw new Error("Unexpected onClick");
+ }
+ }
+
+ this.requestStore.setState(nextState);
+ }
+}
+
+customElements.define("address-picker", AddressPicker);
diff --git a/browser/components/payments/res/containers/basic-card-form.css b/browser/components/payments/res/containers/basic-card-form.css
new file mode 100644
index 0000000000..f4a8721e03
--- /dev/null
+++ b/browser/components/payments/res/containers/basic-card-form.css
@@ -0,0 +1,43 @@
+/* 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/. */
+
+basic-card-form .editCreditCardForm {
+ /* Add the persist-checkbox row to the grid */
+ grid-template-areas:
+ "cc-number cc-exp-month cc-exp-year"
+ "cc-name cc-type cc-csc"
+ "accepted accepted accepted"
+ "persist-checkbox persist-checkbox persist-checkbox"
+ "billingAddressGUID billingAddressGUID billingAddressGUID";
+}
+
+basic-card-form csc-input {
+ display: flex;
+ flex-grow: 1;
+}
+
+basic-card-form .editCreditCardForm > accepted-cards {
+ grid-area: accepted;
+ margin: 0;
+}
+
+basic-card-form .editCreditCardForm .persist-checkbox {
+ display: flex;
+ grid-area: persist-checkbox;
+}
+
+#billingAddressGUID-container {
+ display: grid;
+}
+
+basic-card-form > footer > .cancel-button {
+ /* When cancel is shown (during onboarding), it should always be on the left with a space after it */
+ margin-right: auto;
+}
+
+basic-card-form > footer > .cancel-button[hidden] ~ .back-button {
+ /* When back is shown (outside onboarding) we want "Back <space> Add/Save" */
+ /* Bug 1468153 may change the button ordering to match platform conventions */
+ margin-right: auto;
+}
diff --git a/browser/components/payments/res/containers/basic-card-form.js b/browser/components/payments/res/containers/basic-card-form.js
new file mode 100644
index 0000000000..f71b7fc74c
--- /dev/null
+++ b/browser/components/payments/res/containers/basic-card-form.js
@@ -0,0 +1,507 @@
+/* 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-globals-from ../../../../../browser/extensions/formautofill/content/autofillEditForms.js*/
+import AcceptedCards from "../components/accepted-cards.js";
+import BillingAddressPicker from "./billing-address-picker.js";
+import CscInput from "../components/csc-input.js";
+import LabelledCheckbox from "../components/labelled-checkbox.js";
+import PaymentRequestPage from "../components/payment-request-page.js";
+import PaymentStateSubscriberMixin from "../mixins/PaymentStateSubscriberMixin.js";
+import paymentRequest from "../paymentRequest.js";
+import HandleEventMixin from "../mixins/HandleEventMixin.js";
+
+/* import-globals-from ../unprivileged-fallbacks.js */
+
+/**
+ * <basic-card-form></basic-card-form>
+ *
+ * XXX: Bug 1446164 - This form isn't localized when used via this custom element
+ * as it will be much easier to share the logic once we switch to Fluent.
+ */
+
+export default class BasicCardForm extends HandleEventMixin(
+ PaymentStateSubscriberMixin(PaymentRequestPage)
+) {
+ constructor() {
+ super();
+
+ this.genericErrorText = document.createElement("div");
+ this.genericErrorText.setAttribute("aria-live", "polite");
+ this.genericErrorText.classList.add("page-error");
+
+ this.cscInput = new CscInput({
+ useAlwaysVisiblePlaceholder: true,
+ inputId: "cc-csc",
+ });
+
+ this.persistCheckbox = new LabelledCheckbox();
+ // The persist checkbox shouldn't be part of the record which gets saved so
+ // exclude it from the form.
+ this.persistCheckbox.form = "";
+ this.persistCheckbox.className = "persist-checkbox";
+
+ this.acceptedCardsList = new AcceptedCards();
+
+ // page footer
+ this.cancelButton = document.createElement("button");
+ this.cancelButton.className = "cancel-button";
+ this.cancelButton.addEventListener("click", this);
+
+ this.backButton = document.createElement("button");
+ this.backButton.className = "back-button";
+ this.backButton.addEventListener("click", this);
+
+ this.saveButton = document.createElement("button");
+ this.saveButton.className = "save-button primary";
+ this.saveButton.addEventListener("click", this);
+
+ this.footer.append(this.cancelButton, this.backButton, this.saveButton);
+
+ // The markup is shared with form autofill preferences.
+ let url = "formautofill/editCreditCard.xhtml";
+ this.promiseReady = this._fetchMarkup(url).then(doc => {
+ this.form = doc.getElementById("form");
+ return this.form;
+ });
+ }
+
+ _fetchMarkup(url) {
+ return new Promise((resolve, reject) => {
+ let xhr = new XMLHttpRequest();
+ xhr.responseType = "document";
+ xhr.addEventListener("error", reject);
+ xhr.addEventListener("load", evt => {
+ resolve(xhr.response);
+ });
+ xhr.open("GET", url);
+ xhr.send();
+ });
+ }
+
+ _upgradeBillingAddressPicker() {
+ let addressRow = this.form.querySelector(".billingAddressRow");
+ let addressPicker = (this.billingAddressPicker = new BillingAddressPicker());
+
+ // Wrap the existing <select> that the formHandler manages
+ if (addressPicker.dropdown.popupBox) {
+ addressPicker.dropdown.popupBox.remove();
+ }
+ addressPicker.dropdown.popupBox = this.form.querySelector(
+ "#billingAddressGUID"
+ );
+
+ // Hide the original label as the address picker provide its own,
+ // but we'll copy the localized textContent from it when rendering
+ addressRow.querySelector(".label-text").hidden = true;
+
+ addressPicker.dataset.addLinkLabel = this.dataset.addressAddLinkLabel;
+ addressPicker.dataset.editLinkLabel = this.dataset.addressEditLinkLabel;
+ addressPicker.dataset.fieldSeparator = this.dataset.addressFieldSeparator;
+ addressPicker.dataset.addAddressTitle = this.dataset.billingAddressTitleAdd;
+ addressPicker.dataset.editAddressTitle = this.dataset.billingAddressTitleEdit;
+ addressPicker.dataset.invalidLabel = this.dataset.invalidAddressLabel;
+ // break-after-nth-field, address-fields not needed here
+
+ // this state is only used to carry the selected guid between pages;
+ // the select#billingAddressGUID is the source of truth for the current value
+ addressPicker.setAttribute(
+ "selected-state-key",
+ "basic-card-page|billingAddressGUID"
+ );
+
+ addressPicker.addLink.addEventListener("click", this);
+ addressPicker.editLink.addEventListener("click", this);
+
+ addressRow.appendChild(addressPicker);
+ }
+
+ connectedCallback() {
+ this.promiseReady.then(form => {
+ this.body.appendChild(form);
+
+ let record = {};
+ let addresses = [];
+ this.formHandler = new EditCreditCard(
+ {
+ form,
+ },
+ record,
+ addresses,
+ {
+ isCCNumber: PaymentDialogUtils.isCCNumber,
+ getAddressLabel: PaymentDialogUtils.getAddressLabel,
+ getSupportedNetworks: PaymentDialogUtils.getCreditCardNetworks,
+ }
+ );
+
+ // The EditCreditCard constructor adds `change` and `input` event listeners on the same
+ // element, which update field validity. By adding our event listeners after this
+ // constructor, validity will be updated before our handlers get the event
+ form.addEventListener("change", this);
+ form.addEventListener("input", this);
+ form.addEventListener("invalid", this);
+
+ this._upgradeBillingAddressPicker();
+
+ // The "invalid" event does not bubble and needs to be listened for on each
+ // form element.
+ for (let field of this.form.elements) {
+ field.addEventListener("invalid", this);
+ }
+
+ // Replace the form-autofill cc-csc fields with our csc-input.
+ let cscContainer = this.form.querySelector("#cc-csc-container");
+ cscContainer.textContent = "";
+ cscContainer.appendChild(this.cscInput);
+
+ let billingAddressRow = this.form.querySelector(".billingAddressRow");
+ form.insertBefore(this.persistCheckbox, billingAddressRow);
+ form.insertBefore(this.acceptedCardsList, billingAddressRow);
+ this.body.appendChild(this.genericErrorText);
+ // Only call the connected super callback(s) once our markup is fully
+ // connected, including the shared form fetched asynchronously.
+ super.connectedCallback();
+ });
+ }
+
+ render(state) {
+ let {
+ page,
+ selectedShippingAddress,
+ "basic-card-page": basicCardPage,
+ } = state;
+
+ if (this.id && page && page.id !== this.id) {
+ log.debug(
+ `BasicCardForm: no need to further render inactive page: ${page.id}`
+ );
+ return;
+ }
+
+ if (!basicCardPage.selectedStateKey) {
+ throw new Error("A `selectedStateKey` is required");
+ }
+
+ let editing = !!basicCardPage.guid;
+ this.cancelButton.textContent = this.dataset.cancelButtonLabel;
+ this.backButton.textContent = this.dataset.backButtonLabel;
+ if (editing) {
+ this.saveButton.textContent = this.dataset.updateButtonLabel;
+ } else {
+ this.saveButton.textContent = this.dataset.nextButtonLabel;
+ }
+
+ this.cscInput.placeholder = this.dataset.cscPlaceholder;
+ this.cscInput.frontTooltip = this.dataset.cscFrontInfoTooltip;
+ this.cscInput.backTooltip = this.dataset.cscBackInfoTooltip;
+
+ // The label text from the form isn't available until render() time.
+ let labelText = this.form.querySelector(".billingAddressRow .label-text")
+ .textContent;
+ this.billingAddressPicker.setAttribute("label", labelText);
+
+ this.persistCheckbox.label = this.dataset.persistCheckboxLabel;
+ this.persistCheckbox.infoTooltip = this.dataset.persistCheckboxInfoTooltip;
+
+ this.acceptedCardsList.label = this.dataset.acceptedCardsLabel;
+
+ // The next line needs an onboarding check since we don't set previousId
+ // when navigating to add/edit directly from the summary page.
+ this.backButton.hidden = !page.previousId && page.onboardingWizard;
+ this.cancelButton.hidden = !page.onboardingWizard;
+
+ let record = {};
+ let basicCards = paymentRequest.getBasicCards(state);
+ let addresses = paymentRequest.getAddresses(state);
+
+ this.genericErrorText.textContent = page.error;
+
+ this.form.querySelector("#cc-number").disabled = editing;
+
+ // The CVV fields should be hidden and disabled when editing.
+ this.form.querySelector("#cc-csc-container").hidden = editing;
+ this.cscInput.disabled = editing;
+
+ // If a card is selected we want to edit it.
+ if (editing) {
+ this.pageTitleHeading.textContent = this.dataset.editBasicCardTitle;
+ record = basicCards[basicCardPage.guid];
+ if (!record) {
+ throw new Error(
+ "Trying to edit a non-existing card: " + basicCardPage.guid
+ );
+ }
+ // When editing an existing record, prevent changes to persistence
+ this.persistCheckbox.hidden = true;
+ } else {
+ this.pageTitleHeading.textContent = this.dataset.addBasicCardTitle;
+ // Use a currently selected shipping address as the default billing address
+ record.billingAddressGUID = basicCardPage.billingAddressGUID;
+ if (!record.billingAddressGUID && selectedShippingAddress) {
+ record.billingAddressGUID = selectedShippingAddress;
+ }
+
+ let {
+ saveCreditCardDefaultChecked,
+ } = PaymentDialogUtils.getDefaultPreferences();
+ if (typeof saveCreditCardDefaultChecked != "boolean") {
+ throw new Error(`Unexpected non-boolean value for saveCreditCardDefaultChecked from
+ PaymentDialogUtils.getDefaultPreferences(): ${typeof saveCreditCardDefaultChecked}`);
+ }
+ // Adding a new record: default persistence to pref value when in a not-private session
+ this.persistCheckbox.hidden = false;
+ if (basicCardPage.hasOwnProperty("persistCheckboxValue")) {
+ // returning to this page, use previous checked state
+ this.persistCheckbox.checked = basicCardPage.persistCheckboxValue;
+ } else {
+ this.persistCheckbox.checked = state.isPrivate
+ ? false
+ : saveCreditCardDefaultChecked;
+ }
+ }
+
+ this.formHandler.loadRecord(
+ record,
+ addresses,
+ basicCardPage.preserveFieldValues
+ );
+
+ this.form.querySelector(".billingAddressRow").hidden = false;
+
+ let billingAddressSelect = this.billingAddressPicker.dropdown;
+ if (basicCardPage.billingAddressGUID) {
+ billingAddressSelect.value = basicCardPage.billingAddressGUID;
+ } else if (!editing) {
+ if (paymentRequest.getAddresses(state)[selectedShippingAddress]) {
+ billingAddressSelect.value = selectedShippingAddress;
+ } else {
+ let firstAddressGUID = Object.keys(addresses)[0];
+ if (firstAddressGUID) {
+ // Only set the value if we have a saved address to not mark the field
+ // dirty and invalid on an add form with no saved addresses.
+ billingAddressSelect.value = firstAddressGUID;
+ }
+ }
+ }
+ // Need to recalculate the populated state since
+ // billingAddressSelect is updated after loadRecord.
+ this.formHandler.updatePopulatedState(billingAddressSelect.popupBox);
+
+ this.updateRequiredState();
+ this.updateSaveButtonState();
+ }
+
+ onChange(evt) {
+ let ccType = this.form.querySelector("#cc-type");
+ this.cscInput.setAttribute("card-type", ccType.value);
+
+ this.updateSaveButtonState();
+ }
+
+ onClick(evt) {
+ switch (evt.target) {
+ case this.cancelButton: {
+ paymentRequest.cancel();
+ break;
+ }
+ case this.billingAddressPicker.addLink:
+ case this.billingAddressPicker.editLink: {
+ // The address-picker has set state for the page to advance to, now set up the
+ // necessary state for returning to and re-rendering this page
+ let {
+ "basic-card-page": basicCardPage,
+ page,
+ } = this.requestStore.getState();
+ let nextState = {
+ page: Object.assign({}, page, {
+ previousId: "basic-card-page",
+ }),
+ "basic-card-page": {
+ preserveFieldValues: true,
+ guid: basicCardPage.guid,
+ persistCheckboxValue: this.persistCheckbox.checked,
+ selectedStateKey: basicCardPage.selectedStateKey,
+ },
+ };
+ this.requestStore.setState(nextState);
+ break;
+ }
+ case this.backButton: {
+ let currentState = this.requestStore.getState();
+ let {
+ page,
+ request,
+ "shipping-address-page": shippingAddressPage,
+ "billing-address-page": billingAddressPage,
+ "basic-card-page": basicCardPage,
+ selectedShippingAddress,
+ } = currentState;
+
+ let nextState = {
+ page: {
+ id: page.previousId || "payment-summary",
+ onboardingWizard: page.onboardingWizard,
+ },
+ };
+
+ if (page.onboardingWizard) {
+ if (request.paymentOptions.requestShipping) {
+ shippingAddressPage = Object.assign({}, shippingAddressPage, {
+ guid: selectedShippingAddress,
+ });
+ Object.assign(nextState, {
+ "shipping-address-page": shippingAddressPage,
+ });
+ } else {
+ billingAddressPage = Object.assign({}, billingAddressPage, {
+ guid: basicCardPage.billingAddressGUID,
+ });
+ Object.assign(nextState, {
+ "billing-address-page": billingAddressPage,
+ });
+ }
+
+ let basicCardPageState = Object.assign({}, basicCardPage, {
+ preserveFieldValues: true,
+ });
+ delete basicCardPageState.persistCheckboxValue;
+
+ Object.assign(nextState, {
+ "basic-card-page": basicCardPageState,
+ });
+ }
+
+ this.requestStore.setState(nextState);
+ break;
+ }
+ case this.saveButton: {
+ if (this.form.checkValidity()) {
+ this.saveRecord();
+ }
+ break;
+ }
+ default: {
+ throw new Error("Unexpected click target");
+ }
+ }
+ }
+
+ onInput(event) {
+ event.target.setCustomValidity("");
+ this.updateSaveButtonState();
+ }
+
+ onInvalid(event) {
+ if (event.target instanceof HTMLFormElement) {
+ this.onInvalidForm(event);
+ } else {
+ this.onInvalidField(event);
+ }
+ }
+
+ /**
+ * @param {Event} event - "invalid" event
+ * Note: Keep this in-sync with the equivalent version in address-form.js
+ */
+ onInvalidField(event) {
+ let field = event.target;
+ let container = field.closest(`#${field.id}-container`);
+ let errorTextSpan = paymentRequest.maybeCreateFieldErrorElement(container);
+ errorTextSpan.textContent = field.validationMessage;
+ }
+
+ onInvalidForm() {
+ this.saveButton.disabled = true;
+ }
+
+ updateSaveButtonState() {
+ const INVALID_CLASS_NAME = "invalid-selected-option";
+ let isValid =
+ this.form.checkValidity() &&
+ !this.billingAddressPicker.classList.contains(INVALID_CLASS_NAME);
+ this.saveButton.disabled = !isValid;
+ }
+
+ updateRequiredState() {
+ for (let field of this.form.elements) {
+ let container = field.closest(".container");
+ let span = container.querySelector(".label-text");
+ if (!span) {
+ // The billing address field doesn't use a label inside the field.
+ continue;
+ }
+ span.setAttribute(
+ "fieldRequiredSymbol",
+ this.dataset.fieldRequiredSymbol
+ );
+ container.toggleAttribute("required", field.required && !field.disabled);
+ }
+ }
+
+ async saveRecord() {
+ let record = this.formHandler.buildFormObject();
+ let currentState = this.requestStore.getState();
+ let { tempBasicCards, "basic-card-page": basicCardPage } = currentState;
+ let editing = !!basicCardPage.guid;
+
+ if (
+ editing
+ ? basicCardPage.guid in tempBasicCards
+ : !this.persistCheckbox.checked
+ ) {
+ record.isTemporary = true;
+ }
+
+ for (let editableFieldName of [
+ "cc-name",
+ "cc-exp-month",
+ "cc-exp-year",
+ "cc-type",
+ ]) {
+ record[editableFieldName] = record[editableFieldName] || "";
+ }
+
+ // Only save the card number if we're saving a new record, otherwise we'd
+ // overwrite the unmasked card number with the masked one.
+ if (!editing) {
+ record["cc-number"] = record["cc-number"] || "";
+ }
+
+ // Never save the CSC in storage. Storage will throw and not save the record
+ // if it is passed.
+ delete record["cc-csc"];
+
+ try {
+ let { guid } = await paymentRequest.updateAutofillRecord(
+ "creditCards",
+ record,
+ basicCardPage.guid
+ );
+ let { selectedStateKey } = currentState["basic-card-page"];
+ if (!selectedStateKey) {
+ throw new Error(
+ `state["basic-card-page"].selectedStateKey is required`
+ );
+ }
+ this.requestStore.setState({
+ page: {
+ id: "payment-summary",
+ },
+ [selectedStateKey]: guid,
+ [selectedStateKey + "SecurityCode"]: this.cscInput.value,
+ });
+ } catch (ex) {
+ log.warn("saveRecord: error:", ex);
+ this.requestStore.setState({
+ page: {
+ id: "basic-card-page",
+ error: this.dataset.errorGenericSave,
+ },
+ });
+ }
+ }
+}
+
+customElements.define("basic-card-form", BasicCardForm);
diff --git a/browser/components/payments/res/containers/billing-address-picker.js b/browser/components/payments/res/containers/billing-address-picker.js
new file mode 100644
index 0000000000..57b70a2364
--- /dev/null
+++ b/browser/components/payments/res/containers/billing-address-picker.js
@@ -0,0 +1,33 @@
+/* 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 AddressPicker from "./address-picker.js";
+/* import-globals-from ../unprivileged-fallbacks.js */
+
+/**
+ * <billing-address-picker></billing-address-picker>
+ * Extends AddressPicker to treat the <select>'s value as the source of truth
+ */
+
+export default class BillingAddressPicker extends AddressPicker {
+ constructor() {
+ super();
+ this._allowEmptyOption = true;
+ }
+
+ /**
+ * @param {object?} state - See `PaymentsStore.setState`
+ * The value of the picker is the child dropdown element's value
+ * @returns {string} guid
+ */
+ getCurrentValue() {
+ return this.dropdown.value;
+ }
+
+ onChange(event) {
+ this.render(this.requestStore.getState());
+ }
+}
+
+customElements.define("billing-address-picker", BillingAddressPicker);
diff --git a/browser/components/payments/res/containers/completion-error-page.js b/browser/components/payments/res/containers/completion-error-page.js
new file mode 100644
index 0000000000..9e8f7ce9f7
--- /dev/null
+++ b/browser/components/payments/res/containers/completion-error-page.js
@@ -0,0 +1,112 @@
+/* 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 HandleEventMixin from "../mixins/HandleEventMixin.js";
+import PaymentRequestPage from "../components/payment-request-page.js";
+import PaymentStateSubscriberMixin from "../mixins/PaymentStateSubscriberMixin.js";
+import paymentRequest from "../paymentRequest.js";
+
+/* import-globals-from ../unprivileged-fallbacks.js */
+
+/**
+ * <completion-error-page></completion-error-page>
+ *
+ * XXX: Bug 1473772 - This page isn't fully localized when used via this custom element
+ * as it will be much easier to implement and share the logic once we switch to Fluent.
+ */
+
+export default class CompletionErrorPage extends HandleEventMixin(
+ PaymentStateSubscriberMixin(PaymentRequestPage)
+) {
+ constructor() {
+ super();
+
+ this.classList.add("error-page");
+ this.suggestionHeading = document.createElement("p");
+ this.body.append(this.suggestionHeading);
+ this.suggestionsList = document.createElement("ul");
+ this.suggestions = [];
+ this.body.append(this.suggestionsList);
+
+ this.brandingSpan = document.createElement("span");
+ this.brandingSpan.classList.add("branding");
+ this.footer.appendChild(this.brandingSpan);
+
+ this.doneButton = document.createElement("button");
+ this.doneButton.classList.add("done-button", "primary");
+ this.doneButton.addEventListener("click", this);
+
+ this.footer.appendChild(this.doneButton);
+ }
+
+ render(state) {
+ let { page } = state;
+
+ if (this.id && page && page.id !== this.id) {
+ log.debug(
+ `CompletionErrorPage: no need to further render inactive page: ${page.id}`
+ );
+ return;
+ }
+
+ let { request } = this.requestStore.getState();
+ let { displayHost } = request.topLevelPrincipal.URI;
+ for (let key of [
+ "pageTitle",
+ "suggestion-heading",
+ "suggestion-1",
+ "suggestion-2",
+ "suggestion-3",
+ ]) {
+ if (this.dataset[key] && displayHost) {
+ this.dataset[key] = this.dataset[key].replace(
+ "**host-name**",
+ displayHost
+ );
+ }
+ }
+
+ this.pageTitleHeading.textContent = this.dataset.pageTitle;
+ this.suggestionHeading.textContent = this.dataset.suggestionHeading;
+ this.brandingSpan.textContent = this.dataset.brandingLabel;
+ this.doneButton.textContent = this.dataset.doneButtonLabel;
+
+ this.suggestionsList.textContent = "";
+ if (this.dataset["suggestion-1"]) {
+ this.suggestions[0] = this.dataset["suggestion-1"];
+ }
+ if (this.dataset["suggestion-2"]) {
+ this.suggestions[1] = this.dataset["suggestion-2"];
+ }
+ if (this.dataset["suggestion-3"]) {
+ this.suggestions[2] = this.dataset["suggestion-3"];
+ }
+
+ let suggestionsFragment = document.createDocumentFragment();
+ for (let suggestionText of this.suggestions) {
+ let listNode = document.createElement("li");
+ listNode.textContent = suggestionText;
+ suggestionsFragment.appendChild(listNode);
+ }
+ this.suggestionsList.appendChild(suggestionsFragment);
+ }
+
+ onClick(event) {
+ switch (event.target) {
+ case this.doneButton: {
+ this.onDoneButtonClick(event);
+ break;
+ }
+ default: {
+ throw new Error("Unexpected click target");
+ }
+ }
+ }
+
+ onDoneButtonClick(event) {
+ paymentRequest.closeDialog();
+ }
+}
+
+customElements.define("completion-error-page", CompletionErrorPage);
diff --git a/browser/components/payments/res/containers/cvv-hint-image-back.svg b/browser/components/payments/res/containers/cvv-hint-image-back.svg
new file mode 100644
index 0000000000..1e9f4ddb10
--- /dev/null
+++ b/browser/components/payments/res/containers/cvv-hint-image-back.svg
@@ -0,0 +1,27 @@
+<!-- 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" xmlns:xlink="http://www.w3.org/1999/xlink" width="46" height="27" version="1.1">
+ <defs>
+ <circle id="a" cx="10" cy="10" r="10"/>
+ </defs>
+ <g fill="none" fill-rule="evenodd" stroke="none" stroke-width="1">
+ <path fill="#A1C6FF" d="M37 6.2a10.046 10.046 0 0 0 -2 -0.2c-5.523 0 -10 4.477 -10 10a9.983 9.983 0 0 0 3.999 8h-27.999a1 1 0 0 1 -1 -1v-22a1 1 0 0 1 1 -1h35a1 1 0 0 1 1 1v5.2zm-18 7.8c3.314 0 6 -1.567 6 -3.5s-2.686 -3.5 -6 -3.5 -6 1.567 -6 3.5 2.686 3.5 6 3.5z"/>
+ <path fill="#5F5F5F" d="M2 17h9v2h-9v-2zm0 -15h33v3h-33v-3zm0 18h15v2h-15v-2zm10 -3h13v2h-13v-2z"/>
+ <g transform="translate(25 6)">
+ <mask id="b" fill="#fff">
+ <use xlink:href="#a"/>
+ </mask>
+ <use stroke="#FFF" stroke-width="1.5" xlink:href="#a"/>
+ <g mask="url(#b)">
+ <g transform="translate(-77 -31)">
+ <rect width="99.39" height="69.141" x="0" y="0" fill="#A1C6FF" fill-rule="evenodd" rx="1"/>
+ <path fill="#5F5F5F" fill-rule="evenodd" d="M79 46h17v6h-17z"/>
+ <text fill="none" font-family="sans-serif" font-size="6">
+ <tspan x="80" y="42" fill="#5F5F5F">1234</tspan>
+ </text>
+ </g>
+ </g>
+ </g>
+ </g>
+</svg>
diff --git a/browser/components/payments/res/containers/cvv-hint-image-front.svg b/browser/components/payments/res/containers/cvv-hint-image-front.svg
new file mode 100644
index 0000000000..5a758870a1
--- /dev/null
+++ b/browser/components/payments/res/containers/cvv-hint-image-front.svg
@@ -0,0 +1,25 @@
+<!-- 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" xmlns:xlink="http://www.w3.org/1999/xlink" width="45" height="27" version="1.1">
+ <defs>
+ <circle id="a" cx="10" cy="10" r="10"/>
+ </defs>
+ <g fill="none" fill-rule="evenodd" stroke="none" stroke-width="1">
+ <path fill="#62A0FF" d="M37 6.458a9.996 9.996 0 0 0 -3 -0.458c-3.701 0 -6.933 2.011 -8.662 5h-22.338v5h21a9.983 9.983 0 0 0 3.999 8h-26.999a1 1 0 0 1 -1 -1v-22a1 1 0 0 1 1 -1h35a1 1 0 0 1 1 1v5.458z"/>
+ <path fill="#5F5F5F" d="M37 6.458a9.996 9.996 0 0 0 -3 -0.458 9.97 9.97 0 0 0 -7.141 3h-26.859v-6h37v3.458z"/>
+ <g transform="translate(24 6)">
+ <mask id="b" fill="#fff">
+ <use xlink:href="#a"/>
+ </mask>
+ <use stroke="#FFF" stroke-width="1.5" xlink:href="#a"/>
+ <g mask="url(#b)">
+ <path fill="#62A0FF" fill-rule="evenodd" d="M-41.923 -15.615h64.476a1 1 0 0 1 1 1v44.244a1 1 0 0 1 -1 1h-64.476a1 1 0 0 1 -1 -1v-44.244a1 1 0 0 1 1 -1zm2.923 19.615v9h55v-9h-55z"/>
+ <path fill="#5F5F5F" fill-rule="evenodd" d="M-43 -10h66v12h-66z"/>
+ <text fill="none" font-family="sans-serif" font-size="6" transform="translate(-43.923 -15.615)">
+ <tspan x="47.676" y="26.104" fill="#5F5F5F">123</tspan>
+ </text>
+ </g>
+ </g>
+ </g>
+</svg>
diff --git a/browser/components/payments/res/containers/error-page.css b/browser/components/payments/res/containers/error-page.css
new file mode 100644
index 0000000000..bd4bec96b5
--- /dev/null
+++ b/browser/components/payments/res/containers/error-page.css
@@ -0,0 +1,42 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+.error-page.illustrated > .page-body {
+ display: flex;
+ justify-content: center;
+ min-height: 160px;
+ background-position: left center;
+ background-repeat: no-repeat;
+ background-size: 160px;
+ padding-inline-start: 160px;
+}
+
+.error-page.illustrated > .page-body:dir(rtl) {
+ background-position: right center;
+}
+
+.error-page.illustrated > .page-body > h2 {
+ background: none;
+ padding-inline-start: 0;
+ margin-inline-start: 0;
+ font-weight: lighter;
+ font-size: 2rem;
+}
+
+.error-page.illustrated > .page-body > p {
+ margin-top: 0;
+ margin-bottom: 0;
+}
+
+.error-page.illustrated > .page-body > ul {
+ margin-top: .5rem;
+}
+
+.error-page#completion-timeout-error > .page-body {
+ background-image: url("./timeout.svg");
+}
+
+.error-page#completion-fail-error > .page-body {
+ background-image: url("./warning.svg");
+}
diff --git a/browser/components/payments/res/containers/order-details.css b/browser/components/payments/res/containers/order-details.css
new file mode 100644
index 0000000000..beadeb3b88
--- /dev/null
+++ b/browser/components/payments/res/containers/order-details.css
@@ -0,0 +1,55 @@
+/* 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/. */
+
+order-details {
+ display: grid;
+ grid-template-columns: 20% auto 10rem;
+ grid-gap: 1em;
+ margin: 1px 4vw;
+}
+
+order-details > ul {
+ list-style-type: none;
+ margin: 1em 0;
+ padding: 0;
+ display: contents;
+}
+
+order-details payment-details-item {
+ margin: 1px 0;
+ display: contents;
+}
+payment-details-item .label {
+ grid-column-start: 1;
+ grid-column-end: 3;
+}
+payment-details-item currency-amount {
+ grid-column-start: 3;
+ grid-column-end: 4;
+}
+
+order-details .footer-items-list:not(:empty):before {
+ border: 1px solid GrayText;
+ display: block;
+ content: "";
+ grid-column-start: 1;
+ grid-column-end: 4;
+}
+
+order-details > .details-total {
+ margin: 1px 0;
+ display: contents;
+}
+
+.details-total > .label {
+ margin: 0;
+ font-size: large;
+ grid-column-start: 2;
+ grid-column-end: 3;
+ text-align: end;
+}
+.details-total > currency-amount {
+ font-size: large;
+ text-align: end;
+}
diff --git a/browser/components/payments/res/containers/order-details.js b/browser/components/payments/res/containers/order-details.js
new file mode 100644
index 0000000000..a458c14784
--- /dev/null
+++ b/browser/components/payments/res/containers/order-details.js
@@ -0,0 +1,143 @@
+/* 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/. */
+
+// <currency-amount> is used in the <template>
+import "../components/currency-amount.js";
+import PaymentDetailsItem from "../components/payment-details-item.js";
+import paymentRequest from "../paymentRequest.js";
+import PaymentStateSubscriberMixin from "../mixins/PaymentStateSubscriberMixin.js";
+
+/**
+ * <order-details></order-details>
+ */
+
+export default class OrderDetails extends PaymentStateSubscriberMixin(
+ HTMLElement
+) {
+ connectedCallback() {
+ if (!this._contents) {
+ let template = document.getElementById("order-details-template");
+ let contents = (this._contents = document.importNode(
+ template.content,
+ true
+ ));
+
+ this._mainItemsList = contents.querySelector(".main-list");
+ this._footerItemsList = contents.querySelector(".footer-items-list");
+ this._totalAmount = contents.querySelector(
+ ".details-total > currency-amount"
+ );
+
+ this.appendChild(this._contents);
+ }
+ super.connectedCallback();
+ }
+
+ get mainItemsList() {
+ return this._mainItemsList;
+ }
+
+ get footerItemsList() {
+ return this._footerItemsList;
+ }
+
+ get totalAmountElem() {
+ return this._totalAmount;
+ }
+
+ static _emptyList(listEl) {
+ while (listEl.lastChild) {
+ listEl.removeChild(listEl.lastChild);
+ }
+ }
+
+ static _populateList(listEl, items) {
+ let fragment = document.createDocumentFragment();
+ for (let item of items) {
+ let row = new PaymentDetailsItem();
+ row.label = item.label;
+ row.amountValue = item.amount.value;
+ row.amountCurrency = item.amount.currency;
+ fragment.appendChild(row);
+ }
+ listEl.appendChild(fragment);
+ return listEl;
+ }
+
+ _getAdditionalDisplayItems(state) {
+ let methodId = state.selectedPaymentCard;
+ let modifier = paymentRequest.getModifierForPaymentMethod(state, methodId);
+ if (modifier && modifier.additionalDisplayItems) {
+ return modifier.additionalDisplayItems;
+ }
+ return [];
+ }
+
+ render(state) {
+ let totalItem = paymentRequest.getTotalItem(state);
+
+ OrderDetails._emptyList(this.mainItemsList);
+ OrderDetails._emptyList(this.footerItemsList);
+
+ let mainItems = OrderDetails._getMainListItems(state);
+ if (mainItems.length) {
+ OrderDetails._populateList(this.mainItemsList, mainItems);
+ }
+
+ let footerItems = OrderDetails._getFooterListItems(state);
+ if (footerItems.length) {
+ OrderDetails._populateList(this.footerItemsList, footerItems);
+ }
+
+ this.totalAmountElem.value = totalItem.amount.value;
+ this.totalAmountElem.currency = totalItem.amount.currency;
+ }
+
+ /**
+ * Determine if a display item should belong in the footer list.
+ * This uses the proposed "type" property, tracked at:
+ * https://github.com/w3c/payment-request/issues/163
+ *
+ * @param {object} item - Data representing a PaymentItem
+ * @returns {boolean}
+ */
+ static isFooterItem(item) {
+ return item.type == "tax";
+ }
+
+ static _getMainListItems(state) {
+ let request = state.request;
+ let items = request.paymentDetails.displayItems;
+ if (Array.isArray(items) && items.length) {
+ let predicate = item => !OrderDetails.isFooterItem(item);
+ return request.paymentDetails.displayItems.filter(predicate);
+ }
+ return [];
+ }
+
+ static _getFooterListItems(state) {
+ let request = state.request;
+ let items = request.paymentDetails.displayItems;
+ let footerItems = [];
+ let methodId = state.selectedPaymentCard;
+ if (methodId) {
+ let modifier = paymentRequest.getModifierForPaymentMethod(
+ state,
+ methodId
+ );
+ if (modifier && Array.isArray(modifier.additionalDisplayItems)) {
+ footerItems.push(...modifier.additionalDisplayItems);
+ }
+ }
+ if (Array.isArray(items) && items.length) {
+ let predicate = OrderDetails.isFooterItem;
+ footerItems.push(
+ ...request.paymentDetails.displayItems.filter(predicate)
+ );
+ }
+ return footerItems;
+ }
+}
+
+customElements.define("order-details", OrderDetails);
diff --git a/browser/components/payments/res/containers/payment-dialog.js b/browser/components/payments/res/containers/payment-dialog.js
new file mode 100644
index 0000000000..7bf094e267
--- /dev/null
+++ b/browser/components/payments/res/containers/payment-dialog.js
@@ -0,0 +1,593 @@
+/* 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 HandleEventMixin from "../mixins/HandleEventMixin.js";
+import PaymentStateSubscriberMixin from "../mixins/PaymentStateSubscriberMixin.js";
+import paymentRequest from "../paymentRequest.js";
+
+import "../components/currency-amount.js";
+import "../components/payment-request-page.js";
+import "../components/accepted-cards.js";
+import "./address-picker.js";
+import "./address-form.js";
+import "./basic-card-form.js";
+import "./completion-error-page.js";
+import "./order-details.js";
+import "./payment-method-picker.js";
+import "./shipping-option-picker.js";
+
+/* import-globals-from ../unprivileged-fallbacks.js */
+
+/**
+ * <payment-dialog></payment-dialog>
+ *
+ * Warning: Do not import this module from any other module as it will import
+ * everything else (see above) and ruin element independence. This can stop
+ * being exported once tests stop depending on it.
+ */
+
+export default class PaymentDialog extends HandleEventMixin(
+ PaymentStateSubscriberMixin(HTMLElement)
+) {
+ constructor() {
+ super();
+ this._template = document.getElementById("payment-dialog-template");
+ this._cachedState = {};
+ }
+
+ connectedCallback() {
+ let contents = document.importNode(this._template.content, true);
+ this._hostNameEl = contents.querySelector("#host-name");
+
+ this._cancelButton = contents.querySelector("#cancel");
+ this._cancelButton.addEventListener("click", this.cancelRequest);
+
+ this._payButton = contents.querySelector("#pay");
+ this._payButton.addEventListener("click", this);
+
+ this._viewAllButton = contents.querySelector("#view-all");
+ this._viewAllButton.addEventListener("click", this);
+
+ this._mainContainer = contents.getElementById("main-container");
+ this._orderDetailsOverlay = contents.querySelector(
+ "#order-details-overlay"
+ );
+
+ this._shippingAddressPicker = contents.querySelector(
+ "address-picker.shipping-related"
+ );
+ this._shippingOptionPicker = contents.querySelector(
+ "shipping-option-picker"
+ );
+ this._shippingRelatedEls = contents.querySelectorAll(".shipping-related");
+ this._payerRelatedEls = contents.querySelectorAll(".payer-related");
+ this._payerAddressPicker = contents.querySelector(
+ "address-picker.payer-related"
+ );
+ this._paymentMethodPicker = contents.querySelector("payment-method-picker");
+ this._acceptedCardsList = contents.querySelector("accepted-cards");
+ this._manageText = contents.querySelector(".manage-text");
+ this._manageText.addEventListener("click", this);
+
+ this._header = contents.querySelector("header");
+
+ this._errorText = contents.querySelector("header > .page-error");
+
+ this._disabledOverlay = contents.getElementById("disabled-overlay");
+
+ this.appendChild(contents);
+
+ super.connectedCallback();
+ }
+
+ disconnectedCallback() {
+ this._cancelButton.removeEventListener("click", this.cancelRequest);
+ this._payButton.removeEventListener("click", this.pay);
+ this._viewAllButton.removeEventListener("click", this);
+ super.disconnectedCallback();
+ }
+
+ onClick(event) {
+ switch (event.currentTarget) {
+ case this._viewAllButton:
+ let orderDetailsShowing = !this.requestStore.getState()
+ .orderDetailsShowing;
+ this.requestStore.setState({ orderDetailsShowing });
+ break;
+ case this._payButton:
+ this.pay();
+ break;
+ case this._manageText:
+ if (event.target instanceof HTMLAnchorElement) {
+ this.openPreferences(event);
+ }
+ break;
+ }
+ }
+
+ openPreferences(event) {
+ paymentRequest.openPreferences();
+ event.preventDefault();
+ }
+
+ cancelRequest() {
+ paymentRequest.cancel();
+ }
+
+ pay() {
+ let state = this.requestStore.getState();
+ let {
+ selectedPayerAddress,
+ selectedPaymentCard,
+ selectedPaymentCardSecurityCode,
+ selectedShippingAddress,
+ } = state;
+
+ let data = {
+ selectedPaymentCardGUID: selectedPaymentCard,
+ selectedPaymentCardSecurityCode,
+ };
+
+ data.selectedShippingAddressGUID = state.request.paymentOptions
+ .requestShipping
+ ? selectedShippingAddress
+ : null;
+
+ data.selectedPayerAddressGUID = this._isPayerRequested(
+ state.request.paymentOptions
+ )
+ ? selectedPayerAddress
+ : null;
+
+ paymentRequest.pay(data);
+ }
+
+ /**
+ * Called when the selectedShippingAddress or its properties are changed.
+ * @param {string} shippingAddressGUID
+ */
+ changeShippingAddress(shippingAddressGUID) {
+ // Clear shipping address merchant errors when the shipping address changes.
+ let request = Object.assign({}, this.requestStore.getState().request);
+ request.paymentDetails = Object.assign({}, request.paymentDetails);
+ request.paymentDetails.shippingAddressErrors = {};
+ this.requestStore.setState({ request });
+
+ paymentRequest.changeShippingAddress({
+ shippingAddressGUID,
+ });
+ }
+
+ changeShippingOption(optionID) {
+ paymentRequest.changeShippingOption({
+ optionID,
+ });
+ }
+
+ /**
+ * Called when the selectedPaymentCard or its relevant properties or billingAddress are changed.
+ * @param {string} selectedPaymentCardBillingAddressGUID
+ */
+ changePaymentMethod(selectedPaymentCardBillingAddressGUID) {
+ // Clear paymentMethod merchant errors when the paymentMethod or billingAddress changes.
+ let request = Object.assign({}, this.requestStore.getState().request);
+ request.paymentDetails = Object.assign({}, request.paymentDetails);
+ request.paymentDetails.paymentMethodErrors = null;
+ this.requestStore.setState({ request });
+
+ paymentRequest.changePaymentMethod({
+ selectedPaymentCardBillingAddressGUID,
+ });
+ }
+
+ /**
+ * Called when the selectedPayerAddress or its relevant properties are changed.
+ * @param {string} payerAddressGUID
+ */
+ changePayerAddress(payerAddressGUID) {
+ // Clear payer address merchant errors when the payer address changes.
+ let request = Object.assign({}, this.requestStore.getState().request);
+ request.paymentDetails = Object.assign({}, request.paymentDetails);
+ request.paymentDetails.payerErrors = {};
+ this.requestStore.setState({ request });
+
+ paymentRequest.changePayerAddress({
+ payerAddressGUID,
+ });
+ }
+
+ _isPayerRequested(paymentOptions) {
+ return (
+ paymentOptions.requestPayerName ||
+ paymentOptions.requestPayerEmail ||
+ paymentOptions.requestPayerPhone
+ );
+ }
+
+ _getAdditionalDisplayItems(state) {
+ let methodId = state.selectedPaymentCard;
+ let modifier = paymentRequest.getModifierForPaymentMethod(state, methodId);
+ if (modifier && modifier.additionalDisplayItems) {
+ return modifier.additionalDisplayItems;
+ }
+ return [];
+ }
+
+ _updateCompleteStatus(state) {
+ let { completeStatus } = state.request;
+ switch (completeStatus) {
+ case "fail":
+ case "timeout":
+ case "unknown":
+ state.page = {
+ id: `completion-${completeStatus}-error`,
+ };
+ state.changesPrevented = false;
+ break;
+ case "": {
+ // When we get a DOM update for an updateWith() or retry() the completeStatus
+ // is "" when we need to show non-final screens. Don't set the page as we
+ // may be on a form instead of payment-summary
+ state.changesPrevented = false;
+ break;
+ }
+ }
+ return state;
+ }
+
+ /**
+ * Set some state from the privileged parent process.
+ * Other elements that need to set state should use their own `this.requestStore.setState`
+ * method provided by the `PaymentStateSubscriberMixin`.
+ *
+ * @param {object} state - See `PaymentsStore.setState`
+ */
+ // eslint-disable-next-line complexity
+ async setStateFromParent(state) {
+ let oldAddresses = paymentRequest.getAddresses(
+ this.requestStore.getState()
+ );
+ let oldBasicCards = paymentRequest.getBasicCards(
+ this.requestStore.getState()
+ );
+ if (state.request) {
+ state = this._updateCompleteStatus(state);
+ }
+ this.requestStore.setState(state);
+
+ // Check if any foreign-key constraints were invalidated.
+ state = this.requestStore.getState();
+ let {
+ selectedPayerAddress,
+ selectedPaymentCard,
+ selectedShippingAddress,
+ selectedShippingOption,
+ } = state;
+ let addresses = paymentRequest.getAddresses(state);
+ let { paymentOptions } = state.request;
+
+ if (paymentOptions.requestShipping) {
+ let shippingOptions = state.request.paymentDetails.shippingOptions;
+ let shippingAddress =
+ selectedShippingAddress && addresses[selectedShippingAddress];
+ let oldShippingAddress =
+ selectedShippingAddress && oldAddresses[selectedShippingAddress];
+
+ // Ensure `selectedShippingAddress` never refers to a deleted address.
+ // We also compare address timestamps to notify about changes
+ // made outside the payments UI.
+ if (shippingAddress) {
+ // invalidate the cached value if the address was modified
+ if (
+ oldShippingAddress &&
+ shippingAddress.guid == oldShippingAddress.guid &&
+ shippingAddress.timeLastModified !=
+ oldShippingAddress.timeLastModified
+ ) {
+ delete this._cachedState.selectedShippingAddress;
+ }
+ } else if (selectedShippingAddress !== null) {
+ // null out the `selectedShippingAddress` property if it is undefined,
+ // or if the address it pointed to was removed from storage.
+ log.debug("resetting invalid/deleted shipping address");
+ this.requestStore.setState({
+ selectedShippingAddress: null,
+ });
+ }
+
+ // Ensure `selectedShippingOption` never refers to a deleted shipping option and
+ // matches the merchant's selected option if the user hasn't made a choice.
+ if (
+ shippingOptions &&
+ (!selectedShippingOption ||
+ !shippingOptions.find(opt => opt.id == selectedShippingOption))
+ ) {
+ this._cachedState.selectedShippingOption = selectedShippingOption;
+ this.requestStore.setState({
+ // Use the DOM's computed selected shipping option:
+ selectedShippingOption: state.request.shippingOption,
+ });
+ }
+ }
+
+ let basicCards = paymentRequest.getBasicCards(state);
+ let oldPaymentMethod =
+ selectedPaymentCard && oldBasicCards[selectedPaymentCard];
+ let paymentMethod = selectedPaymentCard && basicCards[selectedPaymentCard];
+ if (
+ oldPaymentMethod &&
+ paymentMethod.guid == oldPaymentMethod.guid &&
+ paymentMethod.timeLastModified != oldPaymentMethod.timeLastModified
+ ) {
+ delete this._cachedState.selectedPaymentCard;
+ } else {
+ // Changes to the billing address record don't change the `timeLastModified`
+ // on the card record so we have to check for changes to the address separately.
+
+ let billingAddressGUID =
+ paymentMethod && paymentMethod.billingAddressGUID;
+ let billingAddress = billingAddressGUID && addresses[billingAddressGUID];
+ let oldBillingAddress =
+ billingAddressGUID && oldAddresses[billingAddressGUID];
+
+ if (
+ oldBillingAddress &&
+ billingAddress &&
+ billingAddress.timeLastModified != oldBillingAddress.timeLastModified
+ ) {
+ delete this._cachedState.selectedPaymentCard;
+ }
+ }
+
+ // Ensure `selectedPaymentCard` never refers to a deleted payment card.
+ if (selectedPaymentCard && !basicCards[selectedPaymentCard]) {
+ this.requestStore.setState({
+ selectedPaymentCard: null,
+ selectedPaymentCardSecurityCode: null,
+ });
+ }
+
+ if (this._isPayerRequested(state.request.paymentOptions)) {
+ let payerAddress =
+ selectedPayerAddress && addresses[selectedPayerAddress];
+ let oldPayerAddress =
+ selectedPayerAddress && oldAddresses[selectedPayerAddress];
+
+ if (
+ oldPayerAddress &&
+ payerAddress &&
+ ((paymentOptions.requestPayerName &&
+ payerAddress.name != oldPayerAddress.name) ||
+ (paymentOptions.requestPayerEmail &&
+ payerAddress.email != oldPayerAddress.email) ||
+ (paymentOptions.requestPayerPhone &&
+ payerAddress.tel != oldPayerAddress.tel))
+ ) {
+ // invalidate the cached value if the payer address fields were modified
+ delete this._cachedState.selectedPayerAddress;
+ }
+
+ // Ensure `selectedPayerAddress` never refers to a deleted address and refers
+ // to an address if one exists.
+ if (!addresses[selectedPayerAddress]) {
+ this.requestStore.setState({
+ selectedPayerAddress: Object.keys(addresses)[0] || null,
+ });
+ }
+ }
+ }
+
+ _renderPayButton(state) {
+ let completeStatus = state.request.completeStatus;
+ switch (completeStatus) {
+ case "processing":
+ case "success":
+ case "unknown": {
+ this._payButton.disabled = true;
+ this._payButton.textContent = this._payButton.dataset[
+ completeStatus + "Label"
+ ];
+ break;
+ }
+ case "": {
+ // initial/default state
+ this._payButton.textContent = this._payButton.dataset.label;
+ const INVALID_CLASS_NAME = "invalid-selected-option";
+ this._payButton.disabled =
+ (state.request.paymentOptions.requestShipping &&
+ (!this._shippingAddressPicker.selectedOption ||
+ this._shippingAddressPicker.classList.contains(
+ INVALID_CLASS_NAME
+ ) ||
+ !this._shippingOptionPicker.selectedOption)) ||
+ (this._isPayerRequested(state.request.paymentOptions) &&
+ (!this._payerAddressPicker.selectedOption ||
+ this._payerAddressPicker.classList.contains(
+ INVALID_CLASS_NAME
+ ))) ||
+ !this._paymentMethodPicker.securityCodeInput.isValid ||
+ !this._paymentMethodPicker.selectedOption ||
+ this._paymentMethodPicker.classList.contains(INVALID_CLASS_NAME) ||
+ state.changesPrevented;
+ break;
+ }
+ case "fail":
+ case "timeout": {
+ // pay button is hidden in fail/timeout states.
+ this._payButton.textContent = this._payButton.dataset.label;
+ this._payButton.disabled = true;
+ break;
+ }
+ default: {
+ throw new Error(`Invalid completeStatus: ${completeStatus}`);
+ }
+ }
+ }
+
+ _renderPayerFields(state) {
+ let paymentOptions = state.request.paymentOptions;
+ let payerRequested = this._isPayerRequested(paymentOptions);
+ let payerAddressForm = this.querySelector(
+ "address-form[selected-state-key='selectedPayerAddress']"
+ );
+
+ for (let element of this._payerRelatedEls) {
+ element.hidden = !payerRequested;
+ }
+
+ if (payerRequested) {
+ let fieldNames = new Set();
+ if (paymentOptions.requestPayerName) {
+ fieldNames.add("name");
+ }
+ if (paymentOptions.requestPayerEmail) {
+ fieldNames.add("email");
+ }
+ if (paymentOptions.requestPayerPhone) {
+ fieldNames.add("tel");
+ }
+ let addressFields = [...fieldNames].join(" ");
+ this._payerAddressPicker.setAttribute("address-fields", addressFields);
+ if (payerAddressForm.form) {
+ payerAddressForm.form.dataset.extraRequiredFields = addressFields;
+ }
+
+ // For the payer picker we want to have a line break after the name field (#1)
+ // if all three fields are requested.
+ if (fieldNames.size == 3) {
+ this._payerAddressPicker.setAttribute("break-after-nth-field", 1);
+ } else {
+ this._payerAddressPicker.removeAttribute("break-after-nth-field");
+ }
+ } else {
+ this._payerAddressPicker.removeAttribute("address-fields");
+ }
+ }
+
+ stateChangeCallback(state) {
+ super.stateChangeCallback(state);
+
+ // Don't dispatch change events for initial selectedShipping* changes at initialization
+ // if requestShipping is false.
+ if (state.request.paymentOptions.requestShipping) {
+ if (
+ state.selectedShippingAddress !=
+ this._cachedState.selectedShippingAddress
+ ) {
+ this.changeShippingAddress(state.selectedShippingAddress);
+ }
+
+ if (
+ state.selectedShippingOption != this._cachedState.selectedShippingOption
+ ) {
+ this.changeShippingOption(state.selectedShippingOption);
+ }
+ }
+
+ let selectedPaymentCard = state.selectedPaymentCard;
+ let basicCards = paymentRequest.getBasicCards(state);
+ let billingAddressGUID = (basicCards[selectedPaymentCard] || {})
+ .billingAddressGUID;
+ if (
+ selectedPaymentCard != this._cachedState.selectedPaymentCard &&
+ billingAddressGUID
+ ) {
+ // Update _cachedState to prevent an infinite loop when changePaymentMethod updates state.
+ this._cachedState.selectedPaymentCard = state.selectedPaymentCard;
+ this.changePaymentMethod(billingAddressGUID);
+ }
+
+ if (this._isPayerRequested(state.request.paymentOptions)) {
+ if (
+ state.selectedPayerAddress != this._cachedState.selectedPayerAddress
+ ) {
+ this.changePayerAddress(state.selectedPayerAddress);
+ }
+ }
+
+ this._cachedState.selectedShippingAddress = state.selectedShippingAddress;
+ this._cachedState.selectedShippingOption = state.selectedShippingOption;
+ this._cachedState.selectedPayerAddress = state.selectedPayerAddress;
+ }
+
+ render(state) {
+ let request = state.request;
+ let paymentDetails = request.paymentDetails;
+ this._hostNameEl.textContent = request.topLevelPrincipal.URI.displayHost;
+
+ let displayItems = request.paymentDetails.displayItems || [];
+ let additionalItems = this._getAdditionalDisplayItems(state);
+ this._viewAllButton.hidden =
+ !displayItems.length && !additionalItems.length;
+
+ let shippingType = state.request.paymentOptions.shippingType || "shipping";
+ let addressPickerLabel = this._shippingAddressPicker.dataset[
+ shippingType + "AddressLabel"
+ ];
+ this._shippingAddressPicker.setAttribute("label", addressPickerLabel);
+ let optionPickerLabel = this._shippingOptionPicker.dataset[
+ shippingType + "OptionsLabel"
+ ];
+ this._shippingOptionPicker.setAttribute("label", optionPickerLabel);
+
+ let shippingAddressForm = this.querySelector(
+ "address-form[selected-state-key='selectedShippingAddress']"
+ );
+ shippingAddressForm.dataset.titleAdd = this.dataset[
+ shippingType + "AddressTitleAdd"
+ ];
+ shippingAddressForm.dataset.titleEdit = this.dataset[
+ shippingType + "AddressTitleEdit"
+ ];
+
+ let totalItem = paymentRequest.getTotalItem(state);
+ let totalAmountEl = this.querySelector("#total > currency-amount");
+ totalAmountEl.value = totalItem.amount.value;
+ totalAmountEl.currency = totalItem.amount.currency;
+
+ // Show the total header on the address and basic card pages only during
+ // on-boarding(FTU) and on the payment summary page.
+ this._header.hidden =
+ !state.page.onboardingWizard && state.page.id != "payment-summary";
+
+ this._orderDetailsOverlay.hidden = !state.orderDetailsShowing;
+ let genericError = "";
+ if (
+ this._shippingAddressPicker.selectedOption &&
+ (!request.paymentDetails.shippingOptions ||
+ !request.paymentDetails.shippingOptions.length)
+ ) {
+ genericError = this._errorText.dataset[shippingType + "GenericError"];
+ }
+ this._errorText.textContent = paymentDetails.error || genericError;
+
+ let paymentOptions = request.paymentOptions;
+ for (let element of this._shippingRelatedEls) {
+ element.hidden = !paymentOptions.requestShipping;
+ }
+
+ this._renderPayerFields(state);
+
+ let isMac = /mac/i.test(navigator.platform);
+ for (let manageTextEl of this._manageText.children) {
+ manageTextEl.hidden = manageTextEl.dataset.os == "mac" ? !isMac : isMac;
+ let link = manageTextEl.querySelector("a");
+ // The href is only set to be exposed to accessibility tools so users know what will open.
+ // The actual opening happens from the click event listener.
+ link.href = "about:preferences#privacy-form-autofill";
+ }
+
+ this._renderPayButton(state);
+
+ for (let page of this._mainContainer.querySelectorAll(":scope > .page")) {
+ page.hidden = state.page.id != page.id;
+ }
+
+ this.toggleAttribute("changes-prevented", state.changesPrevented);
+ this.setAttribute("complete-status", request.completeStatus);
+ this._disabledOverlay.hidden = !state.changesPrevented;
+ }
+}
+
+customElements.define("payment-dialog", PaymentDialog);
diff --git a/browser/components/payments/res/containers/payment-method-picker.js b/browser/components/payments/res/containers/payment-method-picker.js
new file mode 100644
index 0000000000..3693d352a5
--- /dev/null
+++ b/browser/components/payments/res/containers/payment-method-picker.js
@@ -0,0 +1,199 @@
+/* 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 BasicCardOption from "../components/basic-card-option.js";
+import CscInput from "../components/csc-input.js";
+import HandleEventMixin from "../mixins/HandleEventMixin.js";
+import RichPicker from "./rich-picker.js";
+import paymentRequest from "../paymentRequest.js";
+
+/* import-globals-from ../unprivileged-fallbacks.js */
+
+/**
+ * <payment-method-picker></payment-method-picker>
+ * Container around add/edit links and <rich-select> with
+ * <basic-card-option> listening to savedBasicCards.
+ */
+
+export default class PaymentMethodPicker extends HandleEventMixin(RichPicker) {
+ constructor() {
+ super();
+ this.dropdown.setAttribute("option-type", "basic-card-option");
+ this.securityCodeInput = new CscInput();
+ this.securityCodeInput.className = "security-code-container";
+ this.securityCodeInput.placeholder = this.dataset.cscPlaceholder;
+ this.securityCodeInput.backTooltip = this.dataset.cscBackTooltip;
+ this.securityCodeInput.frontTooltip = this.dataset.cscFrontTooltip;
+ this.securityCodeInput.addEventListener("change", this);
+ this.securityCodeInput.addEventListener("input", this);
+ }
+
+ connectedCallback() {
+ super.connectedCallback();
+ this.dropdown.after(this.securityCodeInput);
+ }
+
+ get fieldNames() {
+ let fieldNames = [...BasicCardOption.recordAttributes];
+ return fieldNames;
+ }
+
+ render(state) {
+ let basicCards = paymentRequest.getBasicCards(state);
+ let desiredOptions = [];
+ for (let [guid, basicCard] of Object.entries(basicCards)) {
+ let optionEl = this.dropdown.getOptionByValue(guid);
+ if (!optionEl) {
+ optionEl = document.createElement("option");
+ optionEl.value = guid;
+ }
+
+ for (let key of BasicCardOption.recordAttributes) {
+ let val = basicCard[key];
+ if (val) {
+ optionEl.setAttribute(key, val);
+ } else {
+ optionEl.removeAttribute(key);
+ }
+ }
+
+ optionEl.textContent = BasicCardOption.formatSingleLineLabel(basicCard);
+ desiredOptions.push(optionEl);
+ }
+
+ this.dropdown.popupBox.textContent = "";
+ for (let option of desiredOptions) {
+ this.dropdown.popupBox.appendChild(option);
+ }
+
+ // Update selectedness after the options are updated
+ let selectedPaymentCardGUID = state[this.selectedStateKey];
+ if (selectedPaymentCardGUID) {
+ this.dropdown.value = selectedPaymentCardGUID;
+
+ if (selectedPaymentCardGUID !== this.dropdown.value) {
+ throw new Error(
+ `The option ${selectedPaymentCardGUID} ` +
+ `does not exist in the payment method picker`
+ );
+ }
+ } else {
+ this.dropdown.value = "";
+ }
+
+ let securityCodeState = state[this.selectedStateKey + "SecurityCode"];
+ if (
+ securityCodeState &&
+ securityCodeState != this.securityCodeInput.value
+ ) {
+ this.securityCodeInput.defaultValue = securityCodeState;
+ }
+
+ let selectedCardType =
+ (basicCards[selectedPaymentCardGUID] &&
+ basicCards[selectedPaymentCardGUID]["cc-type"]) ||
+ "";
+ this.securityCodeInput.cardType = selectedCardType;
+
+ super.render(state);
+ }
+
+ errorForSelectedOption(state) {
+ let superError = super.errorForSelectedOption(state);
+ if (superError) {
+ return superError;
+ }
+ let selectedOption = this.selectedOption;
+ if (!selectedOption) {
+ return "";
+ }
+
+ let basicCardMethod = state.request.paymentMethods.find(
+ method => method.supportedMethods == "basic-card"
+ );
+ let merchantNetworks =
+ basicCardMethod &&
+ basicCardMethod.data &&
+ basicCardMethod.data.supportedNetworks;
+ let acceptedNetworks =
+ merchantNetworks || PaymentDialogUtils.getCreditCardNetworks();
+ let selectedCard = paymentRequest.getBasicCards(state)[
+ selectedOption.value
+ ];
+ let isSupported =
+ selectedCard["cc-type"] &&
+ acceptedNetworks.includes(selectedCard["cc-type"]);
+ return isSupported ? "" : this.dataset.invalidLabel;
+ }
+
+ get selectedStateKey() {
+ return this.getAttribute("selected-state-key");
+ }
+
+ onInput(event) {
+ this.onInputOrChange(event);
+ }
+
+ onChange(event) {
+ this.onInputOrChange(event);
+ }
+
+ onInputOrChange({ currentTarget }) {
+ let selectedKey = this.selectedStateKey;
+ let stateChange = {};
+
+ if (!selectedKey) {
+ return;
+ }
+
+ switch (currentTarget) {
+ case this.dropdown: {
+ stateChange[selectedKey] = this.dropdown.value;
+ break;
+ }
+ case this.securityCodeInput: {
+ stateChange[
+ selectedKey + "SecurityCode"
+ ] = this.securityCodeInput.value;
+ break;
+ }
+ default: {
+ return;
+ }
+ }
+
+ this.requestStore.setState(stateChange);
+ }
+
+ onClick({ target }) {
+ let nextState = {
+ page: {
+ id: "basic-card-page",
+ },
+ "basic-card-page": {
+ selectedStateKey: this.selectedStateKey,
+ },
+ };
+
+ switch (target) {
+ case this.addLink: {
+ nextState["basic-card-page"].guid = null;
+ break;
+ }
+ case this.editLink: {
+ let state = this.requestStore.getState();
+ let selectedPaymentCardGUID = state[this.selectedStateKey];
+ nextState["basic-card-page"].guid = selectedPaymentCardGUID;
+ break;
+ }
+ default: {
+ throw new Error("Unexpected onClick");
+ }
+ }
+
+ this.requestStore.setState(nextState);
+ }
+}
+
+customElements.define("payment-method-picker", PaymentMethodPicker);
diff --git a/browser/components/payments/res/containers/rich-picker.css b/browser/components/payments/res/containers/rich-picker.css
new file mode 100644
index 0000000000..7b232518d0
--- /dev/null
+++ b/browser/components/payments/res/containers/rich-picker.css
@@ -0,0 +1,83 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+.rich-picker {
+ display: grid;
+ grid-template-columns: 5fr auto auto;
+ grid-template-areas:
+ "label edit add"
+ "dropdown dropdown dropdown"
+ "invalid invalid invalid";
+ padding-top: 8px;
+}
+
+.rich-picker > label {
+ color: #0c0c0d;
+ font-weight: 700;
+ grid-area: label;
+}
+
+.rich-picker > .add-link,
+.rich-picker > .edit-link {
+ padding: 0 8px;
+}
+
+.rich-picker > .add-link {
+ grid-area: add;
+}
+
+.rich-picker > .edit-link {
+ grid-area: edit;
+ border-inline-end: 1px solid #0C0C0D33;
+}
+
+.rich-picker > rich-select {
+ grid-area: dropdown;
+}
+
+.invalid-selected-option > rich-select > select {
+ border: 1px solid #c70011;
+}
+
+.rich-picker > .invalid-label {
+ grid-area: invalid;
+ font-weight: normal;
+ color: #c70011;
+}
+
+:not(.invalid-selected-option) > .invalid-label {
+ display: none;
+}
+
+/* Payment Method Picker */
+payment-method-picker.rich-picker {
+ grid-template-columns: 20fr 1fr auto auto;
+ grid-template-areas:
+ "label spacer edit add"
+ "dropdown csc csc csc"
+ "invalid invalid invalid invalid";
+}
+
+.security-code-container {
+ display: flex;
+ flex-grow: 1;
+ grid-area: csc;
+ margin: 10px 0; /* Has to be same as rich-select */
+}
+
+.rich-picker .security-code {
+ border: 1px solid #0C0C0D33;
+ /* Underlap the 1px border from common.css */
+ margin-inline-start: -1px;
+ flex-grow: 1;
+ padding: 8px;
+}
+
+.rich-picker .security-code:-moz-ui-invalid,
+.rich-picker .security-code:focus {
+ /* So the error outline and focus ring appear above the adjacent dropdown when appropriate. */
+ /* We don't want to always be on top or we will cover the error outline or focus outline from the
+ dropdown. */
+ z-index: 1;
+}
diff --git a/browser/components/payments/res/containers/rich-picker.js b/browser/components/payments/res/containers/rich-picker.js
new file mode 100644
index 0000000000..3752f67942
--- /dev/null
+++ b/browser/components/payments/res/containers/rich-picker.js
@@ -0,0 +1,114 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import PaymentStateSubscriberMixin from "../mixins/PaymentStateSubscriberMixin.js";
+import RichSelect from "../components/rich-select.js";
+
+export default class RichPicker extends PaymentStateSubscriberMixin(
+ HTMLElement
+) {
+ static get observedAttributes() {
+ return ["label"];
+ }
+
+ constructor() {
+ super();
+ this.classList.add("rich-picker");
+
+ this.dropdown = new RichSelect();
+ this.dropdown.addEventListener("change", this);
+
+ this.labelElement = document.createElement("label");
+
+ this.addLink = document.createElement("a");
+ this.addLink.className = "add-link";
+ this.addLink.href = "javascript:void(0)";
+ this.addLink.addEventListener("click", this);
+
+ this.editLink = document.createElement("a");
+ this.editLink.className = "edit-link";
+ this.editLink.href = "javascript:void(0)";
+ this.editLink.addEventListener("click", this);
+
+ this.invalidLabel = document.createElement("label");
+ this.invalidLabel.className = "invalid-label";
+ }
+
+ connectedCallback() {
+ if (!this.dropdown.popupBox.id) {
+ this.dropdown.popupBox.id =
+ "select-" + Math.floor(Math.random() * 1000000);
+ }
+ this.labelElement.setAttribute("for", this.dropdown.popupBox.id);
+ this.invalidLabel.setAttribute("for", this.dropdown.popupBox.id);
+
+ // The document order, by default, controls tab order so keep that in mind if changing this.
+ this.appendChild(this.labelElement);
+ this.appendChild(this.dropdown);
+ this.appendChild(this.editLink);
+ this.appendChild(this.addLink);
+ this.appendChild(this.invalidLabel);
+ super.connectedCallback();
+ }
+
+ attributeChangedCallback(name, oldValue, newValue) {
+ if (name == "label") {
+ this.labelElement.textContent = newValue;
+ }
+ }
+
+ render(state) {
+ this.editLink.hidden = !this.dropdown.value;
+
+ let errorText = this.errorForSelectedOption(state);
+ this.classList.toggle("invalid-selected-option", !!errorText);
+ this.invalidLabel.textContent = errorText;
+ this.addLink.textContent = this.dataset.addLinkLabel;
+ this.editLink.textContent = this.dataset.editLinkLabel;
+ }
+
+ get selectedOption() {
+ return this.dropdown.selectedOption;
+ }
+
+ get selectedRichOption() {
+ return this.dropdown.selectedRichOption;
+ }
+
+ get requiredFields() {
+ return this.selectedOption ? this.selectedOption.requiredFields || [] : [];
+ }
+
+ get fieldNames() {
+ return [];
+ }
+
+ /**
+ * @param {object} state Application state
+ * @returns {string} Containing an error message for the picker or "" for no error.
+ */
+ errorForSelectedOption(state) {
+ if (!this.selectedOption) {
+ return "";
+ }
+ if (!this.dataset.invalidLabel) {
+ throw new Error("data-invalid-label is required");
+ }
+ return this.missingFieldsOfSelectedOption().length
+ ? this.dataset.invalidLabel
+ : "";
+ }
+
+ missingFieldsOfSelectedOption() {
+ let selectedOption = this.selectedOption;
+ if (!selectedOption) {
+ return [];
+ }
+
+ let fieldNames = this.selectedRichOption.requiredFields || [];
+
+ // Return all field names that are empty or missing from the option.
+ return fieldNames.filter(name => !selectedOption.getAttribute(name));
+ }
+}
diff --git a/browser/components/payments/res/containers/shipping-option-picker.js b/browser/components/payments/res/containers/shipping-option-picker.js
new file mode 100644
index 0000000000..01a97f1ac4
--- /dev/null
+++ b/browser/components/payments/res/containers/shipping-option-picker.js
@@ -0,0 +1,72 @@
+/* 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 RichPicker from "./rich-picker.js";
+import ShippingOption from "../components/shipping-option.js";
+import HandleEventMixin from "../mixins/HandleEventMixin.js";
+
+/**
+ * <shipping-option-picker></shipping-option-picker>
+ * Container around <rich-select> with
+ * <option> listening to shippingOptions.
+ */
+
+export default class ShippingOptionPicker extends HandleEventMixin(RichPicker) {
+ constructor() {
+ super();
+ this.dropdown.setAttribute("option-type", "shipping-option");
+ }
+
+ render(state) {
+ this.addLink.hidden = true;
+ this.editLink.hidden = true;
+
+ // If requestShipping is true but paymentDetails.shippingOptions isn't defined
+ // then use an empty array as a fallback.
+ let shippingOptions = state.request.paymentDetails.shippingOptions || [];
+ let desiredOptions = [];
+ for (let option of shippingOptions) {
+ let optionEl = this.dropdown.getOptionByValue(option.id);
+ if (!optionEl) {
+ optionEl = document.createElement("option");
+ optionEl.value = option.id;
+ }
+
+ optionEl.setAttribute("label", option.label);
+ optionEl.setAttribute("amount-currency", option.amount.currency);
+ optionEl.setAttribute("amount-value", option.amount.value);
+
+ optionEl.textContent = ShippingOption.formatSingleLineLabel(option);
+ desiredOptions.push(optionEl);
+ }
+
+ this.dropdown.popupBox.textContent = "";
+ for (let option of desiredOptions) {
+ this.dropdown.popupBox.appendChild(option);
+ }
+
+ // Update selectedness after the options are updated
+ let selectedShippingOption = state.selectedShippingOption;
+ this.dropdown.value = selectedShippingOption;
+
+ if (
+ selectedShippingOption &&
+ selectedShippingOption !== this.dropdown.popupBox.value
+ ) {
+ throw new Error(
+ `The option ${selectedShippingOption} ` +
+ `does not exist in the shipping option picker`
+ );
+ }
+ }
+
+ onChange(event) {
+ let selectedOptionId = this.dropdown.value;
+ this.requestStore.setState({
+ selectedShippingOption: selectedOptionId,
+ });
+ }
+}
+
+customElements.define("shipping-option-picker", ShippingOptionPicker);
diff --git a/browser/components/payments/res/containers/timeout.svg b/browser/components/payments/res/containers/timeout.svg
new file mode 100644
index 0000000000..a9c96ababd
--- /dev/null
+++ b/browser/components/payments/res/containers/timeout.svg
@@ -0,0 +1,84 @@
+<!-- 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="183" height="159" version="1.1">
+ <defs>
+ <linearGradient id="a" x1="-58.737%" x2="177.192%" y1="-3.847%" y2="112.985%">
+ <stop offset="0%" stop-color="#CCFBFF"/>
+ <stop offset="100%" stop-color="#C9E4FF"/>
+ </linearGradient>
+ <linearGradient id="b" x1="-62.081%" x2="144.194%" y1="-14.656%" y2="104.338%">
+ <stop offset="0%" stop-color="#00C8D7"/>
+ <stop offset="100%" stop-color="#008EA4"/>
+ </linearGradient>
+ <linearGradient id="c" x1="-93.784%" x2="130.325%" y1="-512.631%" y2="364.268%">
+ <stop offset="0%" stop-color="#00C8D7"/>
+ <stop offset="100%" stop-color="#0A84FF"/>
+ </linearGradient>
+ <linearGradient id="d" x1="-632.1%" x2="878.563%" y1="-1341.641%" y2="1656.303%">
+ <stop offset="0%" stop-color="#00C8D7"/>
+ <stop offset="100%" stop-color="#0A84FF"/>
+ </linearGradient>
+ <linearGradient id="e" x1="-145.225%" x2="166.502%" y1="-230.02%" y2="260.392%">
+ <stop offset="0%" stop-color="#00C8D7"/>
+ <stop offset="100%" stop-color="#0A84FF"/>
+ </linearGradient>
+ <linearGradient id="f" x1="-16.485%" x2="216.233%" y1="-373.776%" y2="1088.73%">
+ <stop offset="0%" stop-color="#00C8D7"/>
+ <stop offset="100%" stop-color="#0A84FF"/>
+ </linearGradient>
+ <linearGradient id="g" x1="-13.212%" x2="220.924%" y1="-398.815%" y2="1263.59%">
+ <stop offset="0%" stop-color="#00C8D7"/>
+ <stop offset="100%" stop-color="#0A84FF"/>
+ </linearGradient>
+ <linearGradient id="h" x1="56.524%" x2="154.312%" y1="90.974%" y2="705.12%">
+ <stop offset="0%" stop-color="#00C8D7"/>
+ <stop offset="100%" stop-color="#008EA4"/>
+ </linearGradient>
+ <linearGradient id="i" x1="-2629.906%" x2="2946.182%" y1="-2629.905%" y2="2946.183%">
+ <stop offset="0%" stop-color="#00C8D7"/>
+ <stop offset="100%" stop-color="#0A84FF"/>
+ </linearGradient>
+ <linearGradient id="j" x1="-2788.189%" x2="2787.9%" y1="-2788.189%" y2="2787.9%">
+ <stop offset="0%" stop-color="#00C8D7"/>
+ <stop offset="100%" stop-color="#0A84FF"/>
+ </linearGradient>
+ <linearGradient id="k" x1="-2975.533%" x2="2600.555%" y1="-2975.533%" y2="2600.555%">
+ <stop offset="0%" stop-color="#00C8D7"/>
+ <stop offset="100%" stop-color="#0A84FF"/>
+ </linearGradient>
+ <linearGradient id="l" x1="-2968.553%" x2="2607.535%" y1="-2968.552%" y2="2607.537%">
+ <stop offset="0%" stop-color="#00C8D7"/>
+ <stop offset="100%" stop-color="#0A84FF"/>
+ </linearGradient>
+ <linearGradient id="m" x1="-47.612%" x2="165.227%" y1="-4.394%" y2="113.507%">
+ <stop offset="0%" stop-color="#00C8D7"/>
+ <stop offset="100%" stop-color="#0A84FF"/>
+ </linearGradient>
+ </defs>
+ <g fill="none" fill-rule="nonzero" stroke="none" stroke-width="1">
+ <path fill="#EAEAEE" d="M41.548 94.855h110.678a1 1 0 1 0 0 -2h-110.678a1 1 0 0 0 0 2zm14.952 -5h27.764a0.5 0.5 0 1 0 0 -1h-27.765a0.5 0.5 0 1 0 0 1zm-35.386 8.869a0.5 0.5 0 0 1 0.5 -0.5h12a0.5 0.5 0 1 1 0 1h-12a0.5 0.5 0 0 1 -0.5 -0.5zm20 0a0.5 0.5 0 0 1 0.5 -0.5h3a0.5 0.5 0 1 1 0 1h-3a0.5 0.5 0 0 1 -0.5 -0.5zm7 0a0.5 0.5 0 0 1 0.5 -0.5h1a0.5 0.5 0 1 1 0 1h-1a0.5 0.5 0 0 1 -0.5 -0.5zm10 0a0.5 0.5 0 0 1 0.5 -0.5h12a0.5 0.5 0 1 1 0 1h-12a0.5 0.5 0 0 1 -0.5 -0.5zm20 0a0.5 0.5 0 0 1 0.5 -0.5h3a0.5 0.5 0 1 1 0 1h-3a0.5 0.5 0 0 1 -0.5 -0.5zm7 0a0.5 0.5 0 0 1 0.5 -0.5h1a0.5 0.5 0 1 1 0 1h-1a0.5 0.5 0 0 1 -0.5 -0.5zm10 0a0.5 0.5 0 0 1 0.5 -0.5h12a0.5 0.5 0 0 1 0 1h-12a0.5 0.5 0 0 1 -0.5 -0.5zm20 0a0.5 0.5 0 0 1 0.5 -0.5h3a0.5 0.5 0 0 1 0 1h-3a0.5 0.5 0 0 1 -0.5 -0.5zm7 0a0.5 0.5 0 0 1 0.5 -0.5h1a0.5 0.5 0 0 1 0 1h-1a0.5 0.5 0 0 1 -0.5 -0.5zm10 0a0.5 0.5 0 0 1 0.5 -0.5h12a0.5 0.5 0 0 1 0 1h-12a0.5 0.5 0 0 1 -0.5 -0.5zm20 0a0.5 0.5 0 0 1 0.5 -0.5h3a0.5 0.5 0 0 1 0 1h-3a0.5 0.5 0 0 1 -0.5 -0.5zm7 0a0.5 0.5 0 0 1 0.5 -0.5h1a0.5 0.5 0 0 1 0 1h-1a0.5 0.5 0 0 1 -0.5 -0.5zm10 0a0.5 0.5 0 0 1 0.5 -0.5h12a0.5 0.5 0 0 1 0 1h-12a0.5 0.5 0 0 1 -0.5 -0.5z"/>
+ <path fill="#FFF" d="M1.474 63.811h25.423s-7.955 -17.777 8.932 -20.076c15.062 -2.05 21.014 13.427 21.014 13.427s1.786 -8.93 10.743 -7.221c8.832 1.684 15.353 15.889 15.353 15.889h22.137"/>
+ <path fill="#EAEAEE" d="M105.51 61.633h-6.544a0.588 0.588 0 1 1 0 -1.176h6.545a0.588 0.588 0 0 1 0 1.176zm-17.132 0h-1.176a0.588 0.588 0 1 1 0 -1.176h1.176a0.588 0.588 0 1 1 0 1.176zm-61.046 -0.712h-1.893a0.588 0.588 0 0 1 0 -1.176h1.023a24.94 24.94 0 0 1 -0.269 -0.756 0.588 0.588 0 0 1 1.115 -0.377 18.812 18.812 0 0 0 0.56 1.48 0.588 0.588 0 0 1 -0.536 0.829zm-11.305 0h-14.117a0.588 0.588 0 1 1 0 -1.176h14.117a0.588 0.588 0 0 1 0 1.176zm66.928 -0.064a0.588 0.588 0 0 1 -0.514 -0.301 44.273 44.273 0 0 0 -1.828 -2.974 0.588 0.588 0 1 1 0.977 -0.654 45.65 45.65 0 0 1 1.878 3.053 0.588 0.588 0 0 1 -0.513 0.876zm-57.304 -6.042a0.588 0.588 0 0 1 -0.582 -0.507 20.77 20.77 0 0 1 -0.134 -1.205 0.588 0.588 0 0 1 1.173 -0.094 19.39 19.39 0 0 0 0.126 1.136 0.588 0.588 0 0 1 -0.583 0.67zm30.647 -2.594a0.587 0.587 0 0 1 -0.519 -0.31 26.496 26.496 0 0 0 -0.57 -1.001 0.588 0.588 0 1 1 1.01 -0.605 27.467 27.467 0 0 1 0.596 1.048 0.588 0.588 0 0 1 -0.517 0.868zm18.676 -1.503a0.586 0.586 0 0 1 -0.383 -0.143 14.722 14.722 0 0 0 -6.68 -3.535 8.578 8.578 0 0 0 -5.628 0.61 0.588 0.588 0 1 1 -0.54 -1.046 9.747 9.747 0 0 1 6.389 -0.72 15.855 15.855 0 0 1 7.225 3.8 0.588 0.588 0 0 1 -0.383 1.034zm-22.019 -3.335a0.587 0.587 0 0 1 -0.443 -0.202 21.943 21.943 0 0 0 -2.45 -2.41 0.588 0.588 0 0 1 0.755 -0.903 23.254 23.254 0 0 1 2.582 2.54 0.588 0.588 0 0 1 -0.444 0.975zm-24.258 -3.43a0.589 0.589 0 0 1 -0.395 -1.025 14.421 14.421 0 0 1 7.882 -3.255 19.345 19.345 0 0 1 5.96 0.08 0.588 0.588 0 1 1 -0.203 1.158 18.263 18.263 0 0 0 -5.597 -0.073 13.284 13.284 0 0 0 -7.253 2.963 0.59 0.59 0 0 1 -0.394 0.152z"/>
+ <path fill="#FFF" d="M106.242 66.149h-104.771a1.176 1.176 0 1 1 0 -2.353h104.771a1.176 1.176 0 0 1 0 2.353zm1.978 -51.759h14.187s-4.44 -9.92 4.985 -11.203c8.404 -1.144 11.726 7.493 11.726 7.493s0.907 -4.089 5.995 -4.03c4.756 0.055 8.38 7.914 8.568 8.867h12.353"/>
+ <path fill="#EAEAEE" d="M122.823 12.8h-14.167a0.59 0.59 0 1 1 0 -1.18h14.167a0.59 0.59 0 0 1 0 1.18zm43.647 -0.184h-0.545a0.59 0.59 0 1 1 0 -1.18h0.545a0.59 0.59 0 1 1 0 1.18zm-5.267 0h-3.542a0.59 0.59 0 1 1 0 -1.18h3.542a0.59 0.59 0 1 1 0 1.18zm-21.648 -3.527a0.614 0.614 0 0 1 -0.553 -0.384l-0.088 -0.207a0.59 0.59 0 0 1 0.23 -0.74 5.483 5.483 0 0 1 5.186 -4 7.111 7.111 0 0 1 1.33 0.132 10.622 10.622 0 0 1 5.267 3.045 0.59 0.59 0 0 1 -0.822 0.848 9.484 9.484 0 0 0 -4.666 -2.733 5.935 5.935 0 0 0 -1.109 -0.111c-3.402 0 -4.166 3.527 -4.196 3.678a0.592 0.592 0 0 1 -0.579 0.472zm-16.422 -5.27a0.59 0.59 0 0 1 -0.45 -0.973 6.799 6.799 0 0 1 3.236 -2.026 0.59 0.59 0 1 1 0.351 1.127 5.632 5.632 0 0 0 -2.688 1.665 0.589 0.589 0 0 1 -0.45 0.208zm8.784 -1.987a0.601 0.601 0 0 1 -0.156 -0.02 9.055 9.055 0 0 0 -1.083 -0.225 0.59 0.59 0 0 1 -0.5 -0.668 0.6 0.6 0 0 1 0.668 -0.5 10.36 10.36 0 0 1 1.224 0.253 0.59 0.59 0 0 1 -0.153 1.16z"/>
+ <path fill="#FFF" d="M166.948 16.76h-58.468a1.18 1.18 0 0 1 0 -2.36h58.468a1.18 1.18 0 0 1 0 2.36z"/>
+ <ellipse cx="99.859" cy="152.41" fill="#EAEAEE" rx="30" ry="5.802"/>
+ <path fill="#F9F9FA" d="M116.421 66.559c-4.126 4.126 -9.051 9.488 -9.656 16.465 0.605 6.977 5.53 12.339 9.656 16.465a26.199 26.199 0 0 1 9.126 19.9v11.242h-52.517v-11.242a26.199 26.199 0 0 1 9.126 -19.9c4.126 -4.126 9.051 -9.488 9.656 -16.465 -0.605 -6.977 -5.53 -12.339 -9.656 -16.465a26.199 26.199 0 0 1 -9.126 -19.9v-11.369h52.517v11.369a26.199 26.199 0 0 1 -9.126 19.9z"/>
+ <path fill="url(#a)" d="M35.304 21.239c-3.349 4.126 -8.666 10.808 -9.157 17.785 0.491 6.977 5.808 13.659 9.157 17.785a25.765 25.765 0 0 1 7.408 18.58v11.242h-42.627v-11.242a25.765 25.765 0 0 1 7.407 -18.58c3.35 -4.126 8.667 -10.808 9.157 -17.785 -0.49 -6.977 -5.808 -13.659 -9.157 -17.785a25.765 25.765 0 0 1 -7.407 -18.58v-2.13a95.816 95.816 0 0 0 22.369 2.64c12.527 0 20.258 -2.64 20.258 -2.64v2.13a25.765 25.765 0 0 1 -7.408 18.58z" transform="translate(78 44)"/>
+ <path fill="url(#b)" d="M55.548 6.222h-52.518a2.932 2.932 0 0 1 0 -5.864h52.518a2.932 2.932 0 0 1 0 5.864zm2.932 92.409a2.932 2.932 0 0 0 -2.932 -2.932h-52.518a2.932 2.932 0 0 0 0 5.863h52.518a2.932 2.932 0 0 0 2.932 -2.931z" transform="translate(70 32)"/>
+ <path fill="url(#c)" d="M129.028 130.684c0.334 5 -13.767 7.667 -29.749 7.667 -15.981 0 -28.937 -3.358 -28.937 -7.5 0 -4.143 12.956 -7.5 28.937 -7.5 15.982 0 29.474 3.2 29.75 7.333z"/>
+ <path fill="#F9F9FA" d="M125.654 127.755c0 2.185 -11.233 5.596 -25.792 5.596 -14.56 0 -26.932 -3.41 -26.932 -5.596 0 -2.185 11.802 -3.956 26.362 -3.956 14.56 0 26.362 1.771 26.362 3.956z"/>
+ <path fill="url(#d)" d="M95.233 76.435s1.978 4.121 4.286 4.286c2.307 0.165 4.43 -4.388 4.43 -4.388l-8.716 0.102z"/>
+ <path fill="url(#e)" d="M99.288 102.48c7.502 0 21.137 23.169 21.137 23.169s-1.442 3.702 -20.563 3.702c-19.122 0 -21.71 -3.702 -21.71 -3.702s13.763 -23.17 21.136 -23.17z"/>
+ <path fill="url(#f)" d="M127.852 37.313c0 2.185 -12.049 5.038 -27.657 5.038s-28.864 -2.853 -28.864 -5.038 12.653 -3.956 28.26 -3.956c15.609 0 28.261 1.771 28.261 3.956z"/>
+ <ellipse cx="99.42" cy="33.357" fill="url(#g)" rx="28.089" ry="3.956"/>
+ <ellipse cx="99.603" cy="76.333" fill="url(#h)" rx="4.346" ry="1"/>
+ <circle cx="99.393" cy="84.717" r="1.181" fill="url(#i)"/>
+ <circle cx="98.996" cy="92.589" r="1.181" fill="url(#j)"/>
+ <circle cx="98.145" cy="102.288" r="1.181" fill="url(#k)"/>
+ <circle cx="101.388" cy="98.715" r="1.181" fill="url(#l)"/>
+ <path fill="#F9F9FA" d="M84.628 53.363a1.833 1.833 0 0 1 -0.264 -0.026l-0.237 -0.08a41.18 41.18 0 0 0 -0.238 -0.118 2.238 2.238 0 0 1 -0.197 -0.172 1.325 1.325 0 0 1 -0.382 -0.922 1.359 1.359 0 0 1 0.105 -0.515 1.297 1.297 0 0 1 0.277 -0.422 1.012 1.012 0 0 1 0.197 -0.158 2.196 2.196 0 0 1 0.238 -0.132 1.55 1.55 0 0 1 0.237 -0.066 1.331 1.331 0 0 1 1.2 0.356 1.297 1.297 0 0 1 0.278 0.422 1.169 1.169 0 0 1 0.105 0.515 1.329 1.329 0 0 1 -1.319 1.318zm-0.221 69.007a1.319 1.319 0 0 1 -1.318 -1.282c-0.227 -8.439 2.93 -13.501 3.064 -13.712a1.319 1.319 0 0 1 2.227 1.413c-0.057 0.092 -2.858 4.684 -2.653 12.227a1.32 1.32 0 0 1 -1.283 1.355h-0.037zm8.793 -54.507a1.319 1.319 0 0 1 -1.002 -0.46 102.116 102.116 0 0 1 -6.712 -9.614 1.319 1.319 0 0 1 2.24 -1.39 101.694 101.694 0 0 0 6.476 9.287 1.32 1.32 0 0 1 -1.002 2.177z"/>
+ <path fill="url(#m)" d="M127.377 126.244v-6.856a28.144 28.144 0 0 0 -9.656 -21.217c-4.074 -4.092 -8.47 -8.976 -9.052 -15.1 0.582 -6.218 4.978 -11.103 9.026 -15.17a28.118 28.118 0 0 0 9.682 -21.242v-6.529a3.426 3.426 0 0 0 2.27 -2.456 4.696 4.696 0 0 0 -0.888 -5.862c-3.257 -4.026 -24.92 -4.23 -29.23 -4.23 -10.758 0 -24.656 0.91 -28.356 3.436a4.777 4.777 0 0 0 -2.674 4.272 4.71 4.71 0 0 0 2.19 3.98 4.54 4.54 0 0 0 0.73 0.528v6.86a28.09 28.09 0 0 0 9.657 21.218c4.074 4.093 8.47 8.98 9.051 15.101 -0.582 6.215 -4.977 11.102 -9.025 15.169a28.149 28.149 0 0 0 -9.68 21.317l-0.003 6.78a4.741 4.741 0 0 0 -2.92 4.387 4.455 4.455 0 0 0 0.319 1.685c1.459 6.64 27.596 6.83 30.571 6.83 2.951 0 28.885 -0.189 30.526 -6.661a4.734 4.734 0 0 0 -2.538 -6.24zm0.365 5.42l-0.041 0.132c-0.48 3.01 -15.028 5.032 -28.312 5.032 -16.33 0 -28.03 -2.665 -28.315 -5.058l-0.04 -0.145a2.426 2.426 0 0 1 2.205 -3.426h0.5v-8.81a25.817 25.817 0 0 1 8.946 -19.548c4.404 -4.42 9.154 -9.727 9.763 -16.86 -0.609 -7.047 -5.359 -12.354 -9.79 -16.8a25.792 25.792 0 0 1 -8.92 -19.522v-8.348l-0.318 -0.124a3.457 3.457 0 0 1 -1.232 -0.69l-0.117 -0.091a2.422 2.422 0 0 1 0.19 -4.336l0.094 -0.056c2.392 -1.774 14.074 -3.113 27.175 -3.113 15.231 0 26.482 1.747 27.434 3.379l0.115 0.134a2.396 2.396 0 0 1 0.333 3.42l-0.165 0.2 0.094 0.32c-0.057 0.126 -0.362 0.534 -1.938 1.048l-0.345 0.112v8.145a25.817 25.817 0 0 1 -8.946 19.547c-4.404 4.421 -9.154 9.728 -9.763 16.86 0.609 7.048 5.359 12.354 9.79 16.8a25.813 25.813 0 0 1 8.92 19.564v8.769h0.5a2.42 2.42 0 0 1 2.183 3.464z"/>
+ </g>
+</svg>
diff --git a/browser/components/payments/res/containers/warning.svg b/browser/components/payments/res/containers/warning.svg
new file mode 100644
index 0000000000..0b25be0f91
--- /dev/null
+++ b/browser/components/payments/res/containers/warning.svg
@@ -0,0 +1,32 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<svg xmlns="http://www.w3.org/2000/svg" width="162" height="147" version="1.1">
+ <defs>
+ <linearGradient id="a" x1="-5.18%" x2="85.305%" y1="13.831%" y2="117.402%">
+ <stop offset="0%" stop-color="#00C8D7"/>
+ <stop offset="100%" stop-color="#0A84FF"/>
+ </linearGradient>
+ <linearGradient id="b" x1="-26.306%" x2="110.545%" y1="-9.375%" y2="148.477%">
+ <stop offset="0%" stop-color="#CCFBFF"/>
+ <stop offset="100%" stop-color="#C9E4FF"/>
+ </linearGradient>
+ <linearGradient id="c" x1="-335.989%" x2="397.876%" y1="-66.454%" y2="146.763%">
+ <stop offset="0%" stop-color="#00C8D7"/>
+ <stop offset="100%" stop-color="#0A84FF"/>
+ </linearGradient>
+ </defs>
+ <g fill="none" fill-rule="nonzero" stroke="none" stroke-width="1">
+ <path fill="#EAEAEE" d="M20.53 83.44h110.678a1 1 0 1 0 0 -2h-110.679a1 1 0 0 0 0 2zm14.951 -5h27.765a0.5 0.5 0 1 0 0 -1h-27.765a0.5 0.5 0 1 0 0 1zm-35.385 8.87a0.5 0.5 0 0 1 0.5 -0.5h12a0.5 0.5 0 1 1 0 1h-12a0.5 0.5 0 0 1 -0.5 -0.5zm20 0a0.5 0.5 0 0 1 0.5 -0.5h3a0.5 0.5 0 1 1 0 1h-3a0.5 0.5 0 0 1 -0.5 -0.5zm7 0a0.5 0.5 0 0 1 0.5 -0.5h1a0.5 0.5 0 1 1 0 1h-1a0.5 0.5 0 0 1 -0.5 -0.5zm10 0a0.5 0.5 0 0 1 0.5 -0.5h12a0.5 0.5 0 1 1 0 1h-12a0.5 0.5 0 0 1 -0.5 -0.5zm20 0a0.5 0.5 0 0 1 0.5 -0.5h3a0.5 0.5 0 1 1 0 1h-3a0.5 0.5 0 0 1 -0.5 -0.5zm7 0a0.5 0.5 0 0 1 0.5 -0.5h1a0.5 0.5 0 1 1 0 1h-1a0.5 0.5 0 0 1 -0.5 -0.5zm10 0a0.5 0.5 0 0 1 0.5 -0.5h12a0.5 0.5 0 1 1 0 1h-12a0.5 0.5 0 0 1 -0.5 -0.5zm20 0a0.5 0.5 0 0 1 0.5 -0.5h3a0.5 0.5 0 1 1 0 1h-3a0.5 0.5 0 0 1 -0.5 -0.5zm7 0a0.5 0.5 0 0 1 0.5 -0.5h1a0.5 0.5 0 0 1 0 1h-1a0.5 0.5 0 0 1 -0.5 -0.5zm10 0a0.5 0.5 0 0 1 0.5 -0.5h12a0.5 0.5 0 0 1 0 1h-12a0.5 0.5 0 0 1 -0.5 -0.5zm20 0a0.5 0.5 0 0 1 0.5 -0.5h3a0.5 0.5 0 0 1 0 1h-3a0.5 0.5 0 0 1 -0.5 -0.5zm7 0a0.5 0.5 0 0 1 0.5 -0.5h1a0.5 0.5 0 0 1 0 1h-1a0.5 0.5 0 0 1 -0.5 -0.5zm10 0a0.5 0.5 0 0 1 0.5 -0.5h12a0.5 0.5 0 0 1 0 1h-12a0.5 0.5 0 0 1 -0.5 -0.5z"/>
+ <path fill="#FFF" d="M36.206 58.897h25.423s-7.955 -17.777 8.932 -20.077c15.062 -2.05 21.014 13.427 21.014 13.427s1.786 -8.93 10.743 -7.221c8.832 1.684 15.352 15.889 15.352 15.889h22.137"/>
+ <path fill="#EAEAEE" d="M140.243 56.719h-6.545a0.588 0.588 0 0 1 0 -1.177h6.545a0.588 0.588 0 0 1 0 1.177zm-17.133 0h-1.177a0.588 0.588 0 0 1 0 -1.177h1.177a0.588 0.588 0 0 1 0 1.177zm-61.047 -0.713h-1.892a0.588 0.588 0 1 1 0 -1.176h1.023a24.95 24.95 0 0 1 -0.27 -0.756 0.588 0.588 0 0 1 1.115 -0.377 18.81 18.81 0 0 0 0.562 1.48 0.588 0.588 0 0 1 -0.538 0.83zm-11.304 0h-14.118a0.588 0.588 0 1 1 0 -1.176h14.118a0.588 0.588 0 1 1 0 1.176zm66.928 -0.064a0.588 0.588 0 0 1 -0.515 -0.301 44.263 44.263 0 0 0 -1.827 -2.973 0.588 0.588 0 1 1 0.977 -0.655 45.639 45.639 0 0 1 1.878 3.053 0.588 0.588 0 0 1 -0.513 0.876zm-57.304 -6.042a0.588 0.588 0 0 1 -0.582 -0.507 20.768 20.768 0 0 1 -0.134 -1.205 0.588 0.588 0 0 1 1.173 -0.094 19.388 19.388 0 0 0 0.126 1.136 0.588 0.588 0 0 1 -0.583 0.67zm30.646 -2.594a0.587 0.587 0 0 1 -0.518 -0.31 26.496 26.496 0 0 0 -0.57 -1.001 0.588 0.588 0 1 1 1.01 -0.604 27.467 27.467 0 0 1 0.595 1.047 0.588 0.588 0 0 1 -0.517 0.868zm18.677 -1.503a0.586 0.586 0 0 1 -0.383 -0.142 14.722 14.722 0 0 0 -6.68 -3.536 8.578 8.578 0 0 0 -5.628 0.61 0.588 0.588 0 1 1 -0.54 -1.046 9.747 9.747 0 0 1 6.389 -0.72 15.855 15.855 0 0 1 7.225 3.8 0.588 0.588 0 0 1 -0.383 1.034zm-22.019 -3.335a0.587 0.587 0 0 1 -0.443 -0.201 21.943 21.943 0 0 0 -2.45 -2.41 0.588 0.588 0 0 1 0.755 -0.904 23.255 23.255 0 0 1 2.582 2.54 0.588 0.588 0 0 1 -0.444 0.975zm-24.259 -3.43a0.589 0.589 0 0 1 -0.394 -1.025 14.421 14.421 0 0 1 7.882 -3.254 19.345 19.345 0 0 1 5.959 0.079 0.588 0.588 0 1 1 -0.202 1.158 18.263 18.263 0 0 0 -5.598 -0.072 13.284 13.284 0 0 0 -7.252 2.963 0.59 0.59 0 0 1 -0.395 0.151z"/>
+ <path fill="#FFF" d="M140.974 61.234h-104.772a1.176 1.176 0 0 1 0 -2.353h104.772a1.176 1.176 0 0 1 0 2.353zm-120.522 -46.508h14.187s-4.44 -9.92 4.984 -11.204c8.405 -1.144 11.727 7.493 11.727 7.493s0.907 -4.089 5.995 -4.03c4.755 0.055 8.38 7.915 8.567 8.867h12.354"/>
+ <path fill="#EAEAEE" d="M35.055 13.136h-14.167a0.59 0.59 0 1 1 0 -1.18h14.167a0.59 0.59 0 1 1 0 1.18zm43.647 -0.185h-0.545a0.59 0.59 0 0 1 0 -1.18h0.545a0.59 0.59 0 0 1 0 1.18zm-5.267 0h-3.542a0.59 0.59 0 0 1 0 -1.18h3.542a0.59 0.59 0 0 1 0 1.18zm-21.648 -3.526a0.614 0.614 0 0 1 -0.554 -0.384l-0.087 -0.208a0.59 0.59 0 0 1 0.23 -0.739 5.483 5.483 0 0 1 5.186 -4 7.111 7.111 0 0 1 1.33 0.131 10.622 10.622 0 0 1 5.266 3.045 0.59 0.59 0 0 1 -0.822 0.848 9.484 9.484 0 0 0 -4.665 -2.733 5.934 5.934 0 0 0 -1.109 -0.11c-3.403 0 -4.166 3.526 -4.196 3.677a0.592 0.592 0 0 1 -0.58 0.473zm-16.422 -5.27a0.59 0.59 0 0 1 -0.45 -0.973 6.799 6.799 0 0 1 3.235 -2.027 0.59 0.59 0 0 1 0.352 1.127 5.632 5.632 0 0 0 -2.688 1.665 0.589 0.589 0 0 1 -0.45 0.208zm8.783 -1.988a0.601 0.601 0 0 1 -0.155 -0.02 9.053 9.053 0 0 0 -1.083 -0.224 0.59 0.59 0 0 1 -0.5 -0.669 0.6 0.6 0 0 1 0.668 -0.5 10.36 10.36 0 0 1 1.224 0.253 0.59 0.59 0 0 1 -0.154 1.16z"/>
+ <path fill="#FFF" d="M79.18 17.096h-58.468a1.18 1.18 0 1 1 0 -2.361h58.468a1.18 1.18 0 1 1 0 2.36z"/>
+ <path fill="url(#a)" d="M125.948 119.063h-93.75a2.821 2.821 0 0 1 -2.442 -4.232l46.874 -81.19a2.82 2.82 0 0 1 4.887 0.001l46.874 81.19 -0.001 -0.001a2.821 2.821 0 0 1 -2.442 4.232zm-46.875 -84.333a0.32 0.32 0 0 0 -0.277 0.16l-46.875 81.192a0.32 0.32 0 0 0 0.276 0.481h93.751a0.32 0.32 0 0 0 0.278 -0.48l-0.001 -0.001 -46.874 -81.19a0.32 0.32 0 0 0 -0.278 -0.162z"/>
+ <path fill="#F9F9FA" d="M79.073 34.73a0.32 0.32 0 0 0 -0.277 0.16l-46.875 81.192a0.32 0.32 0 0 0 0.276 0.481h93.751a0.32 0.32 0 0 0 0.278 -0.48l-0.001 -0.001 -46.874 -81.19a0.32 0.32 0 0 0 -0.278 -0.162z"/>
+ <ellipse cx="79.424" cy="140.995" fill="#EAEAEE" rx="49.833" ry="5.802"/>
+ <path fill="url(#b)" d="M79.073 42.313a0.275 0.275 0 0 0 -0.238 0.138l-40.24 69.699a0.275 0.275 0 0 0 0.237 0.413h80.481a0.275 0.275 0 0 0 0.238 -0.412v-0.001l-40.24 -69.699a0.274 0.274 0 0 0 -0.238 -0.138z"/>
+ <path fill="url(#c)" d="M83.67 87.928h-9.19l-1.535 -25.095h12.255l-1.53 25.095zm1.473 11.018c0 3.35 -2.717 6.067 -6.068 6.067 -3.35 0 -6.067 -2.716 -6.067 -6.067s2.716 -6.068 6.067 -6.068a6.1 6.1 0 0 1 6.068 6.068z"/>
+ </g>
+</svg>