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 | |
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 'browser/components/payments/res')
48 files changed, 6220 insertions, 0 deletions
diff --git a/browser/components/payments/res/PaymentsStore.js b/browser/components/payments/res/PaymentsStore.js new file mode 100644 index 0000000000..7e439076d8 --- /dev/null +++ b/browser/components/payments/res/PaymentsStore.js @@ -0,0 +1,97 @@ +/* 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/. */ + +/** + * The PaymentsStore class provides lightweight storage with an async publish/subscribe mechanism. + * Synchronous state changes are batched to improve application performance and to reduce partial + * state propagation. + */ + +export default class PaymentsStore { + /** + * @param {object} [defaultState = {}] The initial state of the store. + */ + constructor(defaultState = {}) { + this._defaultState = Object.assign({}, defaultState); + this._state = defaultState; + this._nextNotifification = 0; + this._subscribers = new Set(); + } + + /** + * Get the current state as a shallow clone with a shallow freeze. + * You shouldn't modify any part of the returned state object as that would bypass notifying + * subscribers and could lead to subscribers assuming old state. + * + * @returns {Object} containing the current state + */ + getState() { + return Object.freeze(Object.assign({}, this._state)); + } + + /** + * Used for testing to reset to the default state from the constructor. + * @returns {Promise} returned by setState. + */ + async reset() { + return this.setState(this._defaultState); + } + + /** + * Augment the current state with the keys of `obj` and asynchronously notify + * state subscribers. As a result, multiple synchronous state changes will lead + * to a single subscriber notification which leads to better performance and + * reduces partial state changes. + * + * @param {Object} obj The object to augment the state with. Keys in the object + * will be shallow copied with Object.assign. + * + * @example If the state is currently {a:3} then setState({b:"abc"}) will result in a state of + * {a:3, b:"abc"}. + */ + async setState(obj) { + Object.assign(this._state, obj); + let thisChangeNum = ++this._nextNotifification; + + // Let any synchronous setState calls that happen after the current setState call + // complete first. + // Their effects on the state will be batched up before the callback is actually called below. + await Promise.resolve(); + + // Don't notify for state changes that are no longer the most recent. We only want to call the + // callback once with the latest state. + if (thisChangeNum !== this._nextNotifification) { + return; + } + + for (let subscriber of this._subscribers) { + try { + subscriber.stateChangeCallback(this.getState()); + } catch (ex) { + console.error(ex); + } + } + } + + /** + * Subscribe the object to state changes notifications via a `stateChangeCallback` method. + * + * @param {Object} component to receive state change callbacks via a `stateChangeCallback` method. + * If the component is already subscribed, do nothing. + */ + subscribe(component) { + if (this._subscribers.has(component)) { + return; + } + + this._subscribers.add(component); + } + + /** + * @param {Object} component to stop receiving state change callbacks. + */ + unsubscribe(component) { + this._subscribers.delete(component); + } +} diff --git a/browser/components/payments/res/components/accepted-cards.css b/browser/components/payments/res/components/accepted-cards.css new file mode 100644 index 0000000000..575e17b4f1 --- /dev/null +++ b/browser/components/payments/res/components/accepted-cards.css @@ -0,0 +1,108 @@ +/* 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/. */ + +accepted-cards { + margin: 1em 0; + display: flex; + flex-wrap: nowrap; + align-items: first baseline; +} + +.accepted-cards-label { + display: inline-block; + font-size: smaller; + flex: 0 2 content; + white-space: nowrap; +} + +.accepted-cards-list { + display: inline-block; + list-style-type: none; + margin: 0; + padding: 0; + flex: 2 1 auto; +} + +.accepted-cards-list > .accepted-cards-item { + display: inline-block; + width: 32px; + height: 32px; + padding: 0; + margin: 5px 0; + margin-inline-start: 10px; + vertical-align: middle; + background-repeat: no-repeat; + background-position: center; + background-size: contain; +} + +/* placeholders for specific card icons we don't yet have assets for */ +accepted-cards:not(.branded) .accepted-cards-item[data-network-id] { + width: 48px; + text-align: center; + background-image: url("./card-icon.svg"); + -moz-context-properties: fill-opacity; + fill-opacity: 0.5; +} +accepted-cards:not(.branded) .accepted-cards-item[data-network-id]::after { + box-sizing: border-box; + content: attr(data-network-id); + padding: 8px 4px 0 4px; + text-align: center; + font-size: 0.7rem; + display: inline-block; + overflow: hidden; + width: 100%; +} + +/* + We use .png / @2x.png images where we don't yet have a vector version of a logo +*/ +.accepted-cards-item[data-network-id="amex"] { + background-image: url("chrome://formautofill/content/third-party/cc-logo-amex.png"); +} + +.accepted-cards-item[data-network-id="cartebancaire"] { + background-image: url("chrome://formautofill/content/third-party/cc-logo-cartebancaire.png"); +} + +.accepted-cards-item[data-network-id="diners"] { + background-image: url("chrome://formautofill/content/third-party/cc-logo-diners.svg"); +} + +.accepted-cards-item[data-network-id="discover"] { + background-image: url("chrome://formautofill/content/third-party/cc-logo-discover.png"); +} + +.accepted-cards-item[data-network-id="jcb"] { + background-image: url("chrome://formautofill/content/third-party/cc-logo-jcb.svg"); +} + +.accepted-cards-item[data-network-id="mastercard"] { + background-image: url("chrome://formautofill/content/third-party/cc-logo-mastercard.svg"); +} + +.accepted-cards-item[data-network-id="mir"] { + background-image: url("chrome://formautofill/content/third-party/cc-logo-mir.svg"); +} + +.accepted-cards-item[data-network-id="unionpay"] { + background-image: url("chrome://formautofill/content/third-party/cc-logo-unionpay.svg"); +} + +.accepted-cards-item[data-network-id="visa"] { + background-image: url("chrome://formautofill/content/third-party/cc-logo-visa.svg"); +} + +@media (min-resolution: 1.1dppx) { + .accepted-cards-item[data-network-id="amex"] { + background-image: url("chrome://formautofill/content/third-party/cc-logo-amex@2x.png"); + } + .accepted-cards-item[data-network-id="cartebancaire"] { + background-image: url("chrome://formautofill/content/third-party/cc-logo-cartebancaire@2x.png"); + } + .accepted-cards-item[data-network-id="discover"] { + background-image: url("chrome://formautofill/content/third-party/cc-logo-discover@2x.png"); + } +} diff --git a/browser/components/payments/res/components/accepted-cards.js b/browser/components/payments/res/components/accepted-cards.js new file mode 100644 index 0000000000..c66e040576 --- /dev/null +++ b/browser/components/payments/res/components/accepted-cards.js @@ -0,0 +1,75 @@ +/* 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-globals-from ../unprivileged-fallbacks.js */ + +/** + * <accepted-cards></accepted-cards> + */ + +export default class AcceptedCards extends PaymentStateSubscriberMixin( + HTMLElement +) { + constructor() { + super(); + + this._listEl = document.createElement("ul"); + this._listEl.classList.add("accepted-cards-list"); + this._labelEl = document.createElement("span"); + this._labelEl.classList.add("accepted-cards-label"); + } + + connectedCallback() { + this.label = this.getAttribute("label"); + this.appendChild(this._labelEl); + + this._listEl.textContent = ""; + let allNetworks = PaymentDialogUtils.getCreditCardNetworks(); + for (let network of allNetworks) { + let item = document.createElement("li"); + item.classList.add("accepted-cards-item"); + item.dataset.networkId = network; + item.setAttribute("aria-role", "image"); + item.setAttribute("aria-label", network); + this._listEl.appendChild(item); + } + let isBranded = PaymentDialogUtils.isOfficialBranding(); + this.classList.toggle("branded", isBranded); + this.appendChild(this._listEl); + // Only call the connected super callback(s) once our markup is fully + // connected + super.connectedCallback(); + } + + render(state) { + let basicCardMethod = state.request.paymentMethods.find( + method => method.supportedMethods == "basic-card" + ); + let merchantNetworks = + basicCardMethod && + basicCardMethod.data && + basicCardMethod.data.supportedNetworks; + if (merchantNetworks && merchantNetworks.length) { + for (let item of this._listEl.children) { + let network = item.dataset.networkId; + item.hidden = !(network && merchantNetworks.includes(network)); + } + this.hidden = false; + } else { + // hide the whole list if the merchant didn't specify a preference + this.hidden = true; + } + } + + set label(value) { + this._labelEl.textContent = value; + } + + get acceptedItems() { + return Array.from(this._listEl.children).filter(item => !item.hidden); + } +} + +customElements.define("accepted-cards", AcceptedCards); diff --git a/browser/components/payments/res/components/address-option.css b/browser/components/payments/res/components/address-option.css new file mode 100644 index 0000000000..9619048ae6 --- /dev/null +++ b/browser/components/payments/res/components/address-option.css @@ -0,0 +1,29 @@ +/* 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/. */ + +address-option.rich-option { + grid-row-gap: 5px; +} + +address-option > .line { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +address-option > .line:empty { + /* Hide the 2nd line in cases where it's empty + (e.g. payer field with one or two fields requested) */ + display: none; +} + +address-option > .line > span { + white-space: nowrap; +} + +address-option > .line > span:empty::before { + /* Show the string for missing fields in grey when the field is empty */ + color: GrayText; + content: attr(data-missing-string); +} diff --git a/browser/components/payments/res/components/address-option.js b/browser/components/payments/res/components/address-option.js new file mode 100644 index 0000000000..2661f3e231 --- /dev/null +++ b/browser/components/payments/res/components/address-option.js @@ -0,0 +1,159 @@ +/* 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 ObservedPropertiesMixin from "../mixins/ObservedPropertiesMixin.js"; +import RichOption from "./rich-option.js"; +/* import-globals-from ../unprivileged-fallbacks.js */ + +/** + * Up to two-line address display. After bug 1475684 this will also be used for + * the single-line <option> substitute too. + * + * <rich-select> + * <address-option guid="98hgvnbmytfc" + * address-level1="MI" + * address-level2="Some City" + * email="foo@example.com" + * country="USA" + * name="Jared Wein" + * postal-code="90210" + * street-address="1234 Anywhere St" + * tel="+1 650 555-5555"></address-option> + * </rich-select> + * + * Attribute names follow FormAutofillStorage.jsm. + */ + +export default class AddressOption extends ObservedPropertiesMixin(RichOption) { + static get recordAttributes() { + return [ + "address-level1", + "address-level2", + "address-level3", + "country", + "email", + "guid", + "name", + "organization", + "postal-code", + "street-address", + "tel", + ]; + } + + static get observedAttributes() { + return RichOption.observedAttributes.concat( + AddressOption.recordAttributes, + "address-fields", + "break-after-nth-field", + "data-field-separator" + ); + } + + constructor() { + super(); + + this._line1 = document.createElement("div"); + this._line1.classList.add("line"); + this._line2 = document.createElement("div"); + this._line2.classList.add("line"); + + for (let name of AddressOption.recordAttributes) { + this[`_${name}`] = document.createElement("span"); + this[`_${name}`].classList.add(name); + // XXX Bug 1490816: Use appropriate strings + let missingValueString = + name.replace(/(-|^)([a-z])/g, ($0, $1, $2) => { + return $1.replace("-", " ") + $2.toUpperCase(); + }) + " Missing"; + this[`_${name}`].dataset.missingString = missingValueString; + } + } + + connectedCallback() { + this.appendChild(this._line1); + this.appendChild(this._line2); + super.connectedCallback(); + } + + static formatSingleLineLabel(address, addressFields) { + return PaymentDialogUtils.getAddressLabel(address, addressFields); + } + + get requiredFields() { + 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", + ]; + } + + render() { + // Clear the lines of the fields so we can append only the ones still + // visible in the correct order below. + this._line1.textContent = ""; + this._line2.textContent = ""; + + // Fill the fields with their text/strings. + // Fall back to empty strings to prevent 'null' from appearing. + for (let name of AddressOption.recordAttributes) { + let camelCaseName = super.constructor.kebabToCamelCase(name); + let fieldEl = this[`_${name}`]; + fieldEl.textContent = this[camelCaseName] || ""; + } + + let { fieldsOrder } = PaymentDialogUtils.getFormFormat(this.country); + // A subset of the requested fields may be returned if the fields don't apply to the country. + let requestedVisibleFields = this.addressFields || "mailing-address"; + let visibleFields = EditAddress.computeVisibleFields( + fieldsOrder, + requestedVisibleFields + ); + let visibleFieldCount = 0; + let requiredFields = this.requiredFields; + // Start by populating line 1 + let lineEl = this._line1; + // Which field number to start line 2 after. + let breakAfterNthField = this.breakAfterNthField || 2; + + // Now actually place the fields in the proper place on the lines. + for (let field of visibleFields) { + let fieldEl = this[`_${field.fieldId}`]; + if (!fieldEl) { + log.warn(`address-option render: '${field.fieldId}' doesn't exist`); + continue; + } + + if (!fieldEl.textContent && !requiredFields.includes(field.fieldId)) { + // The field is empty and we don't need to show "Missing …" so don't append. + continue; + } + + if (lineEl.children.length) { + lineEl.append(this.dataset.fieldSeparator); + } + lineEl.appendChild(fieldEl); + + // Add a break after this field, if requested. + if (++visibleFieldCount == breakAfterNthField) { + lineEl = this._line2; + } + } + } +} + +customElements.define("address-option", AddressOption); diff --git a/browser/components/payments/res/components/basic-card-option.css b/browser/components/payments/res/components/basic-card-option.css new file mode 100644 index 0000000000..29776b0722 --- /dev/null +++ b/browser/components/payments/res/components/basic-card-option.css @@ -0,0 +1,40 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +basic-card-option { + grid-column-gap: 1em; + grid-template-areas: "cc-type cc-number cc-exp cc-name"; + /* Need to set a minimum width for the cc-type svg in the <img> to fill */ + grid-template-columns: minmax(1em, auto); + justify-content: start; +} + +basic-card-option > .cc-number, +basic-card-option > .cc-name, +basic-card-option > .cc-exp, +basic-card-option > .cc-type { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +basic-card-option > .cc-number { + grid-area: cc-number; + /* Don't truncate the card number */ + overflow: visible; +} + +basic-card-option > .cc-name { + grid-area: cc-name; +} + +basic-card-option > .cc-exp { + grid-area: cc-exp; +} + +basic-card-option > .cc-type { + grid-area: cc-type; + height: 100%; + text-transform: capitalize; +} diff --git a/browser/components/payments/res/components/basic-card-option.js b/browser/components/payments/res/components/basic-card-option.js new file mode 100644 index 0000000000..cda4a5d892 --- /dev/null +++ b/browser/components/payments/res/components/basic-card-option.js @@ -0,0 +1,89 @@ +/* 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 ObservedPropertiesMixin from "../mixins/ObservedPropertiesMixin.js"; +import RichOption from "./rich-option.js"; + +/** + * <rich-select> + * <basic-card-option></basic-card-option> + * </rich-select> + */ + +export default class BasicCardOption extends ObservedPropertiesMixin( + RichOption +) { + static get recordAttributes() { + return ["cc-exp", "cc-name", "cc-number", "cc-type", "guid"]; + } + + static get observedAttributes() { + return RichOption.observedAttributes.concat( + BasicCardOption.recordAttributes + ); + } + + constructor() { + super(); + + for (let name of ["cc-name", "cc-number", "cc-exp", "cc-type"]) { + this[`_${name}`] = document.createElement( + name == "cc-type" ? "img" : "span" + ); + this[`_${name}`].classList.add(name); + } + } + + connectedCallback() { + for (let name of ["cc-name", "cc-number", "cc-exp", "cc-type"]) { + this.appendChild(this[`_${name}`]); + } + super.connectedCallback(); + } + + static formatCCNumber(ccNumber) { + // XXX: Bug 1470175 - This should probably be unified with CreditCard.jsm logic. + return ccNumber ? ccNumber.replace(/[*]{4,}/, "****") : ""; + } + + static formatSingleLineLabel(basicCard) { + let ccNumber = BasicCardOption.formatCCNumber(basicCard["cc-number"]); + + // XXX Bug 1473772 - Hard-coded string + let ccExp = basicCard["cc-exp"] ? "Exp. " + basicCard["cc-exp"] : ""; + let ccName = basicCard["cc-name"]; + // XXX: Bug 1491040, displaying cc-type in this context may need its own localized string + let ccType = basicCard["cc-type"] || ""; + // Filter out empty/undefined tokens before joining by three spaces + // ( in the middle of two normal spaces to avoid them visually collapsing in HTML) + return [ + ccType.replace(/^[a-z]/, $0 => $0.toUpperCase()), + ccNumber, + ccExp, + ccName, + // XXX Bug 1473772 - Hard-coded string: + ] + .filter(str => !!str) + .join(" \xa0 "); + } + + get requiredFields() { + return BasicCardOption.recordAttributes; + } + + render() { + this["_cc-name"].textContent = this.ccName || ""; + this["_cc-number"].textContent = BasicCardOption.formatCCNumber( + this.ccNumber + ); + // XXX Bug 1473772 - Hard-coded string: + this["_cc-exp"].textContent = this.ccExp ? "Exp. " + this.ccExp : ""; + // XXX: Bug 1491040, displaying cc-type in this context may need its own localized string + this["_cc-type"].alt = this.ccType || ""; + this["_cc-type"].src = + "chrome://formautofill/content/icon-credit-card-generic.svg"; + } +} + +customElements.define("basic-card-option", BasicCardOption); diff --git a/browser/components/payments/res/components/card-icon.svg b/browser/components/payments/res/components/card-icon.svg new file mode 100644 index 0000000000..1ea36d7fae --- /dev/null +++ b/browser/components/payments/res/components/card-icon.svg @@ -0,0 +1,9 @@ +<!-- 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" viewBox="0 0 48 32"> + <rect x="0" y="0" width="48" height="32" rx="4" ry="4" fill="#000" fill-opacity="context-fill-opacity"> + </rect> + <rect x="0" y="6" width="48" height="20" fill="#fff" fill-opacity="1"> + </rect> +</svg> diff --git a/browser/components/payments/res/components/csc-input.js b/browser/components/payments/res/components/csc-input.js new file mode 100644 index 0000000000..4497f8a789 --- /dev/null +++ b/browser/components/payments/res/components/csc-input.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 ObservedPropertiesMixin from "../mixins/ObservedPropertiesMixin.js"; + +/** + * <csc-input placeholder="CVV*" + default-value="123" + front-tooltip="Look on front of card for CSC" + back-tooltip="Look on back of card for CSC"></csc-input> + */ + +export default class CscInput extends ObservedPropertiesMixin(HTMLElement) { + static get observedAttributes() { + return [ + "back-tooltip", + "card-type", + "default-value", + "disabled", + "front-tooltip", + "placeholder", + "value", + ]; + } + constructor({ useAlwaysVisiblePlaceholder, inputId } = {}) { + super(); + + this.useAlwaysVisiblePlaceholder = useAlwaysVisiblePlaceholder; + + this._input = document.createElement("input"); + this._input.id = inputId || ""; + this._input.setAttribute("type", "text"); + this._input.autocomplete = "off"; + this._input.size = 3; + this._input.required = true; + // 3 or more digits + this._input.pattern = "[0-9]{3,}"; + this._input.classList.add("security-code"); + if (useAlwaysVisiblePlaceholder) { + this._label = document.createElement("span"); + this._label.dataset.localization = "cardCVV"; + this._label.className = "label-text"; + } + this._tooltip = document.createElement("span"); + this._tooltip.className = "info-tooltip csc"; + this._tooltip.setAttribute("tabindex", "0"); + this._tooltip.setAttribute("role", "tooltip"); + + // The parent connectedCallback calls its render method before + // our connectedCallback can run. This causes issues for parent + // code that is looking for all the form elements. Thus, we + // append the children during the constructor to make sure they + // be part of the DOM sooner. + this.appendChild(this._input); + if (this.useAlwaysVisiblePlaceholder) { + this.appendChild(this._label); + } + this.appendChild(this._tooltip); + } + + connectedCallback() { + this.render(); + } + + render() { + if (this.defaultValue) { + let oldDefaultValue = this._input.defaultValue; + this._input.defaultValue = this.defaultValue; + if (this._input.defaultValue != oldDefaultValue) { + // Setting defaultValue will place a value in the field + // but doesn't trigger a 'change' event, which is needed + // to update the Pay button state on the summary page. + this._input.dispatchEvent(new Event("change", { bubbles: true })); + } + } else { + this._input.defaultValue = ""; + } + if (this.value) { + // Setting the value will trigger form validation + // so only set the value if one has been provided. + this._input.value = this.value; + } + if (this.useAlwaysVisiblePlaceholder) { + this._label.textContent = this.placeholder || ""; + } else { + this._input.placeholder = this.placeholder || ""; + } + if (this.cardType == "amex") { + this._tooltip.setAttribute("aria-label", this.frontTooltip || ""); + } else { + this._tooltip.setAttribute("aria-label", this.backTooltip || ""); + } + } + + get value() { + return this._input.value; + } + + get isValid() { + return this._input.validity.valid; + } + + set disabled(value) { + // This is kept out of render() since callers + // are expecting it to apply immediately. + this._input.disabled = value; + return !!value; + } +} + +customElements.define("csc-input", CscInput); diff --git a/browser/components/payments/res/components/currency-amount.js b/browser/components/payments/res/components/currency-amount.js new file mode 100644 index 0000000000..5c8d07cb2d --- /dev/null +++ b/browser/components/payments/res/components/currency-amount.js @@ -0,0 +1,63 @@ +/* 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 value="7.5" currency="USD" display-code></currency-amount> + */ + +import ObservedPropertiesMixin from "../mixins/ObservedPropertiesMixin.js"; + +export default class CurrencyAmount extends ObservedPropertiesMixin( + HTMLElement +) { + static get observedAttributes() { + return ["currency", "display-code", "value"]; + } + + constructor() { + super(); + this._currencyAmountTextNode = document.createTextNode(""); + this._currencyCodeElement = document.createElement("span"); + this._currencyCodeElement.classList.add("currency-code"); + } + + render() { + this.append(this._currencyAmountTextNode, this._currencyCodeElement); + let currencyAmount = ""; + let currencyCode = ""; + try { + if (this.value && this.currency) { + let number = Number.parseFloat(this.value); + if (Number.isNaN(number) || !Number.isFinite(number)) { + throw new RangeError("currency-amount value must be a finite number"); + } + const symbolFormatter = new Intl.NumberFormat(navigator.languages, { + style: "currency", + currency: this.currency, + currencyDisplay: "symbol", + }); + currencyAmount = symbolFormatter.format(this.value); + + if (this.displayCode !== null) { + // XXX: Bug 1473772 will move the separator to a Fluent string. + currencyAmount += " "; + + const codeFormatter = new Intl.NumberFormat(navigator.languages, { + style: "currency", + currency: this.currency, + currencyDisplay: "code", + }); + let parts = codeFormatter.formatToParts(this.value); + let currencyPart = parts.find(part => part.type == "currency"); + currencyCode = currencyPart.value; + } + } + } finally { + this._currencyAmountTextNode.textContent = currencyAmount; + this._currencyCodeElement.textContent = currencyCode; + } + } +} + +customElements.define("currency-amount", CurrencyAmount); diff --git a/browser/components/payments/res/components/labelled-checkbox.js b/browser/components/payments/res/components/labelled-checkbox.js new file mode 100644 index 0000000000..f26d982f4f --- /dev/null +++ b/browser/components/payments/res/components/labelled-checkbox.js @@ -0,0 +1,59 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import ObservedPropertiesMixin from "../mixins/ObservedPropertiesMixin.js"; + +/** + * <labelled-checkbox label="Some label" value="The value"></labelled-checkbox> + */ + +export default class LabelledCheckbox extends ObservedPropertiesMixin( + HTMLElement +) { + static get observedAttributes() { + return ["infoTooltip", "form", "label", "value"]; + } + constructor() { + super(); + + this._label = document.createElement("label"); + this._labelSpan = document.createElement("span"); + this._infoTooltip = document.createElement("span"); + this._infoTooltip.className = "info-tooltip"; + this._infoTooltip.setAttribute("tabindex", "0"); + this._infoTooltip.setAttribute("role", "tooltip"); + this._checkbox = document.createElement("input"); + this._checkbox.type = "checkbox"; + } + + connectedCallback() { + this.appendChild(this._label); + this._label.appendChild(this._checkbox); + this._label.appendChild(this._labelSpan); + this._label.appendChild(this._infoTooltip); + this.render(); + } + + render() { + this._labelSpan.textContent = this.label; + this._infoTooltip.setAttribute("aria-label", this.infoTooltip); + // We don't use the ObservedPropertiesMixin behaviour because we want to be able to mirror + // form="" but ObservedPropertiesMixin removes attributes when "". + if (this.hasAttribute("form")) { + this._checkbox.setAttribute("form", this.getAttribute("form")); + } else { + this._checkbox.removeAttribute("form"); + } + } + + get checked() { + return this._checkbox.checked; + } + + set checked(value) { + return (this._checkbox.checked = value); + } +} + +customElements.define("labelled-checkbox", LabelledCheckbox); diff --git a/browser/components/payments/res/components/payment-details-item.css b/browser/components/payments/res/components/payment-details-item.css new file mode 100644 index 0000000000..3332698014 --- /dev/null +++ b/browser/components/payments/res/components/payment-details-item.css @@ -0,0 +1,12 @@ +/* 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/. */ + +payment-details-item { + margin: 1px 0; + min-height: 2em; +} + +payment-details-item > currency-amount { + text-align: end; +} diff --git a/browser/components/payments/res/components/payment-details-item.js b/browser/components/payments/res/components/payment-details-item.js new file mode 100644 index 0000000000..c2fe31ccfd --- /dev/null +++ b/browser/components/payments/res/components/payment-details-item.js @@ -0,0 +1,47 @@ +/* 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/. */ + +/** + * <ul> + * <payment-details-item + label="Some item" + amount-value="1.00" + amount-currency="USD"></payment-details-item> + * </ul> + */ + +import CurrencyAmount from "./currency-amount.js"; +import ObservedPropertiesMixin from "../mixins/ObservedPropertiesMixin.js"; + +export default class PaymentDetailsItem extends ObservedPropertiesMixin( + HTMLElement +) { + static get observedAttributes() { + return ["label", "amount-currency", "amount-value"]; + } + + constructor() { + super(); + this._label = document.createElement("span"); + this._label.classList.add("label"); + this._currencyAmount = new CurrencyAmount(); + } + + connectedCallback() { + this.appendChild(this._label); + this.appendChild(this._currencyAmount); + + if (super.connectedCallback) { + super.connectedCallback(); + } + } + + render() { + this._currencyAmount.value = this.amountValue; + this._currencyAmount.currency = this.amountCurrency; + this._label.textContent = this.label; + } +} + +customElements.define("payment-details-item", PaymentDetailsItem); diff --git a/browser/components/payments/res/components/payment-request-page.js b/browser/components/payments/res/components/payment-request-page.js new file mode 100644 index 0000000000..76b41f178e --- /dev/null +++ b/browser/components/payments/res/components/payment-request-page.js @@ -0,0 +1,36 @@ +/* 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/. */ + +/** + * <payment-request-page></payment-request-page> + */ + +export default class PaymentRequestPage extends HTMLElement { + constructor() { + super(); + + this.classList.add("page"); + + this.pageTitleHeading = document.createElement("h2"); + + // The body and footer may be pre-defined in the template so re-use them if they exist. + this.body = + this.querySelector(":scope > .page-body") || + document.createElement("div"); + this.body.classList.add("page-body"); + + this.footer = + this.querySelector(":scope > footer") || document.createElement("footer"); + } + + connectedCallback() { + // The heading goes inside the body so it scrolls. + this.body.prepend(this.pageTitleHeading); + this.appendChild(this.body); + + this.appendChild(this.footer); + } +} + +customElements.define("payment-request-page", PaymentRequestPage); diff --git a/browser/components/payments/res/components/rich-option.js b/browser/components/payments/res/components/rich-option.js new file mode 100644 index 0000000000..d82697de20 --- /dev/null +++ b/browser/components/payments/res/components/rich-option.js @@ -0,0 +1,26 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * <rich-select> + * <rich-option></rich-option> + * </rich-select> + */ + +import ObservedPropertiesMixin from "../mixins/ObservedPropertiesMixin.js"; + +export default class RichOption extends ObservedPropertiesMixin(HTMLElement) { + static get observedAttributes() { + return ["selected", "value"]; + } + + connectedCallback() { + this.classList.add("rich-option"); + this.render(); + } + + render() {} +} + +customElements.define("rich-option", RichOption); diff --git a/browser/components/payments/res/components/rich-select.css b/browser/components/payments/res/components/rich-select.css new file mode 100644 index 0000000000..8ec2fead3d --- /dev/null +++ b/browser/components/payments/res/components/rich-select.css @@ -0,0 +1,58 @@ +/* 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-select { + /* Include the padding in the max-width calculation so that we truncate rather + than grow wider than 100% of the parent. */ + box-sizing: border-box; + display: block; + /* Has to be the same as `payment-method-picker > input`: */ + margin: 10px 0; + /* Padding for the dropmarker (copied from common.css) */ + padding-inline-end: 24px; + position: relative; + /* Don't allow the <rich-select> to grow wider than the container so that we + truncate with text-overflow for long options instead. */ + max-width: 100%; +} + +/* Focusing on the underlying select element outlines the outer + rich-select wrapper making it appear like rich-select is focused. */ +rich-select:focus-within > select { + outline: 1px dotted var(--in-content-text-color); +} + +/* + * The HTML select element is hidden and placed on the rich-option + * element to make it look like clicking on the rich-option element + * in the closed state opens the HTML select dropdown. */ +rich-select > select { + /* Hide the text from the closed state so that the text/layout from + <rich-option> won't overlap it. The !important matches common.css. */ + color: transparent !important; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + margin: 0; +} + +rich-select > select > option { + /* Reset the text color in the popup/open state */ + color: var(--in-content-text-color); +} + +.rich-option { + display: grid; + padding: 8px; +} + +.rich-select-selected-option { + /* Clicks on the selected rich option should go to the <select> below to open the popup */ + pointer-events: none; + /* Use position:relative so this is positioned on top of the <select> which + also has position:relative. */ + position: relative; +} diff --git a/browser/components/payments/res/components/rich-select.js b/browser/components/payments/res/components/rich-select.js new file mode 100644 index 0000000000..0226b32a79 --- /dev/null +++ b/browser/components/payments/res/components/rich-select.js @@ -0,0 +1,104 @@ +/* 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 ObservedPropertiesMixin from "../mixins/ObservedPropertiesMixin.js"; +import RichOption from "./rich-option.js"; + +/** + * <rich-select> + * <rich-option></rich-option> + * </rich-select> + * + * Note: The only supported way to change the selected option is via the + * `value` setter. + */ +export default class RichSelect extends HandleEventMixin( + ObservedPropertiesMixin(HTMLElement) +) { + static get observedAttributes() { + return ["disabled", "hidden"]; + } + + constructor() { + super(); + this.popupBox = document.createElement("select"); + } + + connectedCallback() { + // the popupBox element may change in between constructor and being connected + // so wait until connected before listening to events on it + this.popupBox.addEventListener("change", this); + this.appendChild(this.popupBox); + this.render(); + } + + get selectedOption() { + return this.getOptionByValue(this.value); + } + + get selectedRichOption() { + // XXX: Bug 1475684 - This can be removed once `selectedOption` returns a + // RichOption which extends HTMLOptionElement. + return this.querySelector(":scope > .rich-select-selected-option"); + } + + get value() { + return this.popupBox.value; + } + + set value(guid) { + this.popupBox.value = guid; + this.render(); + } + + getOptionByValue(value) { + return this.popupBox.querySelector( + `:scope > [value="${CSS.escape(value)}"]` + ); + } + + onChange(event) { + // Since the render function depends on the popupBox's value, we need to + // re-render if the value changes. + this.render(); + } + + render() { + let selectedRichOption = this.querySelector( + ":scope > .rich-select-selected-option" + ); + if (selectedRichOption) { + selectedRichOption.remove(); + } + + if (this.value) { + let optionType = this.getAttribute("option-type"); + if (!selectedRichOption || selectedRichOption.localName != optionType) { + selectedRichOption = document.createElement(optionType); + } + + let option = this.getOptionByValue(this.value); + let attributeNames = selectedRichOption.constructor.observedAttributes; + for (let attributeName of attributeNames) { + let attributeValue = option.getAttribute(attributeName); + if (attributeValue) { + selectedRichOption.setAttribute(attributeName, attributeValue); + } else { + selectedRichOption.removeAttribute(attributeName); + } + } + } else { + selectedRichOption = new RichOption(); + selectedRichOption.textContent = "(None selected)"; // XXX: bug 1473772 + } + selectedRichOption.classList.add("rich-select-selected-option"); + // Hide the rich-option from a11y tools since the native <select> will + // already provide the selected option label. + selectedRichOption.setAttribute("aria-hidden", "true"); + selectedRichOption = this.appendChild(selectedRichOption); + } +} + +customElements.define("rich-select", RichSelect); diff --git a/browser/components/payments/res/components/shipping-option.css b/browser/components/payments/res/components/shipping-option.css new file mode 100644 index 0000000000..6c84c5eb84 --- /dev/null +++ b/browser/components/payments/res/components/shipping-option.css @@ -0,0 +1,16 @@ +/* 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/. */ + +shipping-option.rich-option { + display: block; + /* Below properties are to support truncating with an ellipsis for long options */ + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +shipping-option > .label, +shipping-option > .amount { + white-space: nowrap; +} diff --git a/browser/components/payments/res/components/shipping-option.js b/browser/components/payments/res/components/shipping-option.js new file mode 100644 index 0000000000..1832546463 --- /dev/null +++ b/browser/components/payments/res/components/shipping-option.js @@ -0,0 +1,65 @@ +/* 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 CurrencyAmount from "./currency-amount.js"; +import ObservedPropertiesMixin from "../mixins/ObservedPropertiesMixin.js"; +import RichOption from "./rich-option.js"; + +/** + * <rich-select> + * <shipping-option></shipping-option> + * </rich-select> + */ + +export default class ShippingOption extends ObservedPropertiesMixin( + RichOption +) { + static get recordAttributes() { + return ["label", "amount-currency", "amount-value"]; + } + + static get observedAttributes() { + return RichOption.observedAttributes.concat( + ShippingOption.recordAttributes + ); + } + + constructor() { + super(); + + this.amount = null; + this._currencyAmount = new CurrencyAmount(); + this._currencyAmount.classList.add("amount"); + this._label = document.createElement("span"); + this._label.classList.add("label"); + } + + connectedCallback() { + this.appendChild(this._currencyAmount); + this.append(" "); + this.appendChild(this._label); + super.connectedCallback(); + } + + static formatSingleLineLabel(option) { + let amount = new CurrencyAmount(); + amount.value = option.amount.value; + amount.currency = option.amount.currency; + amount.render(); + + return amount.textContent + " " + option.label; + } + + render() { + this._label.textContent = this.label; + this._currencyAmount.currency = this.amountCurrency; + this._currencyAmount.value = this.amountValue; + // Need to call render after setting these properties + // if we want the amount to get displayed in the same + // render pass as the label. + this._currencyAmount.render(); + } +} + +customElements.define("shipping-option", ShippingOption); 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>
diff --git a/browser/components/payments/res/debugging.css b/browser/components/payments/res/debugging.css new file mode 100644 index 0000000000..6ce0dcbcf9 --- /dev/null +++ b/browser/components/payments/res/debugging.css @@ -0,0 +1,35 @@ +/* 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/. */ + +html { + color: -moz-DialogText; + font: message-box; + /* Make sure the background ends to the bottom if there is unused space */ + height: 100%; +} + +h1 { + font-size: 1em; +} + +fieldset > label { + white-space: nowrap; +} + +.group { + margin: 0.5em 0; +} + +label.block { + display: block; + margin: 0.3em 0; +} + +button.wide { + width: 100%; +} + +#complete-status { + column-count: 2; +} diff --git a/browser/components/payments/res/debugging.html b/browser/components/payments/res/debugging.html new file mode 100644 index 0000000000..9b5b80c9e6 --- /dev/null +++ b/browser/components/payments/res/debugging.html @@ -0,0 +1,75 @@ +<!DOCTYPE html> +<!-- 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/. --> +<html> + <head> + <meta charset="utf-8"> + <meta http-equiv="Content-Security-Policy" content="default-src 'self'"> + <link rel="stylesheet" href="debugging.css"/> + <script src="debugging.js"></script> + </head> + <body> + <div> + <section class="group"> + <button id="refresh">Refresh</button> + <button id="rerender">Re-render</button> + <button id="logState">Log state</button> + <button id="debugFrame" hidden>Debug frame</button> + <button id="toggleDirectionality">Toggle :dir</button> + <button id="toggleBranding">Toggle branding</button> + </section> + <section class="group"> + <h1>Requests</h1> + <button id="setRequest1">Request 1</button> + <button id="setRequest2">Request 2</button> + <fieldset id="paymentOptions"> + <legend>Payment Options</legend> + <label><input type="checkbox" autocomplete="off" name="requestPayerName" id="setRequestPayerName">requestPayerName</label> + <label><input type="checkbox" autocomplete="off" name="requestPayerEmail" id="setRequestPayerEmail">requestPayerEmail</label> + <label><input type="checkbox" autocomplete="off" name="requestPayerPhone" id="setRequestPayerPhone">requestPayerPhone</label> + <label><input type="checkbox" autocomplete="off" name="requestShipping" id="setRequestShipping">requestShipping</label> + </fieldset> + </section> + + <section class="group"> + <h1>Addresses</h1> + <button id="setAddresses1">Set Addreses 1</button> + <button id="setDupesAddresses">Set Duped Addresses</button> + <button id="delete1Address">Delete 1 Address</button> + </section> + + <section class="group"> + <h1>Payment Methods</h1> + <button id="setBasicCards1">Set Basic Cards 1</button> + <button id="delete1Card">Delete 1 Card</button> + </section> + + <section class="group"> + <h1>States</h1> + <fieldset id="complete-status"> + <legend>Complete Status</legend> + <label class="block"><input type="radio" name="setCompleteStatus" value="">(default)</label> + <label class="block"><input type="radio" name="setCompleteStatus" value="processing">Processing</label> + <label class="block"><input type="radio" name="setCompleteStatus" value="fail">Fail</label> + <label class="block"><input type="radio" name="setCompleteStatus" value="unknown">Unknown</label> + <label class="block"><input type="radio" name="setCompleteStatus" value="timeout">Timeout</label> + </fieldset> + <label class="block"><input type="checkbox" id="setChangesPrevented">Prevent changes</label> + + + <section class="group"> + <fieldset> + <legend>User Data Errors</legend> + <button id="saveVisibleForm" title="Bypasses field validation">Save Visible Form</button> + <button id="setBasicCardErrors">Basic Card Errors</button> + <button id="setPayerErrors">Payer Errors</button> + <button id="setShippingError">Shipping Error</button> + <button id="setShippingAddressErrors">Shipping Address Errors</button> + + </fieldset> + </section> + </section> + </div> + </body> +</html> diff --git a/browser/components/payments/res/debugging.js b/browser/components/payments/res/debugging.js new file mode 100644 index 0000000000..fffc85dd11 --- /dev/null +++ b/browser/components/payments/res/debugging.js @@ -0,0 +1,664 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const paymentDialog = window.parent.document.querySelector("payment-dialog"); +// The requestStore should be manipulated for most changes but autofill storage changes +// happen through setStateFromParent which includes some consistency checks. +const requestStore = paymentDialog.requestStore; + +// keep the payment options checkboxes in sync w. actual state +const paymentOptionsUpdater = { + stateChangeCallback(state) { + this.render(state); + }, + render(state) { + let { completeStatus, paymentOptions } = state.request; + + document.getElementById("setChangesPrevented").checked = + state.changesPrevented; + + let paymentOptionInputs = document.querySelectorAll( + "#paymentOptions input[type='checkbox']" + ); + for (let input of paymentOptionInputs) { + if (paymentOptions.hasOwnProperty(input.name)) { + input.checked = paymentOptions[input.name]; + } + } + + let completeStatusInputs = document.querySelectorAll( + "input[type='radio'][name='setCompleteStatus']" + ); + for (let input of completeStatusInputs) { + input.checked = input.value == completeStatus; + } + }, +}; + +let REQUEST_1 = { + tabId: 9, + topLevelPrincipal: { URI: { displayHost: "debugging.example.com" } }, + requestId: "3797081f-a96b-c34b-a58b-1083c6e66e25", + completeStatus: "", + paymentMethods: [], + paymentDetails: { + id: "", + totalItem: { + label: "Demo total", + amount: { currency: "EUR", value: "1.00" }, + pending: false, + }, + displayItems: [ + { + label: "Square", + amount: { + currency: "USD", + value: "5", + }, + }, + ], + payerErrors: {}, + paymentMethodErrors: {}, + shippingAddressErrors: {}, + shippingOptions: [ + { + id: "std", + label: "Standard (3-5 business days)", + amount: { + currency: "USD", + value: 10, + }, + selected: false, + }, + { + id: "super-slow", + // Long to test truncation + label: "Ssssssssuuuuuuuuupppppeeeeeeerrrrr sssssllllllloooooowwwwww", + amount: { + currency: "USD", + value: 1.5, + }, + selected: true, + }, + ], + modifiers: null, + error: "", + }, + paymentOptions: { + requestPayerName: true, + requestPayerEmail: false, + requestPayerPhone: false, + requestShipping: true, + shippingType: "shipping", + }, + shippingOption: "std", +}; + +let REQUEST_2 = { + tabId: 9, + topLevelPrincipal: { URI: { displayHost: "example.com" } }, + requestId: "3797081f-a96b-c34b-a58b-1083c6e66e25", + completeStatus: "", + paymentMethods: [ + { + supportedMethods: "basic-card", + data: { + supportedNetworks: ["amex", "discover", "mastercard", "visa"], + }, + }, + ], + paymentDetails: { + id: "", + totalItem: { + label: "", + amount: { currency: "CAD", value: "25.75" }, + pending: false, + }, + displayItems: [ + { + label: "Triangle", + amount: { + currency: "CAD", + value: "3", + }, + }, + { + label: "Circle", + amount: { + currency: "EUR", + value: "10.50", + }, + }, + { + label: "Tax", + type: "tax", + amount: { + currency: "USD", + value: "1.50", + }, + }, + ], + payerErrors: {}, + paymentMethoErrors: {}, + shippingAddressErrors: {}, + shippingOptions: [ + { + id: "123", + label: "Fast (default)", + amount: { + currency: "USD", + value: 10, + }, + selected: true, + }, + { + id: "947", + label: "Slow", + amount: { + currency: "USD", + value: 1, + }, + selected: false, + }, + ], + modifiers: [ + { + supportedMethods: "basic-card", + total: { + label: "Total", + amount: { + currency: "CAD", + value: "28.75", + }, + pending: false, + }, + additionalDisplayItems: [ + { + label: "Credit card fee", + amount: { + currency: "CAD", + value: "1.50", + }, + }, + ], + data: {}, + }, + ], + error: "", + }, + paymentOptions: { + requestPayerName: false, + requestPayerEmail: false, + requestPayerPhone: false, + requestShipping: true, + shippingType: "shipping", + }, + shippingOption: "123", +}; + +let ADDRESSES_1 = { + "48bnds6854t": { + "address-level1": "MI", + "address-level2": "Some City", + country: "US", + email: "foo@bar.com", + "family-name": "Smith", + "given-name": "John", + guid: "48bnds6854t", + name: "John Smith", + "postal-code": "90210", + "street-address": "123 Sesame Street,\nApt 40", + tel: "+1 519 555-5555", + timeLastUsed: 50000, + }, + "68gjdh354j": { + "additional-name": "Z.", + "address-level1": "CA", + "address-level2": "Mountain View", + country: "US", + "family-name": "Doe", + "given-name": "Jane", + guid: "68gjdh354j", + name: "Jane Z. Doe", + "postal-code": "94041", + "street-address": "P.O. Box 123", + tel: "+1 650 555-5555", + timeLastUsed: 30000, + }, + abcde12345: { + "address-level2": "Mountain View", + country: "US", + "family-name": "Fields", + "given-name": "Mrs.", + guid: "abcde12345", + name: "Mrs. Fields", + timeLastUsed: 70000, + }, + german1: { + "additional-name": "Y.", + "address-level1": "", + "address-level2": "Berlin", + country: "DE", + email: "de@example.com", + "family-name": "Mouse", + "given-name": "Anon", + guid: "german1", + name: "Anon Y. Mouse", + organization: "Mozilla", + "postal-code": "10997", + "street-address": "Schlesische Str. 27", + tel: "+49 30 983333002", + timeLastUsed: 10000, + }, + "missing-country": { + "address-level1": "ON", + "address-level2": "Toronto", + "family-name": "Bogard", + "given-name": "Kristin", + guid: "missing-country", + name: "Kristin Bogard", + "postal-code": "H0H 0H0", + "street-address": "123 Yonge Street\nSuite 2300", + tel: "+1 416 555-5555", + timeLastUsed: 90000, + }, + TimBR: { + "given-name": "Timothy", + "additional-name": "João", + "family-name": "Berners-Lee", + organization: "World Wide Web Consortium", + "street-address": "Rua Adalberto Pajuaba, 404", + "address-level3": "Campos Elísios", + "address-level2": "Ribeirão Preto", + "address-level1": "SP", + "postal-code": "14055-220", + country: "BR", + tel: "+0318522222222", + email: "timbr@example.org", + timeLastUsed: 110000, + }, +}; + +let DUPED_ADDRESSES = { + a9e830667189: { + "street-address": "Unit 1\n1505 Northeast Kentucky Industrial Parkway \n", + "address-level2": "Greenup", + "address-level1": "KY", + "postal-code": "41144", + country: "US", + email: "bob@example.com", + "family-name": "Smith", + "given-name": "Bob", + guid: "a9e830667189", + tel: "+19871234567", + name: "Bob Smith", + timeLastUsed: 10001, + }, + "72a15aed206d": { + "street-address": "1 New St", + "address-level2": "York", + "address-level1": "SC", + "postal-code": "29745", + country: "US", + "given-name": "Mary Sue", + guid: "72a15aed206d", + tel: "+19871234567", + name: "Mary Sue", + "address-line1": "1 New St", + timeLastUsed: 10009, + }, + "2b4dce0fbc1f": { + "street-address": "123 Park St", + "address-level2": "Springfield", + "address-level1": "OR", + "postal-code": "97403", + country: "US", + email: "rita@foo.com", + "family-name": "Foo", + "given-name": "Rita", + guid: "2b4dce0fbc1f", + name: "Rita Foo", + "address-line1": "123 Park St", + timeLastUsed: 10005, + }, + "46b2635a5b26": { + "street-address": "432 Another St", + "address-level2": "Springfield", + "address-level1": "OR", + "postal-code": "97402", + country: "US", + email: "rita@foo.com", + "family-name": "Foo", + "given-name": "Rita", + guid: "46b2635a5b26", + name: "Rita Foo", + "address-line1": "432 Another St", + timeLastUsed: 10003, + }, +}; + +let BASIC_CARDS_1 = { + "53f9d009aed2": { + billingAddressGUID: "68gjdh354j", + methodName: "basic-card", + "cc-number": "************5461", + guid: "53f9d009aed2", + version: 3, + timeCreated: 1505240896213, + timeLastModified: 1515609524588, + timeLastUsed: 10000, + timesUsed: 0, + "cc-name": "John Smith", + "cc-exp-month": 6, + "cc-exp-year": 2024, + "cc-type": "visa", + "cc-given-name": "John", + "cc-additional-name": "", + "cc-family-name": "Smith", + "cc-exp": "2024-06", + }, + "9h5d4h6f4d1s": { + methodName: "basic-card", + "cc-number": "************0954", + guid: "9h5d4h6f4d1s", + version: 3, + timeCreated: 1517890536491, + timeLastModified: 1517890564518, + timeLastUsed: 50000, + timesUsed: 0, + "cc-name": "Jane Doe", + "cc-exp-month": 5, + "cc-exp-year": 2023, + "cc-type": "mastercard", + "cc-given-name": "Jane", + "cc-additional-name": "", + "cc-family-name": "Doe", + "cc-exp": "2023-05", + }, + "123456789abc": { + methodName: "basic-card", + "cc-number": "************1234", + guid: "123456789abc", + version: 3, + timeCreated: 1517890536491, + timeLastModified: 1517890564518, + timeLastUsed: 90000, + timesUsed: 0, + "cc-name": "Jane Fields", + "cc-given-name": "Jane", + "cc-additional-name": "", + "cc-family-name": "Fields", + "cc-type": "discover", + }, + "amex-card": { + methodName: "basic-card", + billingAddressGUID: "68gjdh354j", + "cc-number": "************1941", + guid: "amex-card", + version: 1, + timeCreated: 1517890536491, + timeLastModified: 1517890564518, + timeLastUsed: 70000, + timesUsed: 0, + "cc-name": "Capt America", + "cc-given-name": "Capt", + "cc-additional-name": "", + "cc-family-name": "America", + "cc-type": "amex", + "cc-exp-month": 6, + "cc-exp-year": 2023, + "cc-exp": "2023-06", + }, + "missing-cc-name": { + methodName: "basic-card", + "cc-number": "************8563", + guid: "missing-cc-name", + version: 3, + timeCreated: 1517890536491, + timeLastModified: 1517890564518, + timeLastUsed: 30000, + timesUsed: 0, + "cc-exp-month": 8, + "cc-exp-year": 2024, + "cc-exp": "2024-08", + }, +}; + +let buttonActions = { + debugFrame() { + let event = new CustomEvent("paymentContentToChrome", { + bubbles: true, + detail: { + messageType: "debugFrame", + }, + }); + document.dispatchEvent(event); + }, + + delete1Address() { + let savedAddresses = Object.assign( + {}, + requestStore.getState().savedAddresses + ); + delete savedAddresses[Object.keys(savedAddresses)[0]]; + // Use setStateFromParent since it ensures there is no dangling + // `selectedShippingAddress` foreign key (FK) reference. + paymentDialog.setStateFromParent({ + savedAddresses, + }); + }, + + delete1Card() { + let savedBasicCards = Object.assign( + {}, + requestStore.getState().savedBasicCards + ); + delete savedBasicCards[Object.keys(savedBasicCards)[0]]; + // Use setStateFromParent since it ensures there is no dangling + // `selectedPaymentCard` foreign key (FK) reference. + paymentDialog.setStateFromParent({ + savedBasicCards, + }); + }, + + logState() { + let state = requestStore.getState(); + // eslint-disable-next-line no-console + console.log(state); + dump(`${JSON.stringify(state, null, 2)}\n`); + }, + + refresh() { + window.parent.location.reload(true); + }, + + rerender() { + requestStore.setState({}); + }, + + saveVisibleForm() { + // Bypasses field validation which is useful to test error handling. + paymentDialog + .querySelector("#main-container > .page:not([hidden])") + .saveRecord(); + }, + + setAddresses1() { + paymentDialog.setStateFromParent({ savedAddresses: ADDRESSES_1 }); + }, + + setDupesAddresses() { + paymentDialog.setStateFromParent({ savedAddresses: DUPED_ADDRESSES }); + }, + + setBasicCards1() { + paymentDialog.setStateFromParent({ savedBasicCards: BASIC_CARDS_1 }); + }, + + setBasicCardErrors() { + let request = Object.assign({}, requestStore.getState().request); + request.paymentDetails = Object.assign( + {}, + requestStore.getState().request.paymentDetails + ); + request.paymentDetails.paymentMethodErrors = { + cardNumber: "", + cardholderName: "", + cardSecurityCode: "", + expiryMonth: "", + expiryYear: "", + billingAddress: { + addressLine: + "Can only buy from ROADS, not DRIVES, BOULEVARDS, or STREETS", + city: "Can only buy from CITIES, not TOWNSHIPS or VILLAGES", + country: "Can only buy from US, not CA", + dependentLocality: "Can only be SUBURBS, not NEIGHBORHOODS", + organization: "Can only buy from CORPORATIONS, not CONSORTIUMS", + phone: "Only allowed to buy from area codes that start with 9", + postalCode: "Only allowed to buy from postalCodes that start with 0", + recipient: "Can only buy from names that start with J", + region: "Can only buy from regions that start with M", + regionCode: "Regions must be 1 to 3 characters in length", + }, + }; + requestStore.setState({ + request, + }); + }, + + setChangesPrevented(evt) { + requestStore.setState({ + changesPrevented: evt.target.checked, + }); + }, + + setCompleteStatus() { + let input = document.querySelector("[name='setCompleteStatus']:checked"); + let completeStatus = input.value; + let request = requestStore.getState().request; + paymentDialog.setStateFromParent({ + request: Object.assign({}, request, { completeStatus }), + }); + }, + + setPayerErrors() { + let request = Object.assign({}, requestStore.getState().request); + request.paymentDetails = Object.assign( + {}, + requestStore.getState().request.paymentDetails + ); + request.paymentDetails.payerErrors = { + email: "Only @mozilla.com emails are supported", + name: "Payer name must start with M", + phone: "Payer area codes must start with 1", + }; + requestStore.setState({ + request, + }); + }, + + setPaymentOptions() { + let options = {}; + let checkboxes = document.querySelectorAll( + "#paymentOptions input[type='checkbox']" + ); + for (let input of checkboxes) { + options[input.name] = input.checked; + } + let req = Object.assign({}, requestStore.getState().request, { + paymentOptions: options, + }); + requestStore.setState({ request: req }); + }, + + setRequest1() { + paymentDialog.setStateFromParent({ request: REQUEST_1 }); + }, + + setRequest2() { + paymentDialog.setStateFromParent({ request: REQUEST_2 }); + }, + + setRequestPayerName() { + buttonActions.setPaymentOptions(); + }, + setRequestPayerEmail() { + buttonActions.setPaymentOptions(); + }, + setRequestPayerPhone() { + buttonActions.setPaymentOptions(); + }, + setRequestShipping() { + buttonActions.setPaymentOptions(); + }, + + setShippingError() { + let request = Object.assign({}, requestStore.getState().request); + request.paymentDetails = Object.assign( + {}, + requestStore.getState().request.paymentDetails + ); + request.paymentDetails.error = "Shipping Error!"; + request.paymentDetails.shippingOptions = []; + requestStore.setState({ + request, + }); + }, + + setShippingAddressErrors() { + let request = Object.assign({}, requestStore.getState().request); + request.paymentDetails = Object.assign( + {}, + requestStore.getState().request.paymentDetails + ); + request.paymentDetails.shippingAddressErrors = { + addressLine: "Can only ship to ROADS, not DRIVES, BOULEVARDS, or STREETS", + city: "Can only ship to CITIES, not TOWNSHIPS or VILLAGES", + country: "Can only ship to USA, not CA", + dependentLocality: "Can only be SUBURBS, not NEIGHBORHOODS", + organization: "Can only ship to CORPORATIONS, not CONSORTIUMS", + phone: "Only allowed to ship to area codes that start with 9", + postalCode: "Only allowed to ship to postalCodes that start with 0", + recipient: "Can only ship to names that start with J", + region: "Can only ship to regions that start with M", + regionCode: "Regions must be 1 to 3 characters in length", + }; + requestStore.setState({ + request, + }); + }, + + toggleDirectionality() { + let body = paymentDialog.ownerDocument.body; + body.dir = body.dir == "rtl" ? "ltr" : "rtl"; + }, + + toggleBranding() { + for (let container of paymentDialog.querySelectorAll("accepted-cards")) { + container.classList.toggle("branded"); + } + }, +}; + +window.addEventListener("click", function onButtonClick(evt) { + let id = evt.target.id || evt.target.name; + if (!id || typeof buttonActions[id] != "function") { + return; + } + + buttonActions[id](evt); +}); + +window.addEventListener("DOMContentLoaded", function onDCL() { + if (window.location.protocol == "resource:") { + // Only show the debug frame button if we're running from a resource URI + // so it doesn't show during development over file: or http: since it won't work. + // Note that the button still won't work if resource://payments/paymentRequest.xhtml + // is manually loaded in a tab but will be shown. + document.getElementById("debugFrame").hidden = false; + } + + requestStore.subscribe(paymentOptionsUpdater); + paymentOptionsUpdater.render(requestStore.getState()); +}); diff --git a/browser/components/payments/res/mixins/HandleEventMixin.js b/browser/components/payments/res/mixins/HandleEventMixin.js new file mode 100644 index 0000000000..8d09ac2207 --- /dev/null +++ b/browser/components/payments/res/mixins/HandleEventMixin.js @@ -0,0 +1,28 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * A mixin to forward events to on* methods if defined. + * + * @param {string} superclass The class to extend. + * @returns {class} + */ +export default function HandleEventMixin(superclass) { + return class HandleEvent extends superclass { + handleEvent(evt) { + function capitalize(str) { + return str.charAt(0).toUpperCase() + str.slice(1); + } + if (super.handleEvent) { + super.handleEvent(evt); + } + // Check whether event name is a defined function in object. + let fn = "on" + capitalize(evt.type); + if (this[fn] && typeof this[fn] === "function") { + return this[fn](evt); + } + return null; + } + }; +} diff --git a/browser/components/payments/res/mixins/ObservedPropertiesMixin.js b/browser/components/payments/res/mixins/ObservedPropertiesMixin.js new file mode 100644 index 0000000000..5fe71af90c --- /dev/null +++ b/browser/components/payments/res/mixins/ObservedPropertiesMixin.js @@ -0,0 +1,71 @@ +/* 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/. */ + +/** + * Define getters and setters for observedAttributes converted to camelCase and + * trigger a batched aynchronous call to `render` upon observed + * attribute/property changes. + */ + +export default function ObservedPropertiesMixin(superClass) { + return class ObservedProperties extends superClass { + static kebabToCamelCase(name) { + return name.replace(/-([a-z])/g, ($0, $1) => $1.toUpperCase()); + } + + constructor() { + super(); + + this._observedPropertiesMixin = { + pendingRender: false, + }; + + // Reflect property changes for `observedAttributes` to attributes. + for (let name of this.constructor.observedAttributes || []) { + if (name in this) { + // Don't overwrite existing properties. + continue; + } + // Convert attribute names from kebab-case to camelCase properties + Object.defineProperty(this, ObservedProperties.kebabToCamelCase(name), { + configurable: true, + get() { + return this.getAttribute(name); + }, + set(value) { + if (value === null || value === undefined || value === false) { + this.removeAttribute(name); + } else { + this.setAttribute(name, value); + } + }, + }); + } + } + + async _invalidateFromObservedPropertiesMixin() { + if (this._observedPropertiesMixin.pendingRender) { + return; + } + + this._observedPropertiesMixin.pendingRender = true; + await Promise.resolve(); + try { + this.render(); + } finally { + this._observedPropertiesMixin.pendingRender = false; + } + } + + attributeChangedCallback(attr, oldValue, newValue) { + if (super.attributeChangedCallback) { + super.attributeChangedCallback(attr, oldValue, newValue); + } + if (oldValue === newValue) { + return; + } + this._invalidateFromObservedPropertiesMixin(); + } + }; +} diff --git a/browser/components/payments/res/mixins/PaymentStateSubscriberMixin.js b/browser/components/payments/res/mixins/PaymentStateSubscriberMixin.js new file mode 100644 index 0000000000..ede9e1bfc5 --- /dev/null +++ b/browser/components/payments/res/mixins/PaymentStateSubscriberMixin.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 PaymentsStore from "../PaymentsStore.js"; + +/** + * A mixin for a custom element to observe store changes to information about a payment request. + */ + +/** + * State of the payment request dialog. + */ +export let requestStore = new PaymentsStore({ + changesPrevented: false, + orderDetailsShowing: false, + "basic-card-page": { + guid: null, + // preserveFieldValues: true, + selectedStateKey: "selectedPaymentCard", + }, + "shipping-address-page": { + guid: null, + }, + "payer-address-page": { + guid: null, + }, + "billing-address-page": { + guid: null, + }, + "payment-summary": {}, + page: { + id: "payment-summary", + previousId: null, + // onboardingWizard: true, + // error: "", + }, + request: { + completeStatus: "", + tabId: null, + topLevelPrincipal: { URI: { displayHost: null } }, + requestId: null, + paymentMethods: [], + paymentDetails: { + id: null, + totalItem: { label: null, amount: { currency: null, value: 0 } }, + displayItems: [], + payerErrors: {}, + paymentMethodErrors: null, + shippingAddressErrors: {}, + shippingOptions: [], + modifiers: null, + error: "", + }, + paymentOptions: { + requestPayerName: false, + requestPayerEmail: false, + requestPayerPhone: false, + requestShipping: false, + shippingType: "shipping", + }, + shippingOption: null, + }, + selectedPayerAddress: null, + selectedPaymentCard: null, + selectedPaymentCardSecurityCode: null, + selectedShippingAddress: null, + selectedShippingOption: null, + savedAddresses: {}, + savedBasicCards: {}, + tempAddresses: {}, + tempBasicCards: {}, +}); + +/** + * A mixin to render UI based upon the requestStore and get updated when that store changes. + * + * Attaches `requestStore` to the element to give access to the store. + * @param {class} superClass The class to extend + * @returns {class} + */ +export default function PaymentStateSubscriberMixin(superClass) { + return class PaymentStateSubscriber extends superClass { + constructor() { + super(); + this.requestStore = requestStore; + } + + connectedCallback() { + this.requestStore.subscribe(this); + this.render(this.requestStore.getState()); + if (super.connectedCallback) { + super.connectedCallback(); + } + } + + disconnectedCallback() { + this.requestStore.unsubscribe(this); + if (super.disconnectedCallback) { + super.disconnectedCallback(); + } + } + + /** + * Called by the store upon state changes. + * @param {object} state The current state + */ + stateChangeCallback(state) { + this.render(state); + } + }; +} diff --git a/browser/components/payments/res/paymentRequest.css b/browser/components/payments/res/paymentRequest.css new file mode 100644 index 0000000000..ef03c745ae --- /dev/null +++ b/browser/components/payments/res/paymentRequest.css @@ -0,0 +1,265 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +:root { + height: 100%; +} + +body { + height: 100%; + margin: 0; + /* Override font-size from in-content/common.css which is too large */ + font-size: inherit; +} + +[hidden] { + display: none !important; +} + +#debugging-console { + /* include the default borders in the max-height */ + box-sizing: border-box; + float: right; + height: 100vh; + /* Float above the other overlays */ + position: relative; + z-index: 99; +} + +payment-dialog { + box-sizing: border-box; + display: grid; + grid-template: "header" auto + "main" 1fr + "disabled-overlay" auto; + height: 100%; +} + +payment-dialog > header, +.page > .page-body, +.page > footer { + padding: 0 10%; +} + +payment-dialog > header { + border-bottom: 1px solid rgba(0,0,0,0.1); + display: flex; + /* Wrap so that the error text appears full-width above the rest of the contents */ + flex-wrap: wrap; + /* from visual spec: */ + padding-bottom: 19px; + padding-top: 19px; +} + +payment-dialog > header > .page-error:empty { + display: none; +} + +payment-dialog > header > .page-error { + background: #D70022; + border-radius: 3px; + color: white; + padding: 6px; + width: 100%; +} + +#main-container { + display: flex; + grid-area: main; + position: relative; + max-height: 100%; +} + +.page { + display: flex; + flex-direction: column; + height: 100%; + position: relative; + width: 100%; +} + +.page > .page-body { + display: flex; + flex-direction: column; + flex-grow: 1; + /* The area above the footer should scroll, if necessary. */ + overflow: auto; + padding-top: 18px; +} + +.page > .page-body > h2:empty { + display: none; +} + +.page-error { + color: #D70022; +} + +.manage-text { + margin: 0; + padding: 18px 0; +} + +.page > footer { + align-items: center; + justify-content: end; + background-color: #eaeaee; + display: flex; + /* from visual spec: */ + padding-top: 20px; + padding-bottom: 18px; +} + +#order-details-overlay { + background-color: var(--in-content-page-background); + overflow: auto; + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + z-index: 1; +} + +#total { + flex: 1 1 auto; + margin: 5px; +} + +#total > currency-amount { + color: var(--in-content-link-color); + font-size: 1.5em; +} + +#total > currency-amount > .currency-code { + color: GrayText; + font-size: 1rem; +} + +#total > div { + color: GrayText; +} + +#view-all { + flex: 0 1 auto; +} + +payment-dialog[complete-status="processing"] #pay { + /* Force opacity to 1 even when disabled in the processing state. */ + opacity: 1; +} + +payment-dialog #pay::before { + -moz-context-properties: fill; + content: url(chrome://browser/skin/connection-secure.svg); + fill: currentColor; + height: 16px; + margin-inline-end: 0.5em; + vertical-align: text-bottom; + width: 16px; +} + +payment-dialog[changes-prevented][complete-status="fail"] #pay, +payment-dialog[changes-prevented][complete-status="unknown"] #pay, +payment-dialog[changes-prevented][complete-status="processing"] #pay, +payment-dialog[changes-prevented][complete-status="success"] #pay { + /* Show the pay button above #disabled-overlay */ + position: relative; + z-index: 51; +} + +#disabled-overlay { + background: white; + grid-area: disabled-overlay; + opacity: 0.6; + width: 100%; + height: 100%; + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + /* z-index must be greater than some positioned fields and #pay with z-index + but less than 99, the z-index of the debugging console. */ + z-index: 50; +} + +.persist-checkbox { + padding: 5px 0; +} + +.persist-checkbox > label { + display: flex; + align-items: center; +} + +.info-tooltip { + display: inline-block; + background-image: url(chrome://global/skin/icons/help.svg); + width: 16px; + height: 16px; + padding: 2px 4px; + background-repeat: no-repeat; + background-position: center; + position: relative; +} + +.info-tooltip:focus::after, +.info-tooltip:hover::after { + content: attr(aria-label); + display: block; + position: absolute; + padding: 3px 5px; + background-color: #fff; + border: 1px solid #bebebf; + box-shadow: 1px 1px 3px #bebebf; + font-size: smaller; + line-height: normal; + width: 188px; + /* Center the tooltip over the (i) icon (188px / 2 - 5px (padding) - 1px (border)). */ + left: -86px; + bottom: 20px; +} + +.info-tooltip:dir(rtl):focus::after, +.info-tooltip:dir(rtl):hover::after { + left: auto; + right: -86px; +} + +.csc.info-tooltip:focus::after, +.csc.info-tooltip:hover::after { + /* Right-align the tooltip over the (i) icon (-188px - 60px (padding) - 2px (border) + 4px ((i) start padding) + 16px ((i) icon width)). */ + left: -226px; + background-position: top 5px left 5px; + background-image: url(./containers/cvv-hint-image-back.svg); + background-repeat: no-repeat; + padding-inline-start: 55px; +} + +.csc.info-tooltip[cc-type="amex"]::after { + background-image: url(./containers/cvv-hint-image-front.svg); +} + +.csc.info-tooltip:dir(rtl):focus::after, +.csc.info-tooltip:dir(rtl):hover::after { + left: auto; + /* Left-align the tooltip over the (i) icon (-188px - 60px (padding) - 2px (border) + 4px ((i) start padding) + 16px ((i) icon width)). */ + right: -226px; + background-position: top 5px right 5px; +} + +.branding { + background-image: url(chrome://branding/content/icon32.png); + background-size: 16px; + background-repeat: no-repeat; + background-position: left center; + padding-inline-start: 20px; + line-height: 20px; + margin-inline-end: auto; +} + +.branding:dir(rtl) { + background-position: right center; +} diff --git a/browser/components/payments/res/paymentRequest.js b/browser/components/payments/res/paymentRequest.js new file mode 100644 index 0000000000..1a08184615 --- /dev/null +++ b/browser/components/payments/res/paymentRequest.js @@ -0,0 +1,356 @@ +/* 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/. */ + +/** + * Loaded in the unprivileged frame of each payment dialog. + * + * Communicates with privileged code via DOM Events. + */ + +/* import-globals-from unprivileged-fallbacks.js */ + +var paymentRequest = { + _nextMessageID: 1, + domReadyPromise: null, + + init() { + // listen to content + window.addEventListener("paymentChromeToContent", this); + + window.addEventListener("keydown", this); + + this.domReadyPromise = new Promise(function dcl(resolve) { + window.addEventListener("DOMContentLoaded", resolve, { once: true }); + }).then(this.handleEvent.bind(this)); + + // This scope is now ready to listen to the initialization data + this.sendMessageToChrome("initializeRequest"); + }, + + handleEvent(event) { + switch (event.type) { + case "DOMContentLoaded": { + this.onPaymentRequestLoad(); + break; + } + case "keydown": { + if (event.code != "KeyD" || !event.altKey || !event.ctrlKey) { + break; + } + this.toggleDebuggingConsole(); + break; + } + case "unload": { + this.onPaymentRequestUnload(); + break; + } + case "paymentChromeToContent": { + this.onChromeToContent(event); + break; + } + default: { + throw new Error("Unexpected event type"); + } + } + }, + + /** + * @param {string} messageType + * @param {[object]} detail + * @returns {number} message ID to be able to identify a reply (where applicable). + */ + sendMessageToChrome(messageType, detail = {}) { + let messageID = this._nextMessageID++; + log.debug("sendMessageToChrome:", messageType, messageID, detail); + let event = new CustomEvent("paymentContentToChrome", { + bubbles: true, + detail: Object.assign( + { + messageType, + messageID, + }, + detail + ), + }); + document.dispatchEvent(event); + return messageID; + }, + + toggleDebuggingConsole() { + let debuggingConsole = document.getElementById("debugging-console"); + if (debuggingConsole.hidden && !debuggingConsole.src) { + debuggingConsole.src = "debugging.html"; + } + debuggingConsole.hidden = !debuggingConsole.hidden; + }, + + onChromeToContent({ detail }) { + let { messageType } = detail; + log.debug("onChromeToContent:", messageType); + + switch (messageType) { + case "responseSent": { + let { request } = document + .querySelector("payment-dialog") + .requestStore.getState(); + document.querySelector("payment-dialog").requestStore.setState({ + changesPrevented: true, + request: Object.assign({}, request, { completeStatus: "processing" }), + }); + break; + } + case "showPaymentRequest": { + this.onShowPaymentRequest(detail); + break; + } + case "updateState": { + document.querySelector("payment-dialog").setStateFromParent(detail); + break; + } + } + }, + + onPaymentRequestLoad() { + log.debug("onPaymentRequestLoad"); + window.addEventListener("unload", this, { once: true }); + + // Automatically show the debugging console if loaded with a truthy `debug` query parameter. + if (new URLSearchParams(location.search).get("debug")) { + this.toggleDebuggingConsole(); + } + }, + + async onShowPaymentRequest(detail) { + // Handle getting called before the DOM is ready. + log.debug("onShowPaymentRequest:", detail); + await this.domReadyPromise; + + log.debug("onShowPaymentRequest: domReadyPromise resolved"); + log.debug("onShowPaymentRequest, isPrivate?", detail.isPrivate); + + let paymentDialog = document.querySelector("payment-dialog"); + let state = { + request: detail.request, + savedAddresses: detail.savedAddresses, + savedBasicCards: detail.savedBasicCards, + // Temp records can exist upon a reload during development. + tempAddresses: detail.tempAddresses, + tempBasicCards: detail.tempBasicCards, + isPrivate: detail.isPrivate, + page: { + id: "payment-summary", + }, + }; + + let hasSavedAddresses = !!Object.keys(this.getAddresses(state)).length; + let hasSavedCards = !!Object.keys(this.getBasicCards(state)).length; + let shippingRequested = state.request.paymentOptions.requestShipping; + + // Onboarding wizard flow. + if (!hasSavedAddresses && shippingRequested) { + state.page = { + id: "shipping-address-page", + onboardingWizard: true, + }; + + state["shipping-address-page"] = { + guid: null, + }; + } else if (!hasSavedAddresses && !hasSavedCards) { + state.page = { + id: "billing-address-page", + onboardingWizard: true, + }; + + state["billing-address-page"] = { + guid: null, + }; + } else if (!hasSavedCards) { + state.page = { + id: "basic-card-page", + onboardingWizard: true, + }; + state["basic-card-page"] = { + selectedStateKey: "selectedPaymentCard", + }; + } + + await paymentDialog.setStateFromParent(state); + + this.sendMessageToChrome("paymentDialogReady"); + }, + + openPreferences() { + this.sendMessageToChrome("openPreferences"); + }, + + cancel() { + this.sendMessageToChrome("paymentCancel"); + }, + + pay(data) { + this.sendMessageToChrome("pay", data); + }, + + closeDialog() { + this.sendMessageToChrome("closeDialog"); + }, + + changePaymentMethod(data) { + this.sendMessageToChrome("changePaymentMethod", data); + }, + + changeShippingAddress(data) { + this.sendMessageToChrome("changeShippingAddress", data); + }, + + changeShippingOption(data) { + this.sendMessageToChrome("changeShippingOption", data); + }, + + changePayerAddress(data) { + this.sendMessageToChrome("changePayerAddress", data); + }, + + /** + * Add/update an autofill storage record. + * + * If the the `guid` argument is provided update the record; otherwise, add it. + * @param {string} collectionName The autofill collection that record belongs to. + * @param {object} record The autofill record to add/update + * @param {string} [guid] The guid of the autofill record to update + * @returns {Promise} when the update response is received + */ + updateAutofillRecord(collectionName, record, guid) { + return new Promise((resolve, reject) => { + let messageID = this.sendMessageToChrome("updateAutofillRecord", { + collectionName, + guid, + record, + }); + + window.addEventListener("paymentChromeToContent", function onMsg({ + detail, + }) { + if ( + detail.messageType != "updateAutofillRecord:Response" || + detail.messageID != messageID + ) { + return; + } + log.debug("updateAutofillRecord: response:", detail); + window.removeEventListener("paymentChromeToContent", onMsg); + document + .querySelector("payment-dialog") + .setStateFromParent(detail.stateChange); + if (detail.error) { + reject(detail); + } else { + resolve(detail); + } + }); + }); + }, + + /** + * @param {object} state object representing the UI state + * @param {string} selectedMethodID (GUID) uniquely identifying the selected payment method + * @returns {object?} the applicable modifier for the payment method + */ + getModifierForPaymentMethod(state, selectedMethodID) { + let basicCards = this.getBasicCards(state); + let selectedMethod = basicCards[selectedMethodID] || null; + if (selectedMethod && selectedMethod.methodName !== "basic-card") { + throw new Error( + `${selectedMethod.methodName} (${selectedMethodID}) ` + + `is not a supported payment method` + ); + } + let modifiers = state.request.paymentDetails.modifiers; + if (!selectedMethod || !modifiers || !modifiers.length) { + return null; + } + let appliedModifier = modifiers.find(modifier => { + // take the first matching modifier + if ( + modifier.supportedMethods && + modifier.supportedMethods != selectedMethod.methodName + ) { + return false; + } + let supportedNetworks = + (modifier.data && modifier.data.supportedNetworks) || []; + return ( + !supportedNetworks.length || + supportedNetworks.includes(selectedMethod["cc-type"]) + ); + }); + return appliedModifier || null; + }, + + /** + * @param {object} state object representing the UI state + * @returns {object} in the shape of `nsIPaymentItem` representing the total + * that applies to the selected payment method. + */ + getTotalItem(state) { + let methodID = state.selectedPaymentCard; + if (methodID) { + let modifier = paymentRequest.getModifierForPaymentMethod( + state, + methodID + ); + if (modifier && modifier.hasOwnProperty("total")) { + return modifier.total; + } + } + return state.request.paymentDetails.totalItem; + }, + + onPaymentRequestUnload() { + // remove listeners that may be used multiple times here + window.removeEventListener("paymentChromeToContent", this); + }, + + _sortObjectsByTimeLastUsed(objects) { + let sortedValues = Object.values(objects).sort((a, b) => { + let aLastUsed = a.timeLastUsed || a.timeLastModified; + let bLastUsed = b.timeLastUsed || b.timeLastModified; + return bLastUsed - aLastUsed; + }); + let sortedObjects = {}; + for (let obj of sortedValues) { + sortedObjects[obj.guid] = obj; + } + return sortedObjects; + }, + + getAddresses(state) { + let addresses = Object.assign( + {}, + state.savedAddresses, + state.tempAddresses + ); + return this._sortObjectsByTimeLastUsed(addresses); + }, + + getBasicCards(state) { + let cards = Object.assign({}, state.savedBasicCards, state.tempBasicCards); + return this._sortObjectsByTimeLastUsed(cards); + }, + + maybeCreateFieldErrorElement(container) { + let span = container.querySelector(".error-text"); + if (!span) { + span = document.createElement("span"); + span.className = "error-text"; + container.appendChild(span); + } + return span; + }, +}; + +paymentRequest.init(); + +export default paymentRequest; diff --git a/browser/components/payments/res/paymentRequest.xhtml b/browser/components/payments/res/paymentRequest.xhtml new file mode 100644 index 0000000000..e88b529333 --- /dev/null +++ b/browser/components/payments/res/paymentRequest.xhtml @@ -0,0 +1,303 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this + - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> +<!DOCTYPE html [ + <!ENTITY % globalDTD SYSTEM "chrome://global/locale/global.dtd"> + %globalDTD; + <!ENTITY % brandDTD SYSTEM "chrome://branding/locale/brand.dtd" > + %brandDTD; + + <!ENTITY viewAllItems "View All Items"> + <!ENTITY paymentSummaryTitle "Your Payment"> + <!ENTITY header.payTo "Pay to"> + <!ENTITY fieldRequiredSymbol "*"> + + <!ENTITY shippingAddressLabel "Shipping Address"> + <!ENTITY deliveryAddressLabel "Delivery Address"> + <!ENTITY pickupAddressLabel "Pickup Address"> + <!ENTITY shippingOptionsLabel "Shipping Options"> + <!ENTITY deliveryOptionsLabel "Delivery Options"> + <!ENTITY pickupOptionsLabel "Pickup Options"> + <!ENTITY shippingGenericError "Can’t ship to this address. Select a different address."> + <!ENTITY deliveryGenericError "Can’t deliver to this address. Select a different address."> + <!ENTITY pickupGenericError "Can’t pick up from this address. Select a different address."> + <!ENTITY paymentMethodsLabel "Payment Method"> + <!ENTITY address.fieldSeparator ", "> + <!ENTITY address.addLink.label "Add"> + <!ENTITY address.editLink.label "Edit"> + <!ENTITY basicCard.addLink.label "Add"> + <!ENTITY basicCard.editLink.label "Edit"> + <!ENTITY payer.addLink.label "Add"> + <!ENTITY payer.editLink.label "Edit"> + <!ENTITY shippingAddress.addPage.title "Add Shipping Address"> + <!ENTITY shippingAddress.editPage.title "Edit Shipping Address"> + <!ENTITY deliveryAddress.addPage.title "Add Delivery Address"> + <!ENTITY deliveryAddress.editPage.title "Edit Delivery Address"> + <!ENTITY pickupAddress.addPage.title "Add Pickup Address"> + <!ENTITY pickupAddress.editPage.title "Edit Pickup Address"> + <!ENTITY billingAddress.addPage.title "Add Billing Address"> + <!ENTITY billingAddress.editPage.title "Edit Billing Address"> + <!ENTITY basicCard.addPage.title "Add Credit Card"> + <!ENTITY basicCard.editPage.title "Edit Credit Card"> + <!ENTITY basicCard.csc.placeholder "CVV"> + <!ENTITY basicCard.csc.back.infoTooltip "3 digit number found on the back of your credit card."> + <!ENTITY basicCard.csc.front.infoTooltip "3 digit number found on the front of your credit card."> + <!ENTITY payer.addPage.title "Add Payer Contact"> + <!ENTITY payer.editPage.title "Edit Payer Contact"> + <!ENTITY payerLabel "Contact Information"> + <!ENTITY manageInPreferences "Manage saved address and credit card information in <a>&brandShortName; Preferences</a>."> + <!ENTITY manageInOptions "Manage saved address and credit card information in <a>&brandShortName; Options</a>."> + <!ENTITY cancelPaymentButton.label "Cancel"> + <!ENTITY approvePaymentButton.label "Pay"> + <!ENTITY processingPaymentButton.label "Processing"> + <!ENTITY successPaymentButton.label "Done"> + <!ENTITY unknownPaymentButton.label "Unknown"> + <!ENTITY orderDetailsLabel "Order Details"> + <!ENTITY orderTotalLabel "Total"> + <!ENTITY basicCardPage.error.genericSave "There was an error saving the payment card."> + <!ENTITY basicCardPage.addressAddLink.label "Add"> + <!ENTITY basicCardPage.addressEditLink.label "Edit"> + <!ENTITY basicCardPage.backButton.label "Back"> + <!ENTITY basicCardPage.nextButton.label "Next"> + <!ENTITY basicCardPage.updateButton.label "Update"> + <!ENTITY basicCardPage.persistCheckbox.label "Save credit card to &brandShortName; (CVV will not be saved)"> + <!ENTITY basicCardPage.persistCheckbox.infoTooltip "&brandShortName; can securely store your credit card information to use in forms like this, so you don’t have to enter it every time."> + <!ENTITY addressPage.error.genericSave "There was an error saving the address."> + <!ENTITY addressPage.cancelButton.label "Cancel"> + <!ENTITY addressPage.backButton.label "Back"> + <!ENTITY addressPage.nextButton.label "Next"> + <!ENTITY addressPage.updateButton.label "Update"> + <!ENTITY addressPage.persistCheckbox.label "Save address to &brandShortName;"> + <!ENTITY addressPage.persistCheckbox.infoTooltip "&brandShortName; can add your address to forms like this, so you don’t have to type it every time."> + <!ENTITY failErrorPage.title "We couldn’t complete your payment to **host-name**"> + <!ENTITY failErrorPage.suggestionHeading "The most likely cause is a hiccup with your credit card."> + <!ENTITY failErrorPage.suggestion1 "Make sure the card you’re using hasn’t expired"> + <!ENTITY failErrorPage.suggestion2 "Double check the card number and expiration date"> + <!ENTITY failErrorPage.suggestion3 "If your credit card information is correct, contact the merchant for more information"> + <!ENTITY failErrorPage.doneButton.label "Close"> + <!ENTITY timeoutErrorPage.title "**host-name** is taking too long to respond."> + <!ENTITY timeoutErrorPage.suggestionHeading "The most likely cause is a temporary connection hiccup. Open a new tab to check your network connection or click “Close” to try again."> + <!ENTITY timeoutErrorPage.doneButton.label "Close"> + <!ENTITY webPaymentsBranding.label "&brandShortName; Checkout"> + <!ENTITY invalidOption.label "Missing or invalid information"> + <!ENTITY acceptedCards.label "Merchant accepts:"> +]> +<html xmlns="http://www.w3.org/1999/xhtml"> +<head> + <title>&paymentSummaryTitle;</title> + + <!-- chrome: is needed for global.dtd --> + <meta http-equiv="Content-Security-Policy" content="default-src 'self' chrome:"/> + + <link rel="stylesheet" href="chrome://global/skin/in-content/common.css"/> + <link rel="stylesheet" href="chrome://formautofill/content/skin/editDialog-shared.css"/> + <link rel="stylesheet" href="chrome://formautofill/content/skin/editAddress.css"/> + <link rel="stylesheet" href="chrome://formautofill/content/skin/editCreditCard.css"/> + <link rel="stylesheet" href="chrome://formautofill/content/skin/editDialog.css"/> + <link rel="stylesheet" href="paymentRequest.css"/> + <link rel="stylesheet" href="components/rich-select.css"/> + <link rel="stylesheet" href="components/address-option.css"/> + <link rel="stylesheet" href="components/basic-card-option.css"/> + <link rel="stylesheet" href="components/shipping-option.css"/> + <link rel="stylesheet" href="components/payment-details-item.css"/> + <link rel="stylesheet" href="components/accepted-cards.css"/> + <link rel="stylesheet" href="containers/address-form.css"/> + <link rel="stylesheet" href="containers/basic-card-form.css"/> + <link rel="stylesheet" href="containers/order-details.css"/> + <link rel="stylesheet" href="containers/rich-picker.css"/> + <link rel="stylesheet" href="containers/error-page.css"/> + + <script src="unprivileged-fallbacks.js"></script> + + <script src="formautofill/autofillEditForms.js"></script> + + <script type="module" src="containers/payment-dialog.js"></script> + <script type="module" src="paymentRequest.js"></script> + + <template id="payment-dialog-template"> + <header> + <div class="page-error" + data-shipping-generic-error="&shippingGenericError;" + data-delivery-generic-error="&deliveryGenericError;" + data-pickup-generic-error="&pickupGenericError;" + aria-live="polite"></div> + <div id="total"> + <currency-amount display-code="display-code"></currency-amount> + <div>&header.payTo; <span id="host-name"></span></div> + </div> + <div id="top-buttons" hidden="hidden"> + <button id="view-all" class="closed">&viewAllItems;</button> + </div> + </header> + + <div id="main-container"> + <payment-request-page id="payment-summary"> + <div class="page-body"> + <address-picker class="shipping-related" + data-add-link-label="&address.addLink.label;" + data-edit-link-label="&address.editLink.label;" + data-field-separator="&address.fieldSeparator;" + data-shipping-address-label="&shippingAddressLabel;" + data-delivery-address-label="&deliveryAddressLabel;" + data-pickup-address-label="&pickupAddressLabel;" + data-invalid-label="&invalidOption.label;" + selected-state-key="selectedShippingAddress"></address-picker> + + <shipping-option-picker class="shipping-related" + data-shipping-options-label="&shippingOptionsLabel;" + data-delivery-options-label="&deliveryOptionsLabel;" + data-pickup-options-label="&pickupOptionsLabel;"></shipping-option-picker> + + <payment-method-picker selected-state-key="selectedPaymentCard" + data-add-link-label="&basicCard.addLink.label;" + data-edit-link-label="&basicCard.editLink.label;" + data-csc-placeholder="&basicCard.csc.placeholder;" + data-csc-back-tooltip="&basicCard.csc.back.infoTooltip;" + data-csc-front-tooltip="&basicCard.csc.front.infoTooltip;" + data-invalid-label="&invalidOption.label;" + label="&paymentMethodsLabel;"> + </payment-method-picker> + <accepted-cards hidden="hidden" label="&acceptedCards.label;"></accepted-cards> + <address-picker class="payer-related" + label="&payerLabel;" + data-add-link-label="&payer.addLink.label;" + data-edit-link-label="&payer.editLink.label;" + data-field-separator="&address.fieldSeparator;" + data-invalid-label="&invalidOption.label;" + selected-state-key="selectedPayerAddress"></address-picker> + + <p class="manage-text"> + <span hidden="hidden" data-os="mac">&manageInPreferences;</span> + <span hidden="hidden">&manageInOptions;</span> + </p> + </div> + + <footer> + <span class="branding">&webPaymentsBranding.label;</span> + <button id="cancel">&cancelPaymentButton.label;</button> + <button id="pay" + class="primary" + data-label="&approvePaymentButton.label;" + data-processing-label="&processingPaymentButton.label;" + data-unknown-label="&unknownPaymentButton.label;" + data-success-label="&successPaymentButton.label;"></button> + </footer> + </payment-request-page> + <section id="order-details-overlay" hidden="hidden"> + <h2>&orderDetailsLabel;</h2> + <order-details></order-details> + </section> + + <basic-card-form id="basic-card-page" + data-add-basic-card-title="&basicCard.addPage.title;" + data-edit-basic-card-title="&basicCard.editPage.title;" + data-error-generic-save="&basicCardPage.error.genericSave;" + + data-address-add-link-label="&basicCardPage.addressAddLink.label;" + data-address-edit-link-label="&basicCardPage.addressEditLink.label;" + + data-invalid-address-label="&invalidOption.label;" + data-address-field-separator="&address.fieldSeparator;" + data-back-button-label="&basicCardPage.backButton.label;" + data-next-button-label="&basicCardPage.nextButton.label;" + data-update-button-label="&basicCardPage.updateButton.label;" + data-cancel-button-label="&cancelPaymentButton.label;" + data-persist-checkbox-label="&basicCardPage.persistCheckbox.label;" + data-persist-checkbox-info-tooltip="&basicCardPage.persistCheckbox.infoTooltip;" + data-csc-placeholder="&basicCard.csc.placeholder;" + data-csc-back-info-tooltip="&basicCard.csc.back.infoTooltip;" + data-csc-front-info-tooltip="&basicCard.csc.front.infoTooltip;" + data-accepted-cards-label="&acceptedCards.label;" + data-field-required-symbol="&fieldRequiredSymbol;" + hidden="hidden"></basic-card-form> + + <address-form id="shipping-address-page" + data-title-add="&shippingAddress.addPage.title;" + data-title-edit="&shippingAddress.editPage.title;" + data-error-generic-save="&addressPage.error.genericSave;" + data-cancel-button-label="&addressPage.cancelButton.label;" + data-back-button-label="&addressPage.backButton.label;" + data-next-button-label="&addressPage.nextButton.label;" + data-update-button-label="&addressPage.updateButton.label;" + data-persist-checkbox-label="&addressPage.persistCheckbox.label;" + data-persist-checkbox-info-tooltip="&addressPage.persistCheckbox.infoTooltip;" + data-field-required-symbol="&fieldRequiredSymbol;" + hidden="hidden" + selected-state-key="selectedShippingAddress"></address-form> + + <address-form id="payer-address-page" + data-title-add="&payer.addPage.title;" + data-title-edit="&payer.editPage.title;" + data-error-generic-save="&addressPage.error.genericSave;" + data-cancel-button-label="&addressPage.cancelButton.label;" + data-back-button-label="&addressPage.backButton.label;" + data-next-button-label="&addressPage.nextButton.label;" + data-update-button-label="&addressPage.updateButton.label;" + data-persist-checkbox-label="&addressPage.persistCheckbox.label;" + data-persist-checkbox-info-tooltip="&addressPage.persistCheckbox.infoTooltip;" + data-field-required-symbol="&fieldRequiredSymbol;" + hidden="hidden" + selected-state-key="selectedPayerAddress"></address-form> + + <address-form id="billing-address-page" + data-title-add="&billingAddress.addPage.title;" + data-title-edit="&billingAddress.editPage.title;" + data-error-generic-save="&addressPage.error.genericSave;" + data-cancel-button-label="&addressPage.cancelButton.label;" + data-back-button-label="&addressPage.backButton.label;" + data-next-button-label="&addressPage.nextButton.label;" + data-update-button-label="&addressPage.updateButton.label;" + data-persist-checkbox-label="&addressPage.persistCheckbox.label;" + data-persist-checkbox-info-tooltip="&addressPage.persistCheckbox.infoTooltip;" + data-field-required-symbol="&fieldRequiredSymbol;" + hidden="hidden" + selected-state-key="basic-card-page|billingAddressGUID"></address-form> + + <completion-error-page id="completion-timeout-error" class="illustrated" + data-page-title="&timeoutErrorPage.title;" + data-suggestion-heading="&timeoutErrorPage.suggestionHeading;" + data-branding-label="&webPaymentsBranding.label;" + data-done-button-label="&timeoutErrorPage.doneButton.label;" + hidden="hidden"></completion-error-page> + <completion-error-page id="completion-fail-error" class="illustrated" + data-page-title="&failErrorPage.title;" + data-suggestion-heading="&failErrorPage.suggestionHeading;" + data-suggestion-1="&failErrorPage.suggestion1;" + data-suggestion-2="&failErrorPage.suggestion2;" + data-suggestion-3="&failErrorPage.suggestion3;" + data-branding-label="&webPaymentsBranding.label;" + data-done-button-label="&failErrorPage.doneButton.label;" + hidden="hidden"></completion-error-page> + </div> + + <div id="disabled-overlay" hidden="hidden"> + <!-- overlay to prevent changes while waiting for a response from the merchant --> + </div> + </template> + + <template id="order-details-template"> + <ul class="main-list"></ul> + <ul class="footer-items-list"></ul> + + <div class="details-total"> + <h2 class="label">&orderTotalLabel;</h2> + <currency-amount></currency-amount> + </div> + </template> +</head> +<body dir="&locale.dir;"> + <iframe id="debugging-console" + hidden="hidden"> + </iframe> + <payment-dialog data-shipping-address-title-add="&shippingAddress.addPage.title;" + data-shipping-address-title-edit="&shippingAddress.editPage.title;" + data-delivery-address-title-add="&deliveryAddress.addPage.title;" + data-delivery-address-title-edit="&deliveryAddress.editPage.title;" + data-pickup-address-title-add="&pickupAddress.addPage.title;" + data-pickup-address-title-edit="&pickupAddress.editPage.title;" + data-billing-address-title-add="&billingAddress.addPage.title;" + data-payer-title-add="&payer.addPage.title;" + data-payer-title-edit="&payer.editPage.title;"></payment-dialog> +</body> +</html> diff --git a/browser/components/payments/res/unprivileged-fallbacks.js b/browser/components/payments/res/unprivileged-fallbacks.js new file mode 100644 index 0000000000..ee7e47a7df --- /dev/null +++ b/browser/components/payments/res/unprivileged-fallbacks.js @@ -0,0 +1,159 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * This file defines fallback objects to be used during development outside + * of the paymentDialogWrapper. When loaded in the wrapper, a frame script + * overwrites these methods. Since these methods need to get overwritten in the + * global scope, it can't be converted into an ES module. + */ + +/* eslint-disable no-console */ +/* exported log, PaymentDialogUtils */ + +"use strict"; + +var log = { + error: console.error.bind(console, "paymentRequest.xhtml:"), + warn: console.warn.bind(console, "paymentRequest.xhtml:"), + info: console.info.bind(console, "paymentRequest.xhtml:"), + debug: console.debug.bind(console, "paymentRequest.xhtml:"), +}; + +var PaymentDialogUtils = { + getAddressLabel(address, addressFields = null) { + if (addressFields) { + let requestedFields = addressFields.trim().split(/\s+/); + return ( + requestedFields + .filter(f => f && address[f]) + .map(f => address[f]) + .join(", ") + ` (${address.guid})` + ); + } + return `${address.name} (${address.guid})`; + }, + + getCreditCardNetworks() { + // Shim for list of known and supported credit card network ids as exposed by + // toolkit/modules/CreditCard.jsm + return [ + "amex", + "cartebancaire", + "diners", + "discover", + "jcb", + "mastercard", + "mir", + "unionpay", + "visa", + ]; + }, + isCCNumber(str) { + return !!str.replace(/[-\s]/g, "").match(/^\d{9,}$/); + }, + DEFAULT_REGION: "US", + countries: new Map([ + ["US", "United States"], + ["CA", "Canada"], + ["DE", "Germany"], + ]), + getFormFormat(country) { + if (country == "DE") { + return { + addressLevel3Label: "suburb", + addressLevel2Label: "city", + addressLevel1Label: "province", + addressLevel1Options: null, + postalCodeLabel: "postalCode", + fieldsOrder: [ + { + fieldId: "name", + newLine: true, + }, + { + fieldId: "organization", + newLine: true, + }, + { + fieldId: "street-address", + newLine: true, + }, + { fieldId: "postal-code" }, + { fieldId: "address-level2" }, + ], + postalCodePattern: "\\d{5}", + countryRequiredFields: [ + "street-address", + "address-level2", + "postal-code", + ], + }; + } + + let addressLevel1Options = null; + if (country == "US") { + addressLevel1Options = new Map([ + ["CA", "California"], + ["MA", "Massachusetts"], + ["MI", "Michigan"], + ]); + } else if (country == "CA") { + addressLevel1Options = new Map([ + ["NS", "Nova Scotia"], + ["ON", "Ontario"], + ["YT", "Yukon"], + ]); + } + + let fieldsOrder = [ + { fieldId: "name", newLine: true }, + { fieldId: "street-address", newLine: true }, + { fieldId: "address-level2" }, + { fieldId: "address-level1" }, + { fieldId: "postal-code" }, + { fieldId: "organization" }, + ]; + if (country == "BR") { + fieldsOrder.splice(2, 0, { fieldId: "address-level3" }); + } + + return { + addressLevel3Label: "suburb", + addressLevel2Label: "city", + addressLevel1Label: country == "US" ? "state" : "province", + addressLevel1Options, + postalCodeLabel: country == "US" ? "zip" : "postalCode", + fieldsOrder, + // The following values come from addressReferences.js and should not be changed. + /* eslint-disable-next-line max-len */ + postalCodePattern: + country == "US" + ? "(\\d{5})(?:[ \\-](\\d{4}))?" + : "[ABCEGHJKLMNPRSTVXY]\\d[ABCEGHJ-NPRSTV-Z] ?\\d[ABCEGHJ-NPRSTV-Z]\\d", + countryRequiredFields: + country == "US" || country == "CA" + ? [ + "street-address", + "address-level2", + "address-level1", + "postal-code", + ] + : ["street-address", "address-level2", "postal-code"], + }; + }, + findAddressSelectOption(selectEl, address, fieldName) { + return null; + }, + getDefaultPreferences() { + let prefValues = { + saveCreditCardDefaultChecked: false, + saveAddressDefaultChecked: true, + }; + return prefValues; + }, + isOfficialBranding() { + return false; + }, +}; |