diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-28 14:29:10 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-28 14:29:10 +0000 |
commit | 2aa4a82499d4becd2284cdb482213d541b8804dd (patch) | |
tree | b80bf8bf13c3766139fbacc530efd0dd9d54394c /browser/components/payments/res/containers | |
parent | Initial commit. (diff) | |
download | firefox-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 '')
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>
|