diff --git a/.github/workflows/checkout-tests.yml b/.github/workflows/checkout-tests.yml new file mode 100644 index 00000000..e188e904 --- /dev/null +++ b/.github/workflows/checkout-tests.yml @@ -0,0 +1,27 @@ +name: Checkout tests + +on: + pull_request: + workflow_dispatch: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '26' + cache: 'npm' + cache-dependency-path: site/package-lock.json + + - name: Install dependencies + working-directory: site + run: npm ci + + - name: Run checkout tests + working-directory: site + run: npm test diff --git a/site/assets/js/modules/forms/phone-number.js b/site/assets/js/modules/forms/phone-number.js index b6a29325..66bc4de0 100644 --- a/site/assets/js/modules/forms/phone-number.js +++ b/site/assets/js/modules/forms/phone-number.js @@ -5,7 +5,7 @@ * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * - * http://www.apache.org/licenses/LICENSE-2.0 + * https://www.apache.org/licenses/LICENSE-2.0 * * Redistribution and use in source and/or binary forms, with or without * modification, must retain the above copyright notice and the following @@ -26,18 +26,6 @@ 'use strict'; -/** - * Removes characters that are not accepted by the phone-number field. - * - *

Allowed: digits, parentheses, hyphens, and spaces. - * - * @param {string} value phone-number value to sanitize - * @return {string} sanitized phone-number value - */ -export function sanitizePhoneNumberInput(value) { - return String(value || '').replace(/[^0-9\s()-]/g, ''); -} - /** * Builds the phone-number payload with country code and number with digits only. * @@ -46,7 +34,7 @@ export function sanitizePhoneNumberInput(value) { * @return {{countryCode: number, number: string}|null} * normalized phone-number payload, or null when incomplete */ -export function normalizePhoneNumber(rawCountryCode, rawNumber) { +function normalizePhoneNumber(rawCountryCode, rawNumber) { const countryCode = String(rawCountryCode || '').replace(/\D/g, ''); const number = String(rawNumber || '').replace(/\D/g, ''); @@ -64,3 +52,33 @@ export function normalizePhoneNumber(rawCountryCode, rawNumber) { number }; } + +/** + * Builds a Paygate phone payload from an `intl-tel-input` field. + * + * @param {string} rawNumber displayed national phone number + * @param {string} rawCountryCode selected international dial code + * @param {string} rawFullNumber full number returned by the plugin + * @return {{countryCode: number, number: string}|null} + * normalized phone-number payload, or null when incomplete + */ +export function normalizeIntlPhoneNumber(rawNumber, rawCountryCode, rawFullNumber) { + const countryCode = digits(rawCountryCode); + const fullNumber = digits(rawFullNumber); + const fallbackNumber = digits(rawNumber); + + if (!countryCode || !fallbackNumber) { + return null; + } + + const number = fullNumber.indexOf(countryCode) === 0 + ? fullNumber.slice(countryCode.length) + : fallbackNumber; + + return normalizePhoneNumber(countryCode, number); +} + +/** Returns decimal digits from a phone-number fragment. */ +function digits(value) { + return String(value || '').replace(/\D/g, ''); +} diff --git a/site/assets/js/modules/paygate/purchases.js b/site/assets/js/modules/paygate/purchases.js index 6c10dbb1..a1b73be8 100644 --- a/site/assets/js/modules/paygate/purchases.js +++ b/site/assets/js/modules/paygate/purchases.js @@ -49,7 +49,8 @@ * @property {string} orderId paygate order ID * @property {string} productTitle product display title * @property {string} productDescription product description shown on checkout - * @property {boolean} paymentCompleted whether the order was already paid + * @property {string} [paymentStatus] current known payment status + * @property {boolean} completed whether the order was already paid */ /** @@ -202,7 +203,7 @@ export function createPurchaseClient(serverUrl) { * @throws {PurchaseApiError} if response status is not OK */ async function getJson(url) { - const response = await fetch(url); + const response = await fetch(url, {cache: 'no-store'}); const body = await readResponseBody(response); if (!response.ok) { diff --git a/site/assets/js/pages/checkout/charge-controller.js b/site/assets/js/pages/checkout/charge-controller.js index cc06fa81..47944ca7 100644 --- a/site/assets/js/pages/checkout/charge-controller.js +++ b/site/assets/js/pages/checkout/charge-controller.js @@ -5,7 +5,7 @@ * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * - * http://www.apache.org/licenses/LICENSE-2.0 + * https://www.apache.org/licenses/LICENSE-2.0 * * Redistribution and use in source and/or binary forms, with or without * modification, must retain the above copyright notice and the following @@ -33,6 +33,7 @@ */ import {createDelayedRequestController} from 'js/pages/checkout/delayed-request-controller'; +import {buildChargeRequest} from 'js/pages/checkout/charge-request'; import {fieldValidationState} from 'js/pages/checkout/form-controller'; /** @@ -128,9 +129,9 @@ export function createChargeController( * @param {boolean} state.isRequesting whether a charge request is currently in flight */ function updateVatIdState({hasCurrentResult, isRequesting}) { - const hasRequestKey = Boolean(getRequestKey()); + const hasVatId = Boolean(getVatId()); - if (!hasRequestKey) { + if (!hasVatId) { onFieldValidationStateChange(fieldValidationState.idle); return; } @@ -155,15 +156,12 @@ export function createChargeController( const buyerCountryCode = getBuyerCountryCode(); const vatId = getVatId(); - if (!orderId || !buyerCountryCode || !vatId) { + const payload = buildChargeRequest(orderId, buyerCountryCode, vatId); + if (!payload) { return null; } - return purchaseClient.calculateCharges({ - orderId, - buyerCountryCode, - vatId - }); + return purchaseClient.calculateCharges(payload); } /** @@ -175,9 +173,7 @@ export function createChargeController( const buyerCountryCode = getBuyerCountryCode(); const vatId = getVatId(); - return buyerCountryCode && vatId - ? [buyerCountryCode, vatId].join(':') - : ''; + return buyerCountryCode ? [buyerCountryCode, vatId].join(':') : ''; } /** @@ -187,19 +183,15 @@ export function createChargeController( * @param {boolean} isCurrentRequest whether the failed request is still current */ function handleRequestError(error, isCurrentRequest) { - const isVatError = isVatErrorResponse(error); - - if (!isVatError) { - view.showErrorModal(); - } - if (!isCurrentRequest) { logApiError(error); return; } - if (isVatError) { + if (isVatErrorResponse(error)) { onVatIdError(getVatErrorReason(error)); + } else { + view.showErrorModal(); } logApiError(error); @@ -212,7 +204,7 @@ export function createChargeController( * @return {boolean} true when the error is a Paygate VAT validation response */ function isVatErrorResponse(error) { - return error.status === 422 && Boolean(error.body.vatIdInvalid); + return error.status === 422 && Boolean(error.body && error.body.vatIdInvalid); } /** diff --git a/site/assets/js/pages/checkout/charge-request.js b/site/assets/js/pages/checkout/charge-request.js new file mode 100644 index 00000000..e813ac1a --- /dev/null +++ b/site/assets/js/pages/checkout/charge-request.js @@ -0,0 +1,45 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +'use strict'; + +/** + * Builds a Paygate charge-calculation request for the current buyer inputs. + * + * @param {string} orderId Paygate order ID + * @param {string} buyerCountryCode billing country ISO code + * @param {string} vatId optional VAT ID + * @return {Object|null} request body, or null while required inputs are absent + */ +export function buildChargeRequest(orderId, buyerCountryCode, vatId) { + if (!orderId || !buyerCountryCode) { + return null; + } + + return vatId + ? {orderId, buyerCountryCode, vatId} + : {orderId, buyerCountryCode}; +} diff --git a/site/assets/js/pages/checkout/completed-page-url.js b/site/assets/js/pages/checkout/completed-page-url.js new file mode 100644 index 00000000..02544d7b --- /dev/null +++ b/site/assets/js/pages/checkout/completed-page-url.js @@ -0,0 +1,74 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +'use strict'; + +/** + * Builds Spine's payment-result URL from its checkout URL. + * + * @param {string} currentUrl absolute checkout URL + * @param {string} orderId Paygate order ID + * @return {string} absolute payment-result URL + */ +export function getCompletedPageUrl(currentUrl, orderId) { + const completedUrl = new URL(currentUrl); + const checkoutPath = /\/checkout\/?$/; + + if (checkoutPath.test(completedUrl.pathname)) { + completedUrl.pathname = completedUrl.pathname.replace( + checkoutPath, + '/checkout-completed/' + ); + } else { + return ''; + } + completedUrl.search = ''; + completedUrl.searchParams.set('orderId', orderId); + completedUrl.hash = ''; + return completedUrl.href; +} + +/** + * Builds Spine's checkout URL from its payment-result URL. + * + * @param {string} currentUrl absolute payment-result URL + * @param {string} orderId Paygate order ID + * @return {string} absolute checkout URL, or an empty string for an unsupported path + */ +export function getCheckoutPageUrl(currentUrl, orderId) { + const checkoutUrl = new URL(currentUrl); + const completedPath = /\/checkout-completed\/?$/; + + if (completedPath.test(checkoutUrl.pathname)) { + checkoutUrl.pathname = checkoutUrl.pathname.replace(completedPath, '/checkout/'); + } else { + return ''; + } + checkoutUrl.search = ''; + checkoutUrl.searchParams.set('orderId', orderId); + checkoutUrl.hash = ''; + return checkoutUrl.href; +} diff --git a/site/assets/js/pages/checkout/completed.js b/site/assets/js/pages/checkout/completed.js new file mode 100644 index 00000000..fc473164 --- /dev/null +++ b/site/assets/js/pages/checkout/completed.js @@ -0,0 +1,214 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +'use strict'; + +import * as params from '@params'; +import {createPurchaseClient} from 'js/modules/paygate/purchases'; +import {getCheckoutPageUrl} from 'js/pages/checkout/completed-page-url'; +import {getOrderId} from 'js/pages/checkout/order-id'; + +const pollingIntervalsMs = [3000, 5000, 10000, 30000]; +const failuresBeforeErrorView = 3; +const notFoundResponsesBeforeResult = 2; +const maxPollingDurationMs = 15 * 60 * 1000; +const unsuccessfulTerminalStatuses = new Set(['ABANDONED', 'FAILED', 'VOIDED']); +const viewIds = Object.freeze({ + IN_PROGRESS: 'payment-in-progress', + COMPLETED: 'payment-completed', + FAILED: 'payment-failed', + REFUNDED: 'payment-refunded', + CHARGED_BACK: 'payment-charged-back', + STATUS_ERROR: 'payment-status-error', + NOT_FOUND: 'payment-order-not-found', + UNKNOWN: 'payment-status-unknown' +}); + +init(); + +/** Starts resolving the payment result represented by the current URL. */ +function init() { + if (!document.querySelector('[data-payment-status-page]')) { + return; + } + + const orderId = getOrderId(window.location); + if (!orderId) { + return; + } + configureBackToCheckoutLink(orderId); + + const paygateUrl = params.payment && params.payment.paygateurl; + if (!paygateUrl) { + return; + } + + let purchaseClient; + try { + purchaseClient = createPurchaseClient(paygateUrl); + } catch (error) { + logApiError(error); + return; + } + + let consecutiveFailures = 0; + let consecutiveNotFoundResponses = 0; + let pollingIntervalIndex = 0; + let pollingTimerId; + let stopped = false; + const pollingDeadlineMs = Date.now() + maxPollingDurationMs; + const deadlineTimerId = window.setTimeout(reachPollingDeadline, maxPollingDurationMs); + + showView(viewIds.IN_PROGRESS); + pollPaymentStatus(); + + /** Reads the order until Paygate reports a terminal payment status. */ + async function pollPaymentStatus() { + pollingTimerId = undefined; + if (stopped) { + return; + } + + try { + const order = await purchaseClient.getOrder(orderId); + if (stopped) { + return; + } + + consecutiveFailures = 0; + consecutiveNotFoundResponses = 0; + + if (order.paymentStatus === 'SETTLED' || (!order.paymentStatus && order.completed)) { + finishWithView(viewIds.COMPLETED); + return; + } + if (unsuccessfulTerminalStatuses.has(order.paymentStatus)) { + finishWithView(viewIds.FAILED); + return; + } + if (order.paymentStatus === 'REFUNDED') { + finishWithView(viewIds.REFUNDED); + return; + } + if (order.paymentStatus === 'CHARGED_BACK') { + finishWithView(viewIds.CHARGED_BACK); + return; + } + + showView(viewIds.IN_PROGRESS); + } catch (error) { + if (stopped) { + return; + } + + if (error.status === 404) { + consecutiveNotFoundResponses += 1; + consecutiveFailures = 0; + if (consecutiveNotFoundResponses >= notFoundResponsesBeforeResult) { + finishWithView(viewIds.NOT_FOUND); + return; + } + } else { + consecutiveNotFoundResponses = 0; + consecutiveFailures += 1; + if (consecutiveFailures >= failuresBeforeErrorView) { + showView(viewIds.STATUS_ERROR); + } + } + logApiError(error); + } + + scheduleNextPoll(); + } + + /** Schedules the next attempt using a backoff capped at 30 seconds. */ + function scheduleNextPoll() { + const remainingDurationMs = pollingDeadlineMs - Date.now(); + if (remainingDurationMs <= 0) { + finishWithView(viewIds.UNKNOWN); + return; + } + + const delay = pollingIntervalsMs[pollingIntervalIndex]; + if (pollingIntervalIndex < pollingIntervalsMs.length - 1) { + pollingIntervalIndex += 1; + } + pollingTimerId = window.setTimeout( + pollPaymentStatus, + Math.min(delay, remainingDurationMs) + ); + } + + /** Stops polling when its maximum duration is reached. */ + function reachPollingDeadline() { + finishWithView(viewIds.UNKNOWN); + } + + /** Stops all timers and shows the final page view. */ + function finishWithView(viewId) { + if (stopped) { + return; + } + stopped = true; + if (pollingTimerId !== undefined) { + window.clearTimeout(pollingTimerId); + } + window.clearTimeout(deadlineTimerId); + showView(viewId); + } +} + +/** Makes the failed-payment action navigate back to the same order. */ +function configureBackToCheckoutLink(orderId) { + const link = document.querySelector('#payment-failed [data-back-to-checkout]'); + if (!link) { + return; + } + + const checkoutUrl = getCheckoutPageUrl(window.location.href, orderId); + if (checkoutUrl) { + link.href = checkoutUrl; + link.hidden = false; + } +} + +/** Shows one payment-status view and hides every other view. */ +function showView(activeViewId) { + Object.values(viewIds).forEach(viewId => { + const view = document.getElementById(viewId); + if (view) { + view.hidden = viewId !== activeViewId; + } + }); +} + +/** Logs an API failure without exposing response data. */ +function logApiError(error) { + console.error( + `${error.status || 'Network error'}: ` + + `${error.statusText || 'Payment status request failed'}` + ); +} diff --git a/site/assets/js/pages/checkout/countries.js b/site/assets/js/pages/checkout/countries.js new file mode 100644 index 00000000..a3716411 --- /dev/null +++ b/site/assets/js/pages/checkout/countries.js @@ -0,0 +1,133 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +'use strict'; + +const countryCodes = [ + 'AD AE AF AG AI AL AM AO AQ AR AS AT AU AW AX AZ BA BB BD BE BF BG BH BI', + 'BJ BL BM BN BO BQ BR BS BT BV BW BY BZ CA CC CD CF CG CH CI CK CL CM CN', + 'CO CR CU CV CW CX CY CZ DE DJ DK DM DO DZ EC EE EG EH ER ES ET FI FJ FK', + 'FM FO FR GA GB GD GE GF GG GH GI GL GM GN GP GQ GR GS GT GU GW GY HK HM', + 'HN HR HT HU ID IE IL IM IN IO IQ IR IS IT JE JM JO JP KE KG KH KI KM KN', + 'KP KR KW KY KZ LA LB LC LI LK LR LS LT LU LV LY MA MC MD ME MF MG MH MK', + 'ML MM MN MO MP MQ MR MS MT MU MV MW MX MY MZ NA NC NE NF NG NI NL NO NP', + 'NR NU NZ OM PA PE PF PG PH PK PL PM PN PR PS PT PW PY QA RE RO RS RU RW', + 'SA SB SC SD SE SG SH SI SJ SK SL SM SN SO SR SS ST SV SX SY SZ TC TD TF', + 'TG TH TJ TK TL TM TN TO TR TT TV TW TZ UA UG UM US UY UZ VA VC VE VG VI', + 'VN VU WF WS YE YT ZA ZM ZW' +].join(' ').split(' '); + +/** + * Populates a billing-country select with ISO 3166-1 countries. + * + * Region names come from the browser locale API. The country code remains a + * usable fallback in browsers that do not implement `Intl.DisplayNames`. + * + * @param {HTMLSelectElement} select country select to populate + */ +export function populateCountrySelect(select) { + if (!select || select.options.length > 1) { + return; + } + + const displayNames = typeof Intl.DisplayNames === 'function' + ? new Intl.DisplayNames(['en'], {type: 'region'}) + : null; + const countries = countryCodes.map(code => ({ + code, + name: displayNames ? displayNames.of(code) : code + })).sort((first, second) => first.name.localeCompare(second.name)); + + const options = document.createDocumentFragment(); + countries.forEach(country => { + options.append(new Option(country.name, country.code)); + }); + select.append(options); +} + +/** + * Initializes the shared Select2 country control with flag sprites. + * + * @param {HTMLSelectElement} select country select to initialize + */ +export function initializeCountrySelector(select) { + populateCountrySelect(select); + + if (!select || !window.jQuery || typeof window.jQuery.fn.select2 !== 'function') { + return; + } + + const $select = window.jQuery(select); + $select.select2({ + placeholder: select.dataset.placeholder || '', + templateResult: formatCountry, + templateSelection: formatCountry, + width: '100%' + }); + $select.on('select2:open', () => { + focusOpenCountrySearchField(); + }); +} + +/** + * Focuses the search input belonging to the currently open country dropdown. + * + * @param {Document|HTMLElement} [root=document] root used to find the open dropdown + */ +export function focusOpenCountrySearchField(root = document) { + const searchField = root.querySelector( + '.select2-container--open .select2-search__field' + ); + + if (searchField) { + searchField.focus(); + } +} + +/** + * Renders one Select2 country option using the shared flag sprite. + * + * @param {Object} country Select2 option data + * @return {string|JQuery} rendered country option + */ +function formatCountry(country) { + if (!country.id) { + return country.text; + } + + const normalizedCode = String(country.id).toLowerCase(); + const $flag = window.jQuery('') + .addClass(`iti__flag iti__${normalizedCode}`) + .attr('aria-hidden', 'true'); + const $label = window.jQuery('') + .addClass('country-selector__text') + .text(country.text); + + return window.jQuery('') + .addClass('country-selector__option') + .append($flag) + .append($label); +} diff --git a/site/assets/js/pages/checkout/dom.js b/site/assets/js/pages/checkout/dom.js index d31bdcaf..d14a34b8 100644 --- a/site/assets/js/pages/checkout/dom.js +++ b/site/assets/js/pages/checkout/dom.js @@ -5,7 +5,7 @@ * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * - * http://www.apache.org/licenses/LICENSE-2.0 + * https://www.apache.org/licenses/LICENSE-2.0 * * Redistribution and use in source and/or binary forms, with or without * modification, must retain the above copyright notice and the following @@ -33,17 +33,10 @@ * @property {JQuery} $form checkout billing form wrapper * @property {JQuery} $summary order summary container * @property {JQuery} $country billing country select - * @property {JQuery} $phone custom phone field wrapper - * @property {JQuery} $phoneCountryCode phone country-code - * select - * @property {JQuery} $phoneFlag visible phone country flag - * @property {JQuery} $phoneDialCode visible phone dial code label - * @property {JQuery} $phoneNumber national phone number input + * @property {JQuery} $phoneNumber international phone input + * @property {JQuery} $phoneCountry native phone-country state * @property {JQuery} $vatId vat ID input * @property {JQuery} $loading summary loading container - * @property {JQuery} $loadingSpinner summary spinner element - * @property {JQuery} $loadingText summary loading text element - * @property {JQuery} $loadingSupport summary support text element * @property {JQuery} $productTitle product title element * @property {JQuery} $productDescription product description * element @@ -53,6 +46,7 @@ * @property {JQuery} $totalValue total amount element * @property {JQuery} $submitButton checkout submit button * @property {JQuery} $errorModal generic checkout error modal + * @property {JQuery} $missingOrder missing-order result panel * @property {JQuery} $notFound order-not-found result panel * @property {JQuery} $summaryError generic checkout summary-error panel * @property {HTMLFormElement} form native checkout form element @@ -68,16 +62,10 @@ export function getCheckoutDom() { $form: $('#checkout-form'), $summary: $('.checkout-summary'), $country: $('#checkout-country'), - $phone: $('.phone-field'), - $phoneCountryCode: $('#checkout-phone-country-code'), - $phoneFlag: $('#checkout-phone-flag'), - $phoneDialCode: $('#checkout-phone-dial-code'), $phoneNumber: $('#checkout-phone'), + $phoneCountry: $('#checkout-phone-country'), $vatId: $('#checkout-vat-id'), $loading: $('#checkout-summary-loading'), - $loadingSpinner: $('#checkout-summary-loading-spinner'), - $loadingText: $('#checkout-summary-loading-text'), - $loadingSupport: $('#checkout-summary-support'), $productTitle: $('#checkout-product-title'), $productDescription: $('#checkout-product-description'), $subtotalValue: $('#checkout-subtotal-value'), @@ -86,6 +74,7 @@ export function getCheckoutDom() { $totalValue: $('#checkout-total-value'), $submitButton: $('#checkout-submit'), $errorModal: $('#checkout-error-modal'), + $missingOrder: $('#checkout-missing-order'), $notFound: $('#checkout-not-found'), $summaryError: $('#checkout-summary-error') }; diff --git a/site/assets/js/pages/checkout/form-controller.js b/site/assets/js/pages/checkout/form-controller.js index 59d9b74c..84ce4461 100644 --- a/site/assets/js/pages/checkout/form-controller.js +++ b/site/assets/js/pages/checkout/form-controller.js @@ -5,7 +5,7 @@ * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * - * http://www.apache.org/licenses/LICENSE-2.0 + * https://www.apache.org/licenses/LICENSE-2.0 * * Redistribution and use in source and/or binary forms, with or without * modification, must retain the above copyright notice and the following @@ -26,11 +26,14 @@ 'use strict'; -import {euCountryPhoneCodes} from 'js/pages/checkout/phone-codes'; -import { - normalizePhoneNumber, - sanitizePhoneNumberInput -} from 'js/modules/forms/phone-number'; +import {isEuCountry} from 'js/pages/checkout/vat-countries'; +import {normalizeIntlPhoneNumber} from 'js/modules/forms/phone-number'; + +const intlTelInputScriptSelector = + 'script[src*="libs/intl-tel-input/intlTelInput.min.js"]'; + +/** Default phone country used before the user selects a billing country. */ +export const defaultPhoneCountryCode = 'US'; /** * Generic async field-validation states. @@ -51,28 +54,39 @@ export const fieldValidationState = Object.freeze({ * API exposed by the checkout form controller. * * @typedef {Object} CheckoutFormController - * @property {function(boolean): boolean} applyBillingCountryFromPhoneCountry - * syncs billing country from phone country when allowed * @property {function(boolean): void} applyPhoneCountryFromBillingCountry * syncs phone country from billing country when allowed * @property {function(): void} bindPhoneEvents * attaches phone field event handlers * @property {function(string): SubmitBillingInfoRequest} * buildSubmitBillingInfoRequest builds the billing-info payload for Paygate + * @property {function(): void} clearVatIdError + * clears the VAT ID API validation error + * @property {function(HTMLElement): void} clearFieldError + * clears the inline validation error for a field while it is being edited * @property {function(): void} focusPhoneNumber * focuses the phone number input when a country is selected + * @property {function(): string} getVatId + * returns VAT ID only when it applies to the selected country + * @property {function(Object): void} restoreCountryState + * restores the billing-country and phone-country controls * @property {function(HTMLElement, string): void} setFieldValidationState * updates generic async field validation styling * @property {function(string): void} showVatIdError * renders VAT API validation errors inline - * @property {function(): void} updatePhoneCountryDisplay - * refreshes visible phone-country UI + * @property {function(): void} showPendingVatIdError + * renders a VAT API validation error deferred while the field was focused + * @property {function(): void} initPhoneNumberField + * initializes the shared international phone input * @property {function(): void} updateVatIdFieldState * refreshes VAT field state after country changes - * @property {function(HTMLInputElement|HTMLSelectElement|HTMLTextAreaElement):boolean} validateField + * @property {function(HTMLInputElement|HTMLSelectElement|HTMLTextAreaElement):boolean} + * validateField * validates one form field * @property {function(string): boolean} validateRequiredFields * validates all required checkout fields + * @property {function(): boolean} validatePhoneNumber + * validates the optional international phone number */ /** @@ -83,14 +97,57 @@ export const fieldValidationState = Object.freeze({ * @return {CheckoutFormController} checkout form helpers and event handlers */ export function createCheckoutFormController({dom}) { + let isSettingPhoneCountryProgrammatically = false; + let pendingVatIdErrorReason = ''; + + /** Initializes the shared `intl-tel-input` field. */ + function initPhoneNumberField() { + const field = dom.$phoneNumber.get(0); + + if (!field || typeof window.intlTelInput !== 'function') { + return; + } + + const initialCountry = normalizeCountryCode(dom.$phoneCountry.val()) || + defaultPhoneCountryCode; + window.intlTelInput(field, { + initialCountry: initialCountry.toLowerCase(), + autoPlaceholder: 'aggressive', + separateDialCode: true, + formatOnDisplay: true, + utilsScript: getPhoneUtilsScriptUrl() + }); + syncPhoneCountryState(); + } + + /** Resolves the phone utility bundle next to the loaded library. */ + function getPhoneUtilsScriptUrl() { + const libraryScript = document.querySelector(intlTelInputScriptSelector); + + return libraryScript + ? new URL('utils.js', libraryScript.src).toString() + : '/libs/intl-tel-input/utils.js'; + } + /** - * Attaches event handlers for the custom phone field. + * Attaches event handlers for the shared phone field. + * + * @param {Object} options phone event options + * @param {function(): void} [options.onPhoneCountryChange] called after a + * user-driven phone country change */ - function bindPhoneEvents() { - dom.$phone.on('click', focusPhoneCountrySelectorIfMissing); - dom.$phoneNumber.on('focus', focusPhoneCountrySelectorIfMissing); - dom.$phoneNumber.on('beforeinput', preventUnsupportedPhoneInput); - dom.$phoneNumber.on('input', sanitizePhoneNumberValue); + function bindPhoneEvents({onPhoneCountryChange} = {}) { + dom.$phoneNumber.on('countrychange', () => { + syncPhoneCountryState(); + if ( + !isSettingPhoneCountryProgrammatically && + typeof onPhoneCountryChange === 'function' + ) { + onPhoneCountryChange(); + } + }); + dom.$phoneNumber.on('input', () => setPhoneFieldError('')); + dom.$phoneNumber.on('blur', validatePhoneNumber); } /** @@ -117,6 +174,11 @@ export function createCheckoutFormController({dom}) { return true; } + if (field.disabled || field.closest('[hidden]')) { + setFieldError(field, ''); + return true; + } + const value = field.value ? field.value.trim() : ''; let message = ''; @@ -130,13 +192,61 @@ export function createCheckoutFormController({dom}) { return !message; } + /** + * Validates the optional phone number through `intl-tel-input`. + * + * @return {boolean} true when the phone is empty or valid + */ + function validatePhoneNumber() { + const value = String(dom.$phoneNumber.val() || '').trim(); + const phoneInput = getPhoneInputInstance(); + const utilsReady = Boolean(window.intlTelInputUtils); + const isValid = !value || !utilsReady || + Boolean(phoneInput && phoneInput.isValidNumber()); + + setPhoneFieldError(isValid ? '' : 'Enter a valid phone number.'); + return isValid; + } + /** * Shows the API-provided VAT ID validation error on the VAT ID field. * * @param {string} reason paygate VAT ID error reason */ function showVatIdError(reason) { - setFieldError(dom.$vatId.get(0), vatIdErrorMessage(reason)); + if (!isVatIdRelevant()) { + return; + } + + const field = dom.$vatId.get(0); + pendingVatIdErrorReason = reason; + + if (field && field.ownerDocument && field.ownerDocument.activeElement === field) { + return; + } + + showPendingVatIdError(); + } + + /** Shows a VAT ID error that arrived while the user was editing the field. */ + function showPendingVatIdError() { + if (!pendingVatIdErrorReason || !isVatIdRelevant()) { + return; + } + + setFieldError(dom.$vatId.get(0), vatIdErrorMessage(pendingVatIdErrorReason)); + pendingVatIdErrorReason = ''; + } + + /** Clears an earlier VAT ID validation response after the input changes. */ + function clearVatIdError() { + pendingVatIdErrorReason = ''; + setFieldError(dom.$vatId.get(0), ''); + } + + /** Clears an inline validation error while the user edits a field. */ + function clearFieldError(field) { + setFieldError(field, ''); } /** @@ -146,6 +256,9 @@ export function createCheckoutFormController({dom}) { * @param {string} state async validation state */ function setFieldValidationState(field, state) { + if (state === fieldValidationState.success) { + setFieldError(field, ''); + } applyFieldValidationState(field, state); } @@ -154,9 +267,34 @@ export function createCheckoutFormController({dom}) { */ function updateVatIdFieldState() { const field = dom.$vatId.get(0); - const vatId = (dom.$vatId.val() || '').trim(); + const fieldContainer = field && field.closest('.form-field'); + const isRelevant = isVatIdRelevant(); - vatId ? validateField(field) : setFieldError(field, ''); + if (!field || !fieldContainer) { + return; + } + + fieldContainer.hidden = !isRelevant; + + if (!isRelevant) { + dom.$vatId.val(''); + clearVatIdError(); + applyFieldValidationState(field, fieldValidationState.idle); + return; + } + + clearVatIdError(); + } + + /** + * Returns VAT ID only when it applies to the selected billing country. + * + * @return {string} VAT ID, or an empty string when VAT ID is not applicable + */ + function getVatId() { + return isVatIdRelevant() + ? String(dom.$vatId.val() || '').trim() + : ''; } /** @@ -169,14 +307,15 @@ export function createCheckoutFormController({dom}) { const formData = Object.fromEntries(new FormData(dom.form).entries()); const field = name => (formData[name] || '').trim(); const companyName = field('company'); - const vatId = field('vat_id'); + const vatId = getVatId(); const fullName = [field('first_name'), field('last_name')] .filter(Boolean) .join(' ') || companyName; - const phoneNumber = normalizePhoneNumber( - formData.phone_country_code || '', - formData.phone_number || '' - ); + const phoneNumber = buildPhoneNumberPayload(); + const company = (companyName || vatId) ? { + ...(companyName ? {name: companyName} : {}), + ...(vatId ? {vatId} : {}) + } : null; const billingInfo = { name: fullName, email: field('email'), @@ -186,10 +325,7 @@ export function createCheckoutFormController({dom}) { street: joinAddressLines(formData.address_line_1, formData.address_line_2), postalCode: field('postal_code') }, - company: companyName ? { - name: companyName, - vatId - } : null + company }; if (phoneNumber) { @@ -203,150 +339,108 @@ export function createCheckoutFormController({dom}) { } /** - * Sets billing country from phone country when the user has not chosen country manually. - * - * @param {boolean} countryManuallySelected whether billing country was chosen by the user - * @return {boolean} true when billing country was changed by the phone-country selector - */ - function applyBillingCountryFromPhoneCountry(countryManuallySelected) { - if (countryManuallySelected) { - return false; - } - - const countryCode = countryCodeFromPhoneCode(getPhoneCountryCode()); - - if (!countryCode || !hasCountryOption(countryCode) || dom.$country.val() === countryCode) { - return false; - } - - dom.$country.val(countryCode); - return true; - } - - /** - * Sets phone country from billing country while the phone number is still untouched. + * Sets phone country from billing country unless the phone country was chosen manually. * * @param {boolean} phoneCountryManuallySelected whether phone country was chosen by the user */ function applyPhoneCountryFromBillingCountry(phoneCountryManuallySelected) { - if (phoneCountryManuallySelected || hasPhoneNumber()) { - updatePhoneCountryDisplay(); + if (phoneCountryManuallySelected) { return; } - setPhoneCountryCode(euCountryPhoneCodes[dom.$country.val()] || ''); - updatePhoneCountryDisplay(); + setPhoneCountry(dom.$country.val()); } - /** - * Mirrors the selected phone country into the custom visible phone field. - */ - function updatePhoneCountryDisplay() { - restorePhoneCountryFromBillingCountry(); - const selection = getPhoneCountrySelection(); - - dom.$phoneFlag.text(selection.flag); - dom.$phoneDialCode.text(selection.code); - dom.$phone.attr( - 'data-phone-country-selected', - selection.isSelected ? 'true' : 'false' - ); - dom.$phoneNumber.prop('disabled', !selection.isSelected); - - if (!selection.isSelected && !hasPhoneNumber()) { - clearPhoneNumber(); + /** Restores billing-country and phone-country controls from browser history. */ + function restoreCountryState({billingCountryCode, phoneCountryCode}) { + if (!billingCountryCode || hasCountryOption(billingCountryCode)) { + setBillingCountry(billingCountryCode); } - } - /** - * Restores phone country from billing country when browser already restored the number. - */ - function restorePhoneCountryFromBillingCountry() { - if (getPhoneCountryCode() || !hasPhoneNumber()) { - return; - } + setPhoneCountry(phoneCountryCode || billingCountryCode); + } - setPhoneCountryCode(euCountryPhoneCodes[dom.$country.val()] || ''); + /** Returns country values restored by the browser's native form state. */ + function getBrowserRestoredCountryState() { + return { + billingCountryCode: normalizeCountryCode(dom.$country.val()), + phoneCountryCode: normalizeCountryCode(dom.$phoneCountry.val()) + }; } /** * Focuses the phone number input when the phone country is selected. */ function focusPhoneNumber() { - if (!getPhoneCountryCode()) { - return; - } - window.requestAnimationFrame(() => { dom.$phoneNumber.trigger('focus'); }); } - /** - * Focuses the phone-country select when the number part cannot be used yet. - */ - function focusPhoneCountrySelectorIfMissing() { - if (!getPhoneCountryCode()) { - dom.$phoneCountryCode.trigger('focus'); + /** Returns the `intl-tel-input` instance for the checkout phone field. */ + function getPhoneInputInstance() { + const field = dom.$phoneNumber.get(0); + + if (!field || !window.intlTelInputGlobals) { + return null; } + + return window.intlTelInputGlobals.getInstance(field); } - /** - * Prevents unsupported phone symbols from being typed into the phone field. - * - * @param {JQuery.Event} event phone number beforeinput event - */ - function preventUnsupportedPhoneInput(event) { - const originalEvent = event.originalEvent; - const input = originalEvent && originalEvent.data; + /** Returns the selected phone country as an uppercase ISO code. */ + function getSelectedPhoneCountryCode() { + const phoneInput = getPhoneInputInstance(); + const countryData = phoneInput && phoneInput.getSelectedCountryData(); - if (input && sanitizePhoneNumberInput(input) !== input) { - event.preventDefault(); - } + return String(countryData && countryData.iso2 || '').toUpperCase(); } - /** - * Sanitizes the phone number input after user edits. - */ - function sanitizePhoneNumberValue() { - const sanitized = sanitizePhoneNumberInput(dom.$phoneNumber.val()); + /** Mirrors the library-owned phone country into a native form control. */ + function syncPhoneCountryState() { + dom.$phoneCountry.val(getSelectedPhoneCountryCode()); + } - if (dom.$phoneNumber.val() !== sanitized) { - dom.$phoneNumber.val(sanitized); + /** Selects the phone country without treating it as a user change. */ + function setPhoneCountry(countryCode) { + const phoneInput = getPhoneInputInstance(); + const normalizedCode = String(countryCode || '').trim().toLowerCase(); + + if (!phoneInput || !/^[a-z]{2}$/.test(normalizedCode)) { + return; } - } - /** - * Returns the current phone-country code. - * - * @return {string} selected phone-country code, or empty string - */ - function getPhoneCountryCode() { - return String(dom.$phoneCountryCode.val() || ''); + isSettingPhoneCountryProgrammatically = true; + try { + // `setCountry()` emits `countrychange` synchronously. Keep the guard + // through that event and release it after the current event turn. + phoneInput.setCountry(normalizedCode); + } finally { + window.setTimeout(() => { + isSettingPhoneCountryProgrammatically = false; + }, 0); + } } - /** - * Updates the selected phone-country code. - * - * @param {string} phoneCode phone-country code without a leading plus sign - */ - function setPhoneCountryCode(phoneCode) { - dom.$phoneCountryCode.val(phoneCode); + /** Sets the native country value and refreshes its Select2 presentation. */ + function setBillingCountry(countryCode) { + dom.$country.val(countryCode); + if (dom.$country.hasClass('select2-hidden-accessible')) { + dom.$country.trigger('change.select2'); + } } - /** - * Returns the currently selected phone-country data for the visible field. - * - * @return {{flag: string, code: string, isSelected: boolean}} selected phone-country data - */ - function getPhoneCountrySelection() { - const selected = dom.$phoneCountryCode.find(':selected'); + /** Builds the optional Paygate phone number from the shared plugin state. */ + function buildPhoneNumberPayload() { + const rawNumber = String(dom.$phoneNumber.val() || '').trim(); + const phoneInput = getPhoneInputInstance(); + const countryData = phoneInput && phoneInput.getSelectedCountryData(); - return { - flag: String(selected.data('flag') || ''), - code: String(selected.data('code') || ''), - isSelected: Boolean(getPhoneCountryCode()) - }; + return normalizeIntlPhoneNumber( + rawNumber, + countryData && countryData.dialCode, + phoneInput && phoneInput.getNumber() + ); } /** @@ -376,6 +470,18 @@ export function createCheckoutFormController({dom}) { errorElement.textContent = message || ''; } + /** Applies or clears the international phone field error state. */ + function setPhoneFieldError(message) { + const field = dom.$phoneNumber.get(0); + + if (!field) { + return; + } + + setFieldError(field, message); + field.setCustomValidity(message || ''); + } + /** * Applies the field state classes used by inline validation styles. * @@ -441,23 +547,6 @@ export function createCheckoutFormController({dom}) { } } - /** - * Clears the national phone-number input and refreshes its validation state. - */ - function clearPhoneNumber() { - dom.$phoneNumber.val(''); - validateField(dom.$phoneNumber.get(0)); - } - - /** - * Checks whether the national phone-number input has user-entered text. - * - * @return {boolean} true when the phone number input is not empty - */ - function hasPhoneNumber() { - return Boolean((dom.$phoneNumber.val() || '').trim()); - } - /** * Checks whether the billing country select contains the given country code. * @@ -465,19 +554,22 @@ export function createCheckoutFormController({dom}) { * @return {boolean} true when the select has an option for the country code */ function hasCountryOption(countryCode) { - return dom.$country.find(`option[value="${countryCode}"]`).length > 0; + const countryField = dom.$country.get(0); + return Boolean( + countryField && Array.from(countryField.options) + .some(option => option.value === countryCode) + ); } - /** - * Resolves an EU billing country code from a phone country code. - * - * @param {string} phoneCode phone calling code without a plus sign - * @return {string} matching billing country code, or empty string when none matches - */ - function countryCodeFromPhoneCode(phoneCode) { - return Object.keys(euCountryPhoneCodes).find( - countryCode => euCountryPhoneCodes[countryCode] === phoneCode - ) || ''; + /** Normalizes a possible ISO country code. */ + function normalizeCountryCode(countryCode) { + const normalizedCode = String(countryCode || '').trim().toUpperCase(); + return /^[A-Z]{2}$/.test(normalizedCode) ? normalizedCode : ''; + } + + /** Checks whether the selected billing country supports VAT ID entry. */ + function isVatIdRelevant() { + return isEuCountry(dom.$country.val()); } /** @@ -492,16 +584,22 @@ export function createCheckoutFormController({dom}) { } return { - applyBillingCountryFromPhoneCountry, applyPhoneCountryFromBillingCountry, bindPhoneEvents, buildSubmitBillingInfoRequest, + clearFieldError, + clearVatIdError, focusPhoneNumber, + getBrowserRestoredCountryState, + getVatId, + initPhoneNumberField, + restoreCountryState, setFieldValidationState, + showPendingVatIdError, showVatIdError, - updatePhoneCountryDisplay, updateVatIdFieldState, validateField, + validatePhoneNumber, validateRequiredFields }; } diff --git a/site/assets/js/pages/checkout/index.js b/site/assets/js/pages/checkout/index.js index e13867e4..886646d4 100644 --- a/site/assets/js/pages/checkout/index.js +++ b/site/assets/js/pages/checkout/index.js @@ -5,7 +5,7 @@ * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * - * http://www.apache.org/licenses/LICENSE-2.0 + * https://www.apache.org/licenses/LICENSE-2.0 * * Redistribution and use in source and/or binary forms, with or without * modification, must retain the above copyright notice and the following @@ -29,8 +29,19 @@ import * as params from '@params'; import {createPurchaseClient} from 'js/modules/paygate/purchases'; import {createChargeController} from 'js/pages/checkout/charge-controller'; +import {getCompletedPageUrl} from 'js/pages/checkout/completed-page-url'; +import {initializeCountrySelector} from 'js/pages/checkout/countries'; import {getCheckoutDom} from 'js/pages/checkout/dom'; -import {createCheckoutFormController} from 'js/pages/checkout/form-controller'; +import { + createCheckoutFormController, + defaultPhoneCountryCode +} from 'js/pages/checkout/form-controller'; +import { + checkoutNavigationMode, + getCheckoutNavigationMode, + getRestoredPhoneCountryManualState +} from 'js/pages/checkout/navigation'; +import {getOrderId} from 'js/pages/checkout/order-id'; import {createCheckoutView} from 'js/pages/checkout/view-controller'; const requiredSelector = 'input[required], select[required], textarea[required]'; @@ -43,18 +54,18 @@ $( return; } + initializeCountrySelector(dom.$country.get(0)); const purchaseClient = createPurchaseClient(params.payment.paygateurl); - const orderId = getOrderId(); + const orderId = getOrderId(window.location); const view = createCheckoutView(dom); const formController = createCheckoutFormController({dom}); - let countryManuallySelected = false; let phoneCountryManuallySelected = false; const chargeController = createChargeController({ purchaseClient, view, ensureOrderId: () => Promise.resolve(orderId), getBuyerCountryCode: () => dom.$country.val(), - getVatId: () => (dom.$vatId.val() || '').trim(), + getVatId: formController.getVatId, onFieldValidationStateChange: state => { formController.setFieldValidationState(dom.$vatId.get(0), state); }, @@ -63,13 +74,15 @@ $( }); if (!orderId) { - redirectToGettingHelp(); + view.showMissingOrderView(); return; } dom.$form.prop('hidden', true); - formController.updatePhoneCountryDisplay(); - formController.bindPhoneEvents(); + formController.initPhoneNumberField(); + formController.bindPhoneEvents({ + onPhoneCountryChange: handlePhoneCountryChange + }); chargeController.updateSubmitState(); loadOrder(); bindEvents(); @@ -79,6 +92,10 @@ $( */ function bindEvents() { dom.$form.on('input', requiredSelector, event => { + formController.clearFieldError(event.target); + }); + + dom.$form.on('blur', 'input[required], textarea[required]', event => { formController.validateField(event.target); }); @@ -94,36 +111,34 @@ $( } }); - $(window).on('pageshow', () => { - scheduleRestoredVatResume(); + $(window).on('pageshow', event => { + const mode = getCheckoutNavigationMode( + getNavigationType(), + Boolean(event.originalEvent && event.originalEvent.persisted) + ); + + if (mode === checkoutNavigationMode.reset) { + scheduleCheckoutReset(); + } else if (mode === checkoutNavigationMode.restore) { + scheduleCheckoutRestoration(); + } }); dom.$country.on('change', () => { - countryManuallySelected = true; chargeController.invalidate(); formController.applyPhoneCountryFromBillingCountry(phoneCountryManuallySelected); formController.updateVatIdFieldState(); chargeController.flush(); }); - dom.$phoneCountryCode.on('change', () => { - phoneCountryManuallySelected = true; - formController.updatePhoneCountryDisplay(); - formController.focusPhoneNumber(); - - if (formController.applyBillingCountryFromPhoneCountry(countryManuallySelected)) { - chargeController.invalidate(); - formController.updateVatIdFieldState(); - chargeController.flush(); - } - }); - dom.$vatId.on('input', () => { + formController.clearVatIdError(); chargeController.invalidate(); chargeController.schedule(); }); dom.$vatId.on('blur', () => { + formController.showPendingVatIdError(); if (chargeController.hasScheduledRequest()) { chargeController.flush(); } @@ -132,21 +147,38 @@ $( dom.$form.on('submit', handleSubmit); } + /** Records a user-selected phone country without changing billing/tax country. */ + function handlePhoneCountryChange() { + phoneCountryManuallySelected = true; + formController.focusPhoneNumber(); + } + /** * Loads order details for the checkout page from the current checkout URL. * * @return {Promise} resolves when the initial order load flow finishes */ async function loadOrder() { - view.setSummaryLoading(true); + view.showSummaryLoading(); try { const order = await purchaseClient.getOrder(orderId); + + if (order.completed) { + const completedPageUrl = getCompletedPageUrl( + window.location.href, + orderId + ); + if (completedPageUrl) { + window.location.replace(completedPageUrl); + return; + } + } + view.fillOrderSummary(order); - view.setSummaryLoading(false); - dom.$form.prop('hidden', false); + view.showCheckoutView(); chargeController.updateSubmitState(); - scheduleRestoredVatResume(); + scheduleCurrentChargeCalculation(); } catch (error) { if (error.status === 404) { chargeController.invalidate(); @@ -155,7 +187,6 @@ $( return; } - view.setSummaryLoading(false); view.showSummaryError(); chargeController.updateSubmitState(); logApiError(error); @@ -171,7 +202,11 @@ $( async function handleSubmit(event) { event.preventDefault(); - if (!formController.validateRequiredFields(requiredSelector)) { + const hasValidRequiredFields = + formController.validateRequiredFields(requiredSelector); + const hasValidPhoneNumber = formController.validatePhoneNumber(); + + if (!hasValidRequiredFields || !hasValidPhoneNumber) { dom.form.reportValidity(); return; } @@ -193,22 +228,6 @@ $( } } - /** - * Reads the order ID from the `orderId` query parameter. - * - * @return {string} checkout order ID, or empty string when unavailable - */ - function getOrderId() { - return (new URLSearchParams(window.location.search).get('orderId') || '').trim(); - } - - /** - * Redirects visitors with incomplete checkout links to the help page. - */ - function redirectToGettingHelp() { - window.location.replace('/getting-help'); - } - /** * Logs API failures in a compact and consistent format. * @@ -222,24 +241,74 @@ $( } /** - * Schedules one pass that resumes charge calculation from browser-restored VAT data. + * Schedules one charge calculation after browser-restored fields settle. */ - function scheduleRestoredVatResume() { - window.setTimeout(resumeChargesFromRestoredVatId, 0); + function scheduleCurrentChargeCalculation() { + window.setTimeout(requestCurrentCharges, 0); + } + + /** Restores custom country widgets after native browser form restoration settles. */ + function scheduleCheckoutRestoration() { + window.setTimeout(() => { + const restoredState = formController.getBrowserRestoredCountryState(); + + phoneCountryManuallySelected = getRestoredPhoneCountryManualState( + phoneCountryManuallySelected, + restoredState + ); + formController.restoreCountryState(restoredState); + formController.updateVatIdFieldState(); + requestCurrentCharges(); + }, 0); + } + + /** Clears browser-restored custom checkout fields after an explicit reload. */ + function scheduleCheckoutReset() { + window.setTimeout(() => { + phoneCountryManuallySelected = false; + formController.restoreCountryState({ + billingCountryCode: '', + phoneCountryCode: defaultPhoneCountryCode + }); + dom.$phoneNumber.val(''); + dom.$vatId.val(''); + formController.updateVatIdFieldState(); + chargeController.invalidate(); + requestCurrentCharges(); + }, 0); + } + + /** Returns the current document-navigation type with a legacy fallback. */ + function getNavigationType() { + const navigationEntries = window.performance && + typeof window.performance.getEntriesByType === 'function' + ? window.performance.getEntriesByType('navigation') + : []; + + if (navigationEntries.length) { + return navigationEntries[0].type; + } + + const legacyType = window.performance && + window.performance.navigation && + window.performance.navigation.type; + if (legacyType === 1) { + return 'reload'; + } + if (legacyType === 2) { + return 'back_forward'; + } + return 'navigate'; } /** - * Restarts charge calculation when the browser already restored VAT ID into the field. + * Calculates charges when a billing country is currently selected. */ - function resumeChargesFromRestoredVatId() { + function requestCurrentCharges() { if (view.isFormHidden()) { return; } - if (!(dom.$vatId.val() || '').trim()) { - return; - } - chargeController.requestIfReady(); } } diff --git a/site/assets/js/pages/checkout/navigation.js b/site/assets/js/pages/checkout/navigation.js new file mode 100644 index 00000000..9d29ed19 --- /dev/null +++ b/site/assets/js/pages/checkout/navigation.js @@ -0,0 +1,61 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +'use strict'; + +export const checkoutNavigationMode = Object.freeze({ + none: 'none', + reset: 'reset', + restore: 'restore' +}); + +/** Chooses whether checkout state should be restored or reset for a navigation. */ +export function getCheckoutNavigationMode(navigationType, pagePersisted = false) { + if (pagePersisted || navigationType === 'back_forward') { + return checkoutNavigationMode.restore; + } + if (navigationType === 'reload') { + return checkoutNavigationMode.reset; + } + return checkoutNavigationMode.none; +} + +/** + * Restores whether the phone country should remain independent from billing country. + * + * @param {boolean} wasManuallySelected state retained by a cached page + * @param {Object} restoredState country values restored by the browser + * @return {boolean} whether billing-country changes should leave phone country unchanged + */ +export function getRestoredPhoneCountryManualState(wasManuallySelected, restoredState) { + const billingCountryCode = restoredState && restoredState.billingCountryCode; + const phoneCountryCode = restoredState && restoredState.phoneCountryCode; + + return Boolean( + wasManuallySelected || + billingCountryCode && phoneCountryCode && phoneCountryCode !== billingCountryCode + ); +} diff --git a/site/assets/js/pages/checkout/phone-codes.js b/site/assets/js/pages/checkout/order-id.js similarity index 70% rename from site/assets/js/pages/checkout/phone-codes.js rename to site/assets/js/pages/checkout/order-id.js index 5dd658e3..5a93bd20 100644 --- a/site/assets/js/pages/checkout/phone-codes.js +++ b/site/assets/js/pages/checkout/order-id.js @@ -5,7 +5,7 @@ * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * - * http://www.apache.org/licenses/LICENSE-2.0 + * https://www.apache.org/licenses/LICENSE-2.0 * * Redistribution and use in source and/or binary forms, with or without * modification, must retain the above copyright notice and the following @@ -27,34 +27,12 @@ 'use strict'; /** - * Phone codes of the EU countries. + * Reads the order ID from the visible checkout URL. + * + * @param {Location|URL} location browser location + * @return {string} Paygate order ID, or an empty string when unavailable */ -export const euCountryPhoneCodes = { - AT: '43', - BE: '32', - BG: '359', - HR: '385', - CY: '357', - CZ: '420', - DK: '45', - EE: '372', - FI: '358', - FR: '33', - DE: '49', - GR: '30', - HU: '36', - IE: '353', - IT: '39', - LV: '371', - LT: '370', - LU: '352', - MT: '356', - NL: '31', - PL: '48', - PT: '351', - RO: '40', - SK: '421', - SI: '386', - ES: '34', - SE: '46' -}; +export function getOrderId(location) { + const checkoutUrl = new URL(location.href); + return (checkoutUrl.searchParams.get('orderId') || '').trim(); +} diff --git a/site/assets/js/pages/checkout/vat-countries.js b/site/assets/js/pages/checkout/vat-countries.js new file mode 100644 index 00000000..3caf64fe --- /dev/null +++ b/site/assets/js/pages/checkout/vat-countries.js @@ -0,0 +1,43 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +'use strict'; + +const euCountryCodes = new Set([ + 'AT', 'BE', 'BG', 'HR', 'CY', 'CZ', 'DK', 'EE', 'FI', + 'FR', 'DE', 'GR', 'HU', 'IE', 'IT', 'LV', 'LT', 'LU', + 'MT', 'NL', 'PL', 'PT', 'RO', 'SK', 'SI', 'ES', 'SE' +]); + +/** + * Checks whether the country supports EU VAT ID entry. + * + * @param {string} countryCode ISO country code + * @return {boolean} true for an EU member country + */ +export function isEuCountry(countryCode) { + return euCountryCodes.has(String(countryCode || '').trim().toUpperCase()); +} diff --git a/site/assets/js/pages/checkout/view-controller.js b/site/assets/js/pages/checkout/view-controller.js index e49a6009..e97aa751 100644 --- a/site/assets/js/pages/checkout/view-controller.js +++ b/site/assets/js/pages/checkout/view-controller.js @@ -5,7 +5,7 @@ * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * - * http://www.apache.org/licenses/LICENSE-2.0 + * https://www.apache.org/licenses/LICENSE-2.0 * * Redistribution and use in source and/or binary forms, with or without * modification, must retain the above copyright notice and the following @@ -42,10 +42,14 @@ * checks whether the checkout form is currently hidden * @property {function(boolean): void} setSubmitDisabled * enables or disables the checkout submit button - * @property {function(boolean): void} setSummaryLoading - * shows or hides the summary loading state + * @property {function(): void} showSummaryLoading + * shows the summary loading state * @property {function(): void} showErrorModal * opens the generic checkout error modal + * @property {function(): void} showCheckoutView + * shows a resolved order summary and its billing form + * @property {function(): void} showMissingOrderView + * shows the missing-order result panel * @property {function(): void} showNotFoundView * shows the checkout order-not-found panel * @property {function(): void} showSummaryError @@ -61,6 +65,21 @@ * @return {CheckoutViewController} view update helpers for the checkout page */ export function createCheckoutView(dom) { + const pageElements = [ + dom.$loading, + dom.$summary, + dom.$form, + dom.$missingOrder, + dom.$notFound, + dom.$summaryError + ]; + const pageViews = { + loading: {elements: [dom.$loading], isResultPage: false}, + checkout: {elements: [dom.$summary, dom.$form], isResultPage: false}, + missingOrder: {elements: [dom.$missingOrder], isResultPage: true}, + notFound: {elements: [dom.$notFound], isResultPage: true}, + summaryError: {elements: [dom.$summaryError], isResultPage: true} + }; /** * Enables or disables the checkout submit button. @@ -68,7 +87,9 @@ export function createCheckoutView(dom) { * @param {boolean} isDisabled whether submit should be disabled */ function setSubmitDisabled(isDisabled) { - dom.$submitButton.prop('disabled', isDisabled); + dom.$submitButton + .prop('disabled', isDisabled) + .toggleClass('disabled', isDisabled); } /** @@ -98,10 +119,12 @@ export function createCheckoutView(dom) { dom.$productDescription.text('').prop('hidden', true); } - dom.$subtotalValue.text(formatMoney(order.netAmount)); + const netAmount = order.netAmount || {}; + + dom.$subtotalValue.text(formatMoney(netAmount)); dom.$vatLabel.text('VAT'); - dom.$vatValue.text(formatMoney(zeroMoney(order.netAmount.currency))); - dom.$totalValue.text(formatMoney(order.netAmount)); + dom.$vatValue.text(formatMoney(zeroMoney(netAmount.currency))); + dom.$totalValue.text(formatMoney(netAmount)); } /** @@ -110,44 +133,36 @@ export function createCheckoutView(dom) { * @param {Object} response paygate charge calculation response */ function updateCharges(response) { - const vatRatePercent = Number(response.vatRate) * 100; - - dom.$vatLabel.text(`VAT (${String(vatRatePercent)}%)`); + dom.$vatLabel.text(formatVatLabel(response && response.vatRate)); dom.$subtotalValue.text(formatMoney(response.netAmount)); dom.$vatValue.text(formatMoney(response.vatAmount)); dom.$totalValue.text(formatMoney(response.totalAmount)); } /** - * Shows or hides the order-summary loading state. - * - * @param {boolean} isLoading whether the summary should show the loading state + * Shows the order-summary loading state. */ - function setSummaryLoading(isLoading) { - dom.$summary.attr('data-loading', isLoading ? 'true' : 'false'); - dom.$summary.attr('data-error', 'false'); - dom.$summary.prop('hidden', false); - dom.$loading.prop('hidden', !isLoading); - dom.$loadingSpinner.prop('hidden', !isLoading); - dom.$loadingSupport.prop('hidden', true); - dom.$form.prop('hidden', isLoading); - dom.$notFound.prop('hidden', true); - dom.$summaryError.prop('hidden', true); - - if (isLoading) { - dom.$loadingText.text('Loading checkout details...'); - } + function showSummaryLoading() { + showPageView('loading'); } /** * Shows the generic summary error panel inside the checkout page. */ function showSummaryError() { - dom.$summary.attr('data-error', 'true'); - dom.$summary.prop('hidden', true); - dom.$form.prop('hidden', true); - dom.$notFound.prop('hidden', true); - dom.$summaryError.prop('hidden', false); + showPageView('summaryError'); + } + + /** Shows the resolved order summary and billing form. */ + function showCheckoutView() { + closeErrorModal(); + showPageView('checkout'); + } + + /** Shows the missing-order result panel. */ + function showMissingOrderView() { + closeErrorModal(); + showPageView('missingOrder'); } /** @@ -169,10 +184,23 @@ export function createCheckoutView(dom) { */ function showNotFoundView() { closeErrorModal(); - dom.$summary.prop('hidden', true); - dom.$form.prop('hidden', true); - dom.$summaryError.prop('hidden', true); - dom.$notFound.prop('hidden', false); + showPageView('notFound'); + } + + /** Shows one checkout page state and hides every other state panel. */ + function showPageView(viewName) { + const view = pageViews[viewName]; + const visibleElements = new Set(view.elements); + + pageElements.forEach(element => { + element.prop('hidden', !visibleElements.has(element)); + }); + setResultPageMode(view.isResultPage); + } + + /** Matches checkout result-page height to the payment-result layout. */ + function setResultPageMode(isResultPage) { + document.body.classList.toggle('checkout-result-page', isResultPage); } /** @@ -182,13 +210,33 @@ export function createCheckoutView(dom) { * @return {string} formatted money value */ function formatMoney(amount) { - const numericAmount = Number(amount.value); + const amountValue = amount && amount.value; + const numericAmount = Number(amountValue); const formattedAmount = Number.isNaN(numericAmount) - ? String(amount.value || '') + ? String(amountValue || '') : numericAmount.toFixed(2); - const currency = amount.currency; + const currency = amount && amount.currency; + const currencySymbol = currency && currency.symbol || ''; + + return `${currencySymbol}${formattedAmount}`; + } + + /** + * Formats a VAT-rate label without exposing invalid or imprecise numbers. + * + * @param {*} rawVatRate VAT rate returned by Paygate + * @return {string} VAT label with an optional percentage + */ + function formatVatLabel(rawVatRate) { + const vatRate = Number(rawVatRate); + + if (rawVatRate === null || rawVatRate === undefined || rawVatRate === '' || + !Number.isFinite(vatRate)) { + return 'VAT'; + } - return `${currency.symbol}${formattedAmount}`; + const percentage = Math.round(vatRate * 10000) / 100; + return `VAT (${String(percentage)}%)`; } /** @@ -209,8 +257,10 @@ export function createCheckoutView(dom) { fillOrderSummary, isFormHidden, setSubmitDisabled, - setSummaryLoading, + showSummaryLoading, + showCheckoutView, showErrorModal, + showMissingOrderView, showNotFoundView, showSummaryError, updateCharges diff --git a/site/assets/js/pages/pricing.js b/site/assets/js/pages/pricing.js index 3c934aaa..ee2d7aa4 100644 --- a/site/assets/js/pages/pricing.js +++ b/site/assets/js/pages/pricing.js @@ -1,11 +1,11 @@ /* - * Copyright 2025, TeamDev. All rights reserved. + * Copyright 2026, TeamDev. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * - * http://www.apache.org/licenses/LICENSE-2.0 + * https://www.apache.org/licenses/LICENSE-2.0 * * Redistribution and use in source and/or binary forms, with or without * modification, must retain the above copyright notice and the following @@ -97,6 +97,12 @@ $( hideRedirect(); }); + window.addEventListener('pageshow', event => { + if (event.persisted) { + hideRedirect(); + } + }); + /** * Checks if all consent checkboxes are checked. * diff --git a/site/assets/scss/libs/country-select/_select2.scss b/site/assets/scss/libs/country-select/_select2.scss new file mode 100644 index 00000000..39a4547f --- /dev/null +++ b/site/assets/scss/libs/country-select/_select2.scss @@ -0,0 +1 @@ +.select2-container{box-sizing:border-box;display:inline-block;margin:0;position:relative;vertical-align:middle}.select2-container .select2-selection--single{box-sizing:border-box;cursor:pointer;display:block;height:28px;user-select:none;-webkit-user-select:none}.select2-container .select2-selection--single .select2-selection__rendered{display:block;padding-left:8px;padding-right:20px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.select2-container .select2-selection--single .select2-selection__clear{background-color:transparent;border:none;font-size:1em}.select2-container[dir="rtl"] .select2-selection--single .select2-selection__rendered{padding-right:8px;padding-left:20px}.select2-container .select2-selection--multiple{box-sizing:border-box;cursor:pointer;display:block;min-height:32px;user-select:none;-webkit-user-select:none}.select2-container .select2-selection--multiple .select2-selection__rendered{display:inline;list-style:none;padding:0}.select2-container .select2-selection--multiple .select2-selection__clear{background-color:transparent;border:none;font-size:1em}.select2-container .select2-search--inline .select2-search__field{box-sizing:border-box;border:none;font-size:100%;margin-top:5px;margin-left:5px;padding:0;max-width:100%;resize:none;height:18px;vertical-align:bottom;font-family:sans-serif;overflow:hidden;word-break:keep-all}.select2-container .select2-search--inline .select2-search__field::-webkit-search-cancel-button{-webkit-appearance:none}.select2-dropdown{background-color:white;border:1px solid #aaa;border-radius:4px;box-sizing:border-box;display:block;position:absolute;left:-100000px;width:100%;z-index:1051}.select2-results{display:block}.select2-results__options{list-style:none;margin:0;padding:0}.select2-results__option{padding:6px;user-select:none;-webkit-user-select:none}.select2-results__option--selectable{cursor:pointer}.select2-container--open .select2-dropdown{left:0}.select2-container--open .select2-dropdown--above{border-bottom:none;border-bottom-left-radius:0;border-bottom-right-radius:0}.select2-container--open .select2-dropdown--below{border-top:none;border-top-left-radius:0;border-top-right-radius:0}.select2-search--dropdown{display:block;padding:4px}.select2-search--dropdown .select2-search__field{padding:4px;width:100%;box-sizing:border-box}.select2-search--dropdown .select2-search__field::-webkit-search-cancel-button{-webkit-appearance:none}.select2-search--dropdown.select2-search--hide{display:none}.select2-close-mask{border:0;margin:0;padding:0;display:block;position:fixed;left:0;top:0;min-height:100%;min-width:100%;height:auto;width:auto;opacity:0;z-index:99;background-color:#fff;filter:alpha(opacity=0)}.select2-hidden-accessible{border:0 !important;clip:rect(0 0 0 0) !important;-webkit-clip-path:inset(50%) !important;clip-path:inset(50%) !important;height:1px !important;overflow:hidden !important;padding:0 !important;position:absolute !important;width:1px !important;white-space:nowrap !important}.select2-container--default .select2-selection--single{background-color:#fff;border:1px solid #aaa;border-radius:4px}.select2-container--default .select2-selection--single .select2-selection__rendered{color:#444;line-height:28px}.select2-container--default .select2-selection--single .select2-selection__clear{cursor:pointer;float:right;font-weight:bold;height:26px;margin-right:20px;padding-right:0px}.select2-container--default .select2-selection--single .select2-selection__placeholder{color:#999}.select2-container--default .select2-selection--single .select2-selection__arrow{height:26px;position:absolute;top:1px;right:1px;width:20px}.select2-container--default .select2-selection--single .select2-selection__arrow b{border-color:#888 transparent transparent transparent;border-style:solid;border-width:5px 4px 0 4px;height:0;left:50%;margin-left:-4px;margin-top:-2px;position:absolute;top:50%;width:0}.select2-container--default[dir="rtl"] .select2-selection--single .select2-selection__clear{float:left}.select2-container--default[dir="rtl"] .select2-selection--single .select2-selection__arrow{left:1px;right:auto}.select2-container--default.select2-container--disabled .select2-selection--single{background-color:#eee;cursor:default}.select2-container--default.select2-container--disabled .select2-selection--single .select2-selection__clear{display:none}.select2-container--default.select2-container--open .select2-selection--single .select2-selection__arrow b{border-color:transparent transparent #888 transparent;border-width:0 4px 5px 4px}.select2-container--default .select2-selection--multiple{background-color:white;border:1px solid #aaa;border-radius:4px;cursor:text;padding-bottom:5px;padding-right:5px;position:relative}.select2-container--default .select2-selection--multiple.select2-selection--clearable{padding-right:25px}.select2-container--default .select2-selection--multiple .select2-selection__clear{cursor:pointer;font-weight:bold;height:20px;margin-right:10px;margin-top:5px;position:absolute;right:0;padding:1px}.select2-container--default .select2-selection--multiple .select2-selection__choice{background-color:#e4e4e4;border:1px solid #aaa;border-radius:4px;box-sizing:border-box;display:inline-block;margin-left:5px;margin-top:5px;padding:0;padding-left:20px;position:relative;max-width:100%;overflow:hidden;text-overflow:ellipsis;vertical-align:bottom;white-space:nowrap}.select2-container--default .select2-selection--multiple .select2-selection__choice__display{cursor:default;padding-left:2px;padding-right:5px}.select2-container--default .select2-selection--multiple .select2-selection__choice__remove{background-color:transparent;border:none;border-right:1px solid #aaa;border-top-left-radius:4px;border-bottom-left-radius:4px;color:#999;cursor:pointer;font-size:1em;font-weight:bold;padding:0 4px;position:absolute;left:0;top:0}.select2-container--default .select2-selection--multiple .select2-selection__choice__remove:hover,.select2-container--default .select2-selection--multiple .select2-selection__choice__remove:focus{background-color:#f1f1f1;color:#333;outline:none}.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-selection__choice{margin-left:5px;margin-right:auto}.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-selection__choice__display{padding-left:5px;padding-right:2px}.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-selection__choice__remove{border-left:1px solid #aaa;border-right:none;border-top-left-radius:0;border-bottom-left-radius:0;border-top-right-radius:4px;border-bottom-right-radius:4px}.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-selection__clear{float:left;margin-left:10px;margin-right:auto}.select2-container--default.select2-container--focus .select2-selection--multiple{border:solid black 1px;outline:0}.select2-container--default.select2-container--disabled .select2-selection--multiple{background-color:#eee;cursor:default}.select2-container--default.select2-container--disabled .select2-selection__choice__remove{display:none}.select2-container--default.select2-container--open.select2-container--above .select2-selection--single,.select2-container--default.select2-container--open.select2-container--above .select2-selection--multiple{border-top-left-radius:0;border-top-right-radius:0}.select2-container--default.select2-container--open.select2-container--below .select2-selection--single,.select2-container--default.select2-container--open.select2-container--below .select2-selection--multiple{border-bottom-left-radius:0;border-bottom-right-radius:0}.select2-container--default .select2-search--dropdown .select2-search__field{border:1px solid #aaa}.select2-container--default .select2-search--inline .select2-search__field{background:transparent;border:none;outline:0;box-shadow:none;-webkit-appearance:textfield}.select2-container--default .select2-results>.select2-results__options{max-height:200px;overflow-y:auto}.select2-container--default .select2-results__option .select2-results__option{padding-left:1em}.select2-container--default .select2-results__option .select2-results__option .select2-results__group{padding-left:0}.select2-container--default .select2-results__option .select2-results__option .select2-results__option{margin-left:-1em;padding-left:2em}.select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option{margin-left:-2em;padding-left:3em}.select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option{margin-left:-3em;padding-left:4em}.select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option{margin-left:-4em;padding-left:5em}.select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option{margin-left:-5em;padding-left:6em}.select2-container--default .select2-results__option--group{padding:0}.select2-container--default .select2-results__option--disabled{color:#999}.select2-container--default .select2-results__option--selected{background-color:#ddd}.select2-container--default .select2-results__option--highlighted.select2-results__option--selectable{background-color:#5897fb;color:white}.select2-container--default .select2-results__group{cursor:default;display:block;padding:6px}.select2-container--classic .select2-selection--single{background-color:#f7f7f7;border:1px solid #aaa;border-radius:4px;outline:0;background-image:-webkit-linear-gradient(top, #fff 50%, #eee 100%);background-image:-o-linear-gradient(top, #fff 50%, #eee 100%);background-image:linear-gradient(to bottom, #fff 50%, #eee 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFFFFFFF', endColorstr='#FFEEEEEE', GradientType=0)}.select2-container--classic .select2-selection--single:focus{border:1px solid #5897fb}.select2-container--classic .select2-selection--single .select2-selection__rendered{color:#444;line-height:28px}.select2-container--classic .select2-selection--single .select2-selection__clear{cursor:pointer;float:right;font-weight:bold;height:26px;margin-right:20px}.select2-container--classic .select2-selection--single .select2-selection__placeholder{color:#999}.select2-container--classic .select2-selection--single .select2-selection__arrow{background-color:#ddd;border:none;border-left:1px solid #aaa;border-top-right-radius:4px;border-bottom-right-radius:4px;height:26px;position:absolute;top:1px;right:1px;width:20px;background-image:-webkit-linear-gradient(top, #eee 50%, #ccc 100%);background-image:-o-linear-gradient(top, #eee 50%, #ccc 100%);background-image:linear-gradient(to bottom, #eee 50%, #ccc 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFEEEEEE', endColorstr='#FFCCCCCC', GradientType=0)}.select2-container--classic .select2-selection--single .select2-selection__arrow b{border-color:#888 transparent transparent transparent;border-style:solid;border-width:5px 4px 0 4px;height:0;left:50%;margin-left:-4px;margin-top:-2px;position:absolute;top:50%;width:0}.select2-container--classic[dir="rtl"] .select2-selection--single .select2-selection__clear{float:left}.select2-container--classic[dir="rtl"] .select2-selection--single .select2-selection__arrow{border:none;border-right:1px solid #aaa;border-radius:0;border-top-left-radius:4px;border-bottom-left-radius:4px;left:1px;right:auto}.select2-container--classic.select2-container--open .select2-selection--single{border:1px solid #5897fb}.select2-container--classic.select2-container--open .select2-selection--single .select2-selection__arrow{background:transparent;border:none}.select2-container--classic.select2-container--open .select2-selection--single .select2-selection__arrow b{border-color:transparent transparent #888 transparent;border-width:0 4px 5px 4px}.select2-container--classic.select2-container--open.select2-container--above .select2-selection--single{border-top:none;border-top-left-radius:0;border-top-right-radius:0;background-image:-webkit-linear-gradient(top, #fff 0%, #eee 50%);background-image:-o-linear-gradient(top, #fff 0%, #eee 50%);background-image:linear-gradient(to bottom, #fff 0%, #eee 50%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFFFFFFF', endColorstr='#FFEEEEEE', GradientType=0)}.select2-container--classic.select2-container--open.select2-container--below .select2-selection--single{border-bottom:none;border-bottom-left-radius:0;border-bottom-right-radius:0;background-image:-webkit-linear-gradient(top, #eee 50%, #fff 100%);background-image:-o-linear-gradient(top, #eee 50%, #fff 100%);background-image:linear-gradient(to bottom, #eee 50%, #fff 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFEEEEEE', endColorstr='#FFFFFFFF', GradientType=0)}.select2-container--classic .select2-selection--multiple{background-color:white;border:1px solid #aaa;border-radius:4px;cursor:text;outline:0;padding-bottom:5px;padding-right:5px}.select2-container--classic .select2-selection--multiple:focus{border:1px solid #5897fb}.select2-container--classic .select2-selection--multiple .select2-selection__clear{display:none}.select2-container--classic .select2-selection--multiple .select2-selection__choice{background-color:#e4e4e4;border:1px solid #aaa;border-radius:4px;display:inline-block;margin-left:5px;margin-top:5px;padding:0}.select2-container--classic .select2-selection--multiple .select2-selection__choice__display{cursor:default;padding-left:2px;padding-right:5px}.select2-container--classic .select2-selection--multiple .select2-selection__choice__remove{background-color:transparent;border:none;border-top-left-radius:4px;border-bottom-left-radius:4px;color:#888;cursor:pointer;font-size:1em;font-weight:bold;padding:0 4px}.select2-container--classic .select2-selection--multiple .select2-selection__choice__remove:hover{color:#555;outline:none}.select2-container--classic[dir="rtl"] .select2-selection--multiple .select2-selection__choice{margin-left:5px;margin-right:auto}.select2-container--classic[dir="rtl"] .select2-selection--multiple .select2-selection__choice__display{padding-left:5px;padding-right:2px}.select2-container--classic[dir="rtl"] .select2-selection--multiple .select2-selection__choice__remove{border-top-left-radius:0;border-bottom-left-radius:0;border-top-right-radius:4px;border-bottom-right-radius:4px}.select2-container--classic.select2-container--open .select2-selection--multiple{border:1px solid #5897fb}.select2-container--classic.select2-container--open.select2-container--above .select2-selection--multiple{border-top:none;border-top-left-radius:0;border-top-right-radius:0}.select2-container--classic.select2-container--open.select2-container--below .select2-selection--multiple{border-bottom:none;border-bottom-left-radius:0;border-bottom-right-radius:0}.select2-container--classic .select2-search--dropdown .select2-search__field{border:1px solid #aaa;outline:0}.select2-container--classic .select2-search--inline .select2-search__field{outline:0;box-shadow:none}.select2-container--classic .select2-dropdown{background-color:#fff;border:1px solid transparent}.select2-container--classic .select2-dropdown--above{border-bottom:none}.select2-container--classic .select2-dropdown--below{border-top:none}.select2-container--classic .select2-results>.select2-results__options{max-height:200px;overflow-y:auto}.select2-container--classic .select2-results__option--group{padding:0}.select2-container--classic .select2-results__option--disabled{color:grey}.select2-container--classic .select2-results__option--highlighted.select2-results__option--selectable{background-color:#3875d7;color:#fff}.select2-container--classic .select2-results__group{cursor:default;display:block;padding:6px}.select2-container--classic.select2-container--open .select2-dropdown{border-color:#5897fb} diff --git a/site/assets/scss/libs/intl-tel-input/_intl-tel-input.scss b/site/assets/scss/libs/intl-tel-input/_intl-tel-input.scss new file mode 100644 index 00000000..1684ca36 --- /dev/null +++ b/site/assets/scss/libs/intl-tel-input/_intl-tel-input.scss @@ -0,0 +1 @@ +.iti{position:relative;display:inline-block}.iti *{box-sizing:border-box}.iti__hide{display:none}.iti__v-hide{visibility:hidden}.iti input,.iti input[type=tel],.iti input[type=text]{position:relative;z-index:0;margin-top:0!important;margin-bottom:0!important;padding-right:36px;margin-right:0}.iti__flag-container{position:absolute;top:0;bottom:0;right:0;padding:1px}.iti__selected-flag{z-index:1;position:relative;display:flex;align-items:center;height:100%;padding:0 6px 0 8px}.iti__arrow{margin-left:6px;width:0;height:0;border-left:3px solid transparent;border-right:3px solid transparent;border-top:4px solid #555}[dir=rtl] .iti__arrow{margin-right:6px;margin-left:0}.iti__arrow--up{border-top:none;border-bottom:4px solid #555}.iti__country-list{position:absolute;z-index:2;list-style:none;padding:0;margin:0 0 0 -1px;box-shadow:1px 1px 4px rgba(0,0,0,.2);background-color:#fff;border:1px solid #ccc;white-space:nowrap;max-height:200px;overflow-y:scroll;-webkit-overflow-scrolling:touch}.iti__country-list--dropup{bottom:100%;margin-bottom:-1px}@media (max-width:500px){.iti__country-list{white-space:normal}}.iti__flag-box{display:inline-block;width:20px}.iti__divider{padding-bottom:5px;margin-bottom:5px;border-bottom:1px solid #ccc}.iti__country{display:flex;align-items:center;padding:5px 10px;outline:0}.iti__dial-code{color:#999}.iti__country.iti__highlight{background-color:rgba(0,0,0,.05)}.iti__country-name,.iti__flag-box{margin-right:6px}[dir=rtl] .iti__country-name,[dir=rtl] .iti__flag-box{margin-right:0;margin-left:6px}.iti--allow-dropdown input,.iti--allow-dropdown input[type=tel],.iti--allow-dropdown input[type=text],.iti--separate-dial-code input,.iti--separate-dial-code input[type=tel],.iti--separate-dial-code input[type=text]{padding-right:6px;padding-left:52px;margin-left:0}[dir=rtl] .iti--allow-dropdown input,[dir=rtl] .iti--allow-dropdown input[type=tel],[dir=rtl] .iti--allow-dropdown input[type=text],[dir=rtl] .iti--separate-dial-code input,[dir=rtl] .iti--separate-dial-code input[type=tel],[dir=rtl] .iti--separate-dial-code input[type=text]{padding-right:52px;padding-left:6px;margin-right:0}.iti--allow-dropdown .iti__flag-container,.iti--separate-dial-code .iti__flag-container{right:auto;left:0}[dir=rtl] .iti--allow-dropdown .iti__flag-container,[dir=rtl] .iti--separate-dial-code .iti__flag-container{right:0;left:auto}.iti--allow-dropdown .iti__flag-container:hover{cursor:pointer}.iti--allow-dropdown .iti__flag-container:hover .iti__selected-flag{background-color:rgba(0,0,0,.05)}.iti--allow-dropdown input[disabled]+.iti__flag-container:hover,.iti--allow-dropdown input[readonly]+.iti__flag-container:hover{cursor:default}.iti--allow-dropdown input[disabled]+.iti__flag-container:hover .iti__selected-flag,.iti--allow-dropdown input[readonly]+.iti__flag-container:hover .iti__selected-flag{background-color:transparent}.iti--separate-dial-code .iti__selected-flag{background-color:rgba(0,0,0,.05)}.iti--separate-dial-code.iti--show-flags .iti__selected-dial-code{margin-left:6px}[dir=rtl] .iti--separate-dial-code.iti--show-flags .iti__selected-dial-code{margin-left:0;margin-right:6px}.iti--container{position:absolute;top:-1000px;left:-1000px;z-index:1060;padding:1px}.iti--container:hover{cursor:pointer}.iti-mobile .iti--container{top:30px;bottom:30px;left:30px;right:30px;position:fixed}.iti-mobile .iti__country-list{max-height:100%;width:100%}.iti-mobile .iti__country{padding:10px 10px;line-height:1.5em}.iti__flag{width:20px}.iti__flag.iti__be{width:18px}.iti__flag.iti__ch{width:15px}.iti__flag.iti__mc{width:19px}.iti__flag.iti__ne{width:18px}.iti__flag.iti__np{width:13px}.iti__flag.iti__va{width:15px}@media (-webkit-min-device-pixel-ratio:2),(min-resolution:192dpi){.iti__flag{background-size:5762px 15px}}.iti__flag.iti__ac{height:10px;background-position:0 0}.iti__flag.iti__ad{height:14px;background-position:-22px 0}.iti__flag.iti__ae{height:10px;background-position:-44px 0}.iti__flag.iti__af{height:14px;background-position:-66px 0}.iti__flag.iti__ag{height:14px;background-position:-88px 0}.iti__flag.iti__ai{height:10px;background-position:-110px 0}.iti__flag.iti__al{height:15px;background-position:-132px 0}.iti__flag.iti__am{height:10px;background-position:-154px 0}.iti__flag.iti__ao{height:14px;background-position:-176px 0}.iti__flag.iti__aq{height:14px;background-position:-198px 0}.iti__flag.iti__ar{height:13px;background-position:-220px 0}.iti__flag.iti__as{height:10px;background-position:-242px 0}.iti__flag.iti__at{height:14px;background-position:-264px 0}.iti__flag.iti__au{height:10px;background-position:-286px 0}.iti__flag.iti__aw{height:14px;background-position:-308px 0}.iti__flag.iti__ax{height:13px;background-position:-330px 0}.iti__flag.iti__az{height:10px;background-position:-352px 0}.iti__flag.iti__ba{height:10px;background-position:-374px 0}.iti__flag.iti__bb{height:14px;background-position:-396px 0}.iti__flag.iti__bd{height:12px;background-position:-418px 0}.iti__flag.iti__be{height:15px;background-position:-440px 0}.iti__flag.iti__bf{height:14px;background-position:-460px 0}.iti__flag.iti__bg{height:12px;background-position:-482px 0}.iti__flag.iti__bh{height:12px;background-position:-504px 0}.iti__flag.iti__bi{height:12px;background-position:-526px 0}.iti__flag.iti__bj{height:14px;background-position:-548px 0}.iti__flag.iti__bl{height:14px;background-position:-570px 0}.iti__flag.iti__bm{height:10px;background-position:-592px 0}.iti__flag.iti__bn{height:10px;background-position:-614px 0}.iti__flag.iti__bo{height:14px;background-position:-636px 0}.iti__flag.iti__bq{height:14px;background-position:-658px 0}.iti__flag.iti__br{height:14px;background-position:-680px 0}.iti__flag.iti__bs{height:10px;background-position:-702px 0}.iti__flag.iti__bt{height:14px;background-position:-724px 0}.iti__flag.iti__bv{height:15px;background-position:-746px 0}.iti__flag.iti__bw{height:14px;background-position:-768px 0}.iti__flag.iti__by{height:10px;background-position:-790px 0}.iti__flag.iti__bz{height:12px;background-position:-812px 0}.iti__flag.iti__ca{height:10px;background-position:-834px 0}.iti__flag.iti__cc{height:10px;background-position:-856px 0}.iti__flag.iti__cd{height:15px;background-position:-878px 0}.iti__flag.iti__cf{height:14px;background-position:-900px 0}.iti__flag.iti__cg{height:14px;background-position:-922px 0}.iti__flag.iti__ch{height:15px;background-position:-944px 0}.iti__flag.iti__ci{height:14px;background-position:-961px 0}.iti__flag.iti__ck{height:10px;background-position:-983px 0}.iti__flag.iti__cl{height:14px;background-position:-1005px 0}.iti__flag.iti__cm{height:14px;background-position:-1027px 0}.iti__flag.iti__cn{height:14px;background-position:-1049px 0}.iti__flag.iti__co{height:14px;background-position:-1071px 0}.iti__flag.iti__cp{height:14px;background-position:-1093px 0}.iti__flag.iti__cq{height:12px;background-position:-1115px 0}.iti__flag.iti__cr{height:12px;background-position:-1137px 0}.iti__flag.iti__cu{height:10px;background-position:-1159px 0}.iti__flag.iti__cv{height:12px;background-position:-1181px 0}.iti__flag.iti__cw{height:14px;background-position:-1203px 0}.iti__flag.iti__cx{height:10px;background-position:-1225px 0}.iti__flag.iti__cy{height:14px;background-position:-1247px 0}.iti__flag.iti__cz{height:14px;background-position:-1269px 0}.iti__flag.iti__de{height:12px;background-position:-1291px 0}.iti__flag.iti__dg{height:10px;background-position:-1313px 0}.iti__flag.iti__dj{height:14px;background-position:-1335px 0}.iti__flag.iti__dk{height:15px;background-position:-1357px 0}.iti__flag.iti__dm{height:10px;background-position:-1379px 0}.iti__flag.iti__do{height:14px;background-position:-1401px 0}.iti__flag.iti__dz{height:14px;background-position:-1423px 0}.iti__flag.iti__ea{height:14px;background-position:-1445px 0}.iti__flag.iti__ec{height:14px;background-position:-1467px 0}.iti__flag.iti__ee{height:13px;background-position:-1489px 0}.iti__flag.iti__eg{height:14px;background-position:-1511px 0}.iti__flag.iti__eh{height:10px;background-position:-1533px 0}.iti__flag.iti__er{height:10px;background-position:-1555px 0}.iti__flag.iti__es{height:14px;background-position:-1577px 0}.iti__flag.iti__et{height:10px;background-position:-1599px 0}.iti__flag.iti__eu{height:14px;background-position:-1621px 0}.iti__flag.iti__ez{height:14px;background-position:-1643px 0}.iti__flag.iti__fi{height:12px;background-position:-1665px 0}.iti__flag.iti__fj{height:10px;background-position:-1687px 0}.iti__flag.iti__fk{height:10px;background-position:-1709px 0}.iti__flag.iti__fm{height:11px;background-position:-1731px 0}.iti__flag.iti__fo{height:15px;background-position:-1753px 0}.iti__flag.iti__fr{height:14px;background-position:-1775px 0}.iti__flag.iti__fx{height:14px;background-position:-1797px 0}.iti__flag.iti__ga{height:15px;background-position:-1819px 0}.iti__flag.iti__gb{height:10px;background-position:-1841px 0}.iti__flag.iti__gd{height:12px;background-position:-1863px 0}.iti__flag.iti__ge{height:14px;background-position:-1885px 0}.iti__flag.iti__gf{height:14px;background-position:-1907px 0}.iti__flag.iti__gg{height:14px;background-position:-1929px 0}.iti__flag.iti__gh{height:14px;background-position:-1951px 0}.iti__flag.iti__gi{height:10px;background-position:-1973px 0}.iti__flag.iti__gl{height:14px;background-position:-1995px 0}.iti__flag.iti__gm{height:14px;background-position:-2017px 0}.iti__flag.iti__gn{height:14px;background-position:-2039px 0}.iti__flag.iti__gp{height:14px;background-position:-2061px 0}.iti__flag.iti__gq{height:14px;background-position:-2083px 0}.iti__flag.iti__gr{height:14px;background-position:-2105px 0}.iti__flag.iti__gs{height:10px;background-position:-2127px 0}.iti__flag.iti__gt{height:13px;background-position:-2149px 0}.iti__flag.iti__gu{height:11px;background-position:-2171px 0}.iti__flag.iti__gw{height:10px;background-position:-2193px 0}.iti__flag.iti__gy{height:12px;background-position:-2215px 0}.iti__flag.iti__hk{height:14px;background-position:-2237px 0}.iti__flag.iti__hm{height:10px;background-position:-2259px 0}.iti__flag.iti__hn{height:10px;background-position:-2281px 0}.iti__flag.iti__hr{height:10px;background-position:-2303px 0}.iti__flag.iti__ht{height:12px;background-position:-2325px 0}.iti__flag.iti__hu{height:10px;background-position:-2347px 0}.iti__flag.iti__ic{height:14px;background-position:-2369px 0}.iti__flag.iti__id{height:14px;background-position:-2391px 0}.iti__flag.iti__ie{height:10px;background-position:-2413px 0}.iti__flag.iti__il{height:15px;background-position:-2435px 0}.iti__flag.iti__im{height:10px;background-position:-2457px 0}.iti__flag.iti__in{height:14px;background-position:-2479px 0}.iti__flag.iti__io{height:10px;background-position:-2501px 0}.iti__flag.iti__iq{height:14px;background-position:-2523px 0}.iti__flag.iti__ir{height:12px;background-position:-2545px 0}.iti__flag.iti__is{height:15px;background-position:-2567px 0}.iti__flag.iti__it{height:14px;background-position:-2589px 0}.iti__flag.iti__je{height:12px;background-position:-2611px 0}.iti__flag.iti__jm{height:10px;background-position:-2633px 0}.iti__flag.iti__jo{height:10px;background-position:-2655px 0}.iti__flag.iti__jp{height:14px;background-position:-2677px 0}.iti__flag.iti__ke{height:14px;background-position:-2699px 0}.iti__flag.iti__kg{height:12px;background-position:-2721px 0}.iti__flag.iti__kh{height:13px;background-position:-2743px 0}.iti__flag.iti__ki{height:10px;background-position:-2765px 0}.iti__flag.iti__km{height:12px;background-position:-2787px 0}.iti__flag.iti__kn{height:14px;background-position:-2809px 0}.iti__flag.iti__kp{height:10px;background-position:-2831px 0}.iti__flag.iti__kr{height:14px;background-position:-2853px 0}.iti__flag.iti__kw{height:10px;background-position:-2875px 0}.iti__flag.iti__ky{height:10px;background-position:-2897px 0}.iti__flag.iti__kz{height:10px;background-position:-2919px 0}.iti__flag.iti__la{height:14px;background-position:-2941px 0}.iti__flag.iti__lb{height:14px;background-position:-2963px 0}.iti__flag.iti__lc{height:10px;background-position:-2985px 0}.iti__flag.iti__li{height:12px;background-position:-3007px 0}.iti__flag.iti__lk{height:10px;background-position:-3029px 0}.iti__flag.iti__lr{height:11px;background-position:-3051px 0}.iti__flag.iti__ls{height:14px;background-position:-3073px 0}.iti__flag.iti__lt{height:12px;background-position:-3095px 0}.iti__flag.iti__lu{height:12px;background-position:-3117px 0}.iti__flag.iti__lv{height:10px;background-position:-3139px 0}.iti__flag.iti__ly{height:10px;background-position:-3161px 0}.iti__flag.iti__ma{height:14px;background-position:-3183px 0}.iti__flag.iti__mc{height:15px;background-position:-3205px 0}.iti__flag.iti__md{height:10px;background-position:-3226px 0}.iti__flag.iti__me{height:10px;background-position:-3248px 0}.iti__flag.iti__mf{height:14px;background-position:-3270px 0}.iti__flag.iti__mg{height:14px;background-position:-3292px 0}.iti__flag.iti__mh{height:11px;background-position:-3314px 0}.iti__flag.iti__mk{height:10px;background-position:-3336px 0}.iti__flag.iti__ml{height:14px;background-position:-3358px 0}.iti__flag.iti__mm{height:14px;background-position:-3380px 0}.iti__flag.iti__mn{height:10px;background-position:-3402px 0}.iti__flag.iti__mo{height:14px;background-position:-3424px 0}.iti__flag.iti__mp{height:10px;background-position:-3446px 0}.iti__flag.iti__mq{height:14px;background-position:-3468px 0}.iti__flag.iti__mr{height:14px;background-position:-3490px 0}.iti__flag.iti__ms{height:10px;background-position:-3512px 0}.iti__flag.iti__mt{height:14px;background-position:-3534px 0}.iti__flag.iti__mu{height:14px;background-position:-3556px 0}.iti__flag.iti__mv{height:14px;background-position:-3578px 0}.iti__flag.iti__mw{height:14px;background-position:-3600px 0}.iti__flag.iti__mx{height:12px;background-position:-3622px 0}.iti__flag.iti__my{height:10px;background-position:-3644px 0}.iti__flag.iti__mz{height:14px;background-position:-3666px 0}.iti__flag.iti__na{height:14px;background-position:-3688px 0}.iti__flag.iti__nc{height:10px;background-position:-3710px 0}.iti__flag.iti__ne{height:15px;background-position:-3732px 0}.iti__flag.iti__nf{height:10px;background-position:-3752px 0}.iti__flag.iti__ng{height:10px;background-position:-3774px 0}.iti__flag.iti__ni{height:12px;background-position:-3796px 0}.iti__flag.iti__nl{height:14px;background-position:-3818px 0}.iti__flag.iti__no{height:15px;background-position:-3840px 0}.iti__flag.iti__np{height:15px;background-position:-3862px 0}.iti__flag.iti__nr{height:10px;background-position:-3877px 0}.iti__flag.iti__nu{height:10px;background-position:-3899px 0}.iti__flag.iti__nz{height:10px;background-position:-3921px 0}.iti__flag.iti__om{height:10px;background-position:-3943px 0}.iti__flag.iti__pa{height:14px;background-position:-3965px 0}.iti__flag.iti__pe{height:14px;background-position:-3987px 0}.iti__flag.iti__pf{height:14px;background-position:-4009px 0}.iti__flag.iti__pg{height:15px;background-position:-4031px 0}.iti__flag.iti__ph{height:10px;background-position:-4053px 0}.iti__flag.iti__pk{height:14px;background-position:-4075px 0}.iti__flag.iti__pl{height:13px;background-position:-4097px 0}.iti__flag.iti__pm{height:14px;background-position:-4119px 0}.iti__flag.iti__pn{height:10px;background-position:-4141px 0}.iti__flag.iti__pr{height:14px;background-position:-4163px 0}.iti__flag.iti__ps{height:10px;background-position:-4185px 0}.iti__flag.iti__pt{height:14px;background-position:-4207px 0}.iti__flag.iti__pw{height:13px;background-position:-4229px 0}.iti__flag.iti__py{height:11px;background-position:-4251px 0}.iti__flag.iti__qa{height:8px;background-position:-4273px 0}.iti__flag.iti__re{height:14px;background-position:-4295px 0}.iti__flag.iti__ro{height:14px;background-position:-4317px 0}.iti__flag.iti__rs{height:14px;background-position:-4339px 0}.iti__flag.iti__ru{height:14px;background-position:-4361px 0}.iti__flag.iti__rw{height:14px;background-position:-4383px 0}.iti__flag.iti__sa{height:14px;background-position:-4405px 0}.iti__flag.iti__sb{height:10px;background-position:-4427px 0}.iti__flag.iti__sc{height:10px;background-position:-4449px 0}.iti__flag.iti__sd{height:10px;background-position:-4471px 0}.iti__flag.iti__se{height:13px;background-position:-4493px 0}.iti__flag.iti__sg{height:14px;background-position:-4515px 0}.iti__flag.iti__sh{height:10px;background-position:-4537px 0}.iti__flag.iti__si{height:10px;background-position:-4559px 0}.iti__flag.iti__sj{height:15px;background-position:-4581px 0}.iti__flag.iti__sk{height:14px;background-position:-4603px 0}.iti__flag.iti__sl{height:14px;background-position:-4625px 0}.iti__flag.iti__sm{height:15px;background-position:-4647px 0}.iti__flag.iti__sn{height:14px;background-position:-4669px 0}.iti__flag.iti__so{height:14px;background-position:-4691px 0}.iti__flag.iti__sr{height:14px;background-position:-4713px 0}.iti__flag.iti__ss{height:10px;background-position:-4735px 0}.iti__flag.iti__st{height:10px;background-position:-4757px 0}.iti__flag.iti__su{height:10px;background-position:-4779px 0}.iti__flag.iti__sv{height:12px;background-position:-4801px 0}.iti__flag.iti__sx{height:14px;background-position:-4823px 0}.iti__flag.iti__sy{height:14px;background-position:-4845px 0}.iti__flag.iti__sz{height:14px;background-position:-4867px 0}.iti__flag.iti__ta{height:10px;background-position:-4889px 0}.iti__flag.iti__tc{height:10px;background-position:-4911px 0}.iti__flag.iti__td{height:14px;background-position:-4933px 0}.iti__flag.iti__tf{height:14px;background-position:-4955px 0}.iti__flag.iti__tg{height:13px;background-position:-4977px 0}.iti__flag.iti__th{height:14px;background-position:-4999px 0}.iti__flag.iti__tj{height:10px;background-position:-5021px 0}.iti__flag.iti__tk{height:10px;background-position:-5043px 0}.iti__flag.iti__tl{height:10px;background-position:-5065px 0}.iti__flag.iti__tm{height:14px;background-position:-5087px 0}.iti__flag.iti__tn{height:14px;background-position:-5109px 0}.iti__flag.iti__to{height:10px;background-position:-5131px 0}.iti__flag.iti__tr{height:14px;background-position:-5153px 0}.iti__flag.iti__tt{height:12px;background-position:-5175px 0}.iti__flag.iti__tv{height:10px;background-position:-5197px 0}.iti__flag.iti__tw{height:14px;background-position:-5219px 0}.iti__flag.iti__tz{height:14px;background-position:-5241px 0}.iti__flag.iti__ua{height:14px;background-position:-5263px 0}.iti__flag.iti__ug{height:14px;background-position:-5285px 0}.iti__flag.iti__uk{height:10px;background-position:-5307px 0}.iti__flag.iti__um{height:11px;background-position:-5329px 0}.iti__flag.iti__un{height:14px;background-position:-5351px 0}.iti__flag.iti__us{height:11px;background-position:-5373px 0}.iti__flag.iti__uy{height:14px;background-position:-5395px 0}.iti__flag.iti__uz{height:10px;background-position:-5417px 0}.iti__flag.iti__va{height:15px;background-position:-5439px 0}.iti__flag.iti__vc{height:14px;background-position:-5456px 0}.iti__flag.iti__ve{height:14px;background-position:-5478px 0}.iti__flag.iti__vg{height:10px;background-position:-5500px 0}.iti__flag.iti__vi{height:14px;background-position:-5522px 0}.iti__flag.iti__vn{height:14px;background-position:-5544px 0}.iti__flag.iti__vu{height:12px;background-position:-5566px 0}.iti__flag.iti__wf{height:14px;background-position:-5588px 0}.iti__flag.iti__ws{height:10px;background-position:-5610px 0}.iti__flag.iti__xk{height:15px;background-position:-5632px 0}.iti__flag.iti__ye{height:14px;background-position:-5654px 0}.iti__flag.iti__yt{height:14px;background-position:-5676px 0}.iti__flag.iti__za{height:14px;background-position:-5698px 0}.iti__flag.iti__zm{height:14px;background-position:-5720px 0}.iti__flag.iti__zw{height:10px;background-position:-5742px 0}.iti__flag{height:15px;box-shadow:0 0 1px 0 #888;background-image:url("../../images/flags/flags.png?1");background-repeat:no-repeat;background-color:#dbdbdb;background-position:20px 0}@media (-webkit-min-device-pixel-ratio:2),(min-resolution:192dpi){.iti__flag{background-image:url("../../images/flags/flags@2x.png?1")}}.iti__flag.iti__np{background-color:transparent} \ No newline at end of file diff --git a/site/assets/scss/modules/_buttons.scss b/site/assets/scss/modules/_buttons.scss index 0a39913c..cafcf833 100644 --- a/site/assets/scss/modules/_buttons.scss +++ b/site/assets/scss/modules/_buttons.scss @@ -1,11 +1,11 @@ /*! - * Copyright 2025, TeamDev. All rights reserved. + * Copyright 2026, TeamDev. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * - * http://www.apache.org/licenses/LICENSE-2.0 + * https://www.apache.org/licenses/LICENSE-2.0 * * Redistribution and use in source and/or binary forms, with or without * modification, must retain the above copyright notice and the following @@ -25,24 +25,28 @@ */ $btn-font-size: 14px; +$btn-border-radius: $border-radius-m; +$btn-primary-color: $main-brand-color; +$btn-primary-hover-color: #5fabdd; .btn { display: inline-block; + padding: 14px 32px; font-size: $btn-font-size; font-weight: bold; text-align: center; - padding: 14px 32px; - border-radius: $border-radius-m; text-transform: uppercase; + letter-spacing: 1px; + border-radius: $btn-border-radius; cursor: pointer; transition: all .34s ease-in-out; &:hover { - outline: none; text-decoration: none; } - &:focus { + &:focus, + &:focus-visible { text-decoration: none; box-shadow: none; outline: 1px dotted; @@ -61,6 +65,46 @@ $btn-font-size: 14px; cursor: pointer; } +.btn-primary-blue { + --bs-btn-color: #{$white}; + --bs-btn-bg: #{$btn-primary-color}; + --bs-btn-border-color: #{$btn-primary-color}; + + --bs-btn-hover-color: #{$white}; + --bs-btn-hover-bg: #{$btn-primary-hover-color}; + --bs-btn-hover-border-color: #{$btn-primary-hover-color}; + + --bs-btn-active-color: #{$white}; + --bs-btn-active-bg: #{$btn-primary-color}; + --bs-btn-active-border-color: #{$btn-primary-color}; + + --bs-btn-disabled-color: #{$white}; + --bs-btn-disabled-bg: #{$btn-primary-color}; + --bs-btn-disabled-border-color: #{$btn-primary-color}; + + border-width: 2px; +} + +.btn-bordered-blue { + --bs-btn-color: #{$btn-primary-color}; + --bs-btn-bg: transparent; + --bs-btn-border-color: #{$btn-primary-color}; + + --bs-btn-hover-color: #{$btn-primary-color}; + --bs-btn-hover-bg: rgba(26, 150, 222, .12); + --bs-btn-hover-border-color: #{$btn-primary-color}; + + --bs-btn-active-color: #{$btn-primary-color}; + --bs-btn-active-bg: transparent; + --bs-btn-active-border-color: #{$btn-primary-color}; + + --bs-btn-disabled-color: #{$gray-600}; + --bs-btn-disabled-bg: transparent; + --bs-btn-disabled-border-color: #{$gray-600}; + + border-width: 2px; +} + .icon-caret { background: url('img/icons/caret.svg') no-repeat center/cover; height: 20px; @@ -73,40 +117,6 @@ $btn-font-size: 14px; } } -.external-link-icon { - background: url('img/icons/external-link.svg') no-repeat center/cover; - width: 15px; - height: 15px; -} - -.btn-doc-call-white { - color: $white; - border: 1px solid $white; - background-color: transparent; - border-radius: $border-radius-s; - padding: 12px 56px; - - &:hover, &:focus { - color: white; - background-color: rgba($link-blue-color, .2); - border: 1px solid $link-blue-color; - box-shadow: 0 5px 20px 0 rgba($footer-color, .28); - } -} - -.btn-bordered-blue { - letter-spacing: 1px; - color: $main-brand-color; - border: 2px solid $main-brand-color; - - &:hover, - &:focus { - background-color: rgba($main-brand-color, .12); - color: $main-brand-color; - border: 2px solid $main-brand-color; - } -} - .link-back, .link-next { &:hover, diff --git a/site/assets/scss/modules/_call-to-action.scss b/site/assets/scss/modules/_call-to-action.scss index 3292efc9..229e5a95 100644 --- a/site/assets/scss/modules/_call-to-action.scss +++ b/site/assets/scss/modules/_call-to-action.scss @@ -52,7 +52,7 @@ background-color: transparent; text-transform: uppercase; border: 1px solid $white; - border-radius: $border-radius-s; + border-radius: $border-radius-m; padding: 12px 56px; margin: 40px auto 0; diff --git a/site/assets/scss/modules/_forms.scss b/site/assets/scss/modules/_forms.scss index f140799a..6d78882e 100644 --- a/site/assets/scss/modules/_forms.scss +++ b/site/assets/scss/modules/_forms.scss @@ -5,7 +5,7 @@ * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * - * http://www.apache.org/licenses/LICENSE-2.0 + * https://www.apache.org/licenses/LICENSE-2.0 * * Redistribution and use in source and/or binary forms, with or without * modification, must retain the above copyright notice and the following @@ -24,13 +24,13 @@ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ -$form-error-color: #d93025; -$form-success-color: #2e7d32; +$form-error-color: $red; +$form-success-color: $green; .form-title { padding-top: 0; margin-bottom: 24px; - color: $black; + color: $text-color; font-size: 24px !important; font-weight: 700; line-height: 1.2; @@ -49,19 +49,15 @@ $form-success-color: #2e7d32; .form-label { display: block; margin-bottom: 6px; - color: $black; + color: $text-color; font-size: 14px; font-weight: 500; line-height: 1.3; } .field-error { - .form-label { - color: rgba($form-error-color, .92); - } - .form-input { - border-color: rgba($form-error-color, .7); + border-color: $form-error-color; box-shadow: 0 0 0 3px rgba($form-error-color, .12); } @@ -81,7 +77,7 @@ $form-success-color: #2e7d32; width: 100%; min-width: 0; padding: 13px 15px; - color: $black; + color: $text-color; font-size: 16px; font-weight: 400; line-height: 1.3; @@ -106,6 +102,19 @@ $form-success-color: #2e7d32; padding-right: 44px; } +.form-field-vat { + position: relative; + + .error-message { + position: absolute; + top: 100%; + left: 0; + width: 100%; + margin-top: 4px; + line-height: 1.3; + } +} + .form-field-vat .form-input-status::after { position: absolute; top: 50%; @@ -156,122 +165,10 @@ $form-success-color: #2e7d32; padding-right: 40px; } -.phone-field { - position: relative; - display: flex; - align-items: center; - min-height: 47px; - padding-left: 66px; - border: 1px solid rgba(black, .12); - border-radius: $border-radius-m; - background: white; - box-shadow: inset 0 1px 1px rgba(black, .02); - transition: border-color .2s ease-in-out, box-shadow .2s ease-in-out; - - &:focus-within { - border-color: rgba($main-brand-color, .55); - box-shadow: 0 0 0 3px rgba($main-brand-color, .12); - } - - @include breakpoint(sm-phone) { - min-height: 44px; - padding-left: 64px; - } -} - -.phone-field[data-phone-country-selected='false'] { - cursor: pointer; - - .phone-number { - cursor: pointer; - } - - .phone-flag, - .phone-chevron { - display: none; - } - - .phone-country-select { - width: 100%; - } -} - -.phone-field .phone-number { - flex: 1 1 auto; - min-width: 0; - padding: 13px 15px 13px 10px; - border: 0; - border-radius: 0; - box-shadow: none; - color: $black; - font-size: 16px; - font-weight: 400; - letter-spacing: 0; - background: transparent; - - &:focus { - border-color: transparent; - box-shadow: none; - } -} - -.field-error .phone-field { - border-color: rgba($form-error-color, .7); - box-shadow: 0 0 0 3px rgba($form-error-color, .12); -} - -.field-error .phone-field .phone-number { - border-color: transparent; - box-shadow: none; -} - -.phone-country-select { - position: absolute; - top: 0; - bottom: 0; - left: 0; - z-index: 3; - width: 66px; - height: 100%; - opacity: 0; - cursor: pointer; -} - -.phone-flag { - position: absolute; - top: 50%; - left: 16px; - z-index: 1; - font-size: 19px; - line-height: 1; - transform: translateY(-50%); -} - -.phone-chevron { - position: absolute; - top: 50%; - left: 42px; - z-index: 1; - width: 9px; - height: 9px; - border-right: 2px solid $gray-500; - border-bottom: 2px solid $gray-500; - transform: translateY(-58%) rotate(45deg); - pointer-events: none; -} - -.phone-dial-code { - flex: 0 0 auto; - color: $black; - font-size: 16px; - font-weight: 400; - line-height: 1; -} - .error-message { display: none; margin-top: 6px; - color: rgba($form-error-color, .92); + color: $form-error-color; font-size: 13px; line-height: 1.35; } @@ -319,13 +216,4 @@ $form-success-color: #2e7d32; padding: 12px 14px; font-size: 15px; } - - .phone-flag { - left: 15px; - font-size: 18px; - } - - .phone-chevron { - left: 40px; - } } diff --git a/site/assets/scss/modules/_result-panel.scss b/site/assets/scss/modules/_result-panel.scss index f243c6b0..cad773db 100644 --- a/site/assets/scss/modules/_result-panel.scss +++ b/site/assets/scss/modules/_result-panel.scss @@ -5,7 +5,7 @@ * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * - * http://www.apache.org/licenses/LICENSE-2.0 + * https://www.apache.org/licenses/LICENSE-2.0 * * Redistribution and use in source and/or binary forms, with or without * modification, must retain the above copyright notice and the following @@ -24,6 +24,13 @@ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ +.error-pages { + .main { + display: flex; + flex-direction: column; + } +} + .result-page { flex: 1 0 auto; min-height: 420px; @@ -119,7 +126,7 @@ .result-panel h1.result-panel-title, .result-panel h2.result-panel-title { margin: 0 0 14px; - color: $black; + color: $text-color; font-size: 36px; font-weight: 800; line-height: 1.15; @@ -143,12 +150,31 @@ } } +.result-panel p.result-panel-text-primary { + margin-bottom: 0; +} + +.result-panel p.result-panel-contact { + margin-top: 24px; +} + +.result-panel-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + gap: 16px; + margin-top: 20px; +} + +.checkout .result-panel-actions { + margin-top: 50px; +} + .result-panel a.result-panel-button, .result-panel a.result-panel-button:visited { display: inline-block; - margin-top: 20px; min-width: 220px; - color: $main-brand-color; font-size: 14px; font-weight: bold; letter-spacing: 1px; @@ -161,6 +187,16 @@ } } +.result-panel-text a { + color: $main-brand-color; + text-decoration: none; + + &:hover, + &:focus { + text-decoration: underline; + } +} + @include breakpoint(sm-phone) { .result-panel { padding: 14px 0 16px; @@ -180,4 +216,13 @@ .result-panel p.result-panel-text { font-size: 16px; } + + .checkout .result-panel-actions { + flex-direction: column; + margin-top: 30px; + } + + .result-panel a.result-panel-button { + width: 100%; + } } diff --git a/site/assets/scss/pages/_checkout.scss b/site/assets/scss/pages/_checkout.scss index 526ebff2..e22a7b24 100644 --- a/site/assets/scss/pages/_checkout.scss +++ b/site/assets/scss/pages/_checkout.scss @@ -5,7 +5,7 @@ * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * - * http://www.apache.org/licenses/LICENSE-2.0 + * https://www.apache.org/licenses/LICENSE-2.0 * * Redistribution and use in source and/or binary forms, with or without * modification, must retain the above copyright notice and the following @@ -24,207 +24,130 @@ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ +@import 'libs/country-select/select2'; +@import 'libs/intl-tel-input/intl-tel-input'; + +$checkout-panel-width: 790px; + .checkout-page { .main { + min-height: 100vh; display: flex; flex-direction: column; } } +.checkout-completed-page .main, +.checkout-result-page .main { + min-height: auto; +} + .checkout { flex: 1 0 auto; display: flex; flex-direction: column; background: radial-gradient(circle at top left, rgba($main-brand-color, .10), transparent 36%), linear-gradient(180deg, #f5faff 0%, var(--body-bg-color) 220px); + width: 100%; + min-height: 0; + overflow-x: hidden; @include breakpoint(lg-phone) { background: white; } + [hidden] { + display: none !important; + } + .content-with-fixed-header { margin-top: $header-height; - - @include breakpoint(desktop) { - margin-top: $header-height; - } } +} - .row { - flex: 1 0 auto; - margin-right: 0; - margin-left: 0; - } +.checkout-frame { + flex: 1 0 auto; + display: flex; + flex-direction: column; + width: 100%; +} - .row > [class*='col-'] { - padding-right: 0; - padding-left: 0; - } +.checkout-panel { + flex: 1 0 auto; + display: flex; + flex-direction: column; + align-items: center; + width: 100%; - .content-holder, - .article-container { - display: flex; + > .content-holder { flex: 1 0 auto; + display: flex; flex-direction: column; - } - - .article-container { - height: 100%; + width: 100%; + max-width: $checkout-panel-width; + margin: 0; + padding: 48px 64px 56px; + background: white; @include breakpoint(lg-phone) { - padding-block: 24px 32px; - } - } -} - -.checkout-summary { - - &[data-loading='true'] { - .checkout-summary-details { - min-height: 260px; - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; + padding: 28px 24px 40px; } - .checkout-summary-row, - .checkout-summary-product, - .checkout-summary-product-description { - display: none; - } - } - - &[data-error='true'] { - .checkout-summary-details { - min-height: 260px; - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; - } - - .checkout-summary-row, - .checkout-summary-product, - .checkout-summary-product-description { - display: none; + @include breakpoint(sm-phone) { + padding-right: 18px; + padding-left: 18px; } } +} - .checkout-summary-details { - border-top: none; - padding-top: 8px; - } +.checkout-loading { + flex: 1 0 auto; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: 100%; + min-height: 260px; + text-align: center; - .checkout-summary-loading { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - width: 100%; + p { max-width: 420px; - margin: 0 auto; + margin: 0; color: $gray-500; - font-size: 16px; - font-weight: 600; - line-height: 1.4; - text-align: center; - - .loader { - width: 38px; - height: 38px; - margin: 0 auto 14px; - } - - p { - margin: 0; - font-size: 17px; - font-weight: 600; - line-height: 1.4; - color: $gray-500; - text-align: center; - } - } - - .checkout-summary-support { - max-width: 360px; - margin-top: 12px; - font-size: 15px; - font-weight: 500; + font-size: 17px; + font-weight: 400; line-height: 1.5; - text-align: center; - - a { - color: $main-brand-color; - font-weight: 700; - text-decoration: none; - - &:hover, - &:focus { - text-decoration: underline; - } - } } - @include breakpoint(sm-phone) { - &[data-loading='true'], - &[data-error='true'] { - .checkout-summary-details { - min-height: 220px; - } - } - - .checkout-summary-loading { - font-size: 16px; - - .loader { - width: 34px; - height: 34px; - } - - p { - font-size: 16px; - } - } + span { + display: block; } +} - .checkout-summary-row { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - align-items: end; - column-gap: 20px; - padding: 12px 0; - border-bottom: 0; - } +.checkout-loading-spinner { + width: 40px; + height: 40px; + margin: 0 auto 16px; + border: 4px solid rgba($main-brand-color, .18); + border-top-color: $main-brand-color; + border-radius: 50%; + animation: checkout-loading-spin .8s linear infinite; +} - .checkout-summary-row-divider { - border-bottom: 1px solid rgba($black, .12); +@keyframes checkout-loading-spin { + to { + transform: rotate(360deg); } +} - .checkout-summary-row-total { - padding-top: 12px; - padding-bottom: 0; - border-bottom: 0; - - .checkout-summary-label { - color: $black; - font-size: 20px; - font-weight: 800; - letter-spacing: -.02em; - } - - .checkout-summary-value { - font-size: 20px; - font-weight: 800; - letter-spacing: -.02em; - } - } +.checkout-summary { + width: 100%; .checkout-summary-product { max-width: 100%; padding-bottom: 8px; - color: rgba($black, .4); - font-size: 42px; + color: rgba(black, .4); + font-size: $font-size--xxxl; font-weight: 300; letter-spacing: 0; line-height: 1.12; @@ -236,129 +159,281 @@ } .checkout-summary-product-description { - margin-bottom: 28px; - color: rgba($black, .54); + margin-bottom: 30px; + color: $gray-500; font-size: 16px; - font-weight: 400; line-height: 1.5; + } - @include breakpoint(lg-phone) { - margin-bottom: 24px; - } + .checkout-summary-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: end; + column-gap: 20px; + padding: 12px 0; + } - @include breakpoint(md-phone) { - font-size: 14px; - margin-bottom: 16px; - } + .checkout-summary-row-divider { + border-bottom: 1px solid rgba($black, .12); } - .checkout-summary-label { - flex: 0 0 auto; + .checkout-summary-label, + .checkout-summary-value { min-width: 0; - color: $black; + color: $text-color; font-size: 16px; - font-weight: 400; line-height: 1.2; } .checkout-summary-value { - min-width: 0; max-width: 100%; - color: $black; - font-size: 18px; font-weight: 700; - line-height: 1.2; text-align: right; - white-space: normal; overflow-wrap: anywhere; } - .checkout-summary-value-amount { - color: $black; - font-size: 16px; - font-weight: 700; - line-height: 1.2; - } + .checkout-summary-row-total { + padding-bottom: 0; - .checkout-summary-value-total { - color: $black; - font-size: 20px; - font-weight: 700; - letter-spacing: 0; - line-height: 1.1; + .checkout-summary-label, + .checkout-summary-value { + font-size: 20px; + font-weight: 800; + } } @include breakpoint(lg-phone) { margin-bottom: 16px; - .checkout-summary-product { - font-size: 28px; - padding-bottom: 6px; + .checkout-summary-product-description { + margin-bottom: 22px; } + } + @include breakpoint(sm-phone) { .checkout-summary-row { column-gap: 12px; - padding: 10px 0 12px; + padding: 10px 0; } } +} + +.checkout .form-section { + margin-top: 44px; + + .form-title { + margin-bottom: 26px; + padding-top: 0; + color: $text-color; + font-size: 24px; + font-weight: 800; + line-height: 1.2; + } + + .form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + column-gap: 20px; + } + + .form-field { + min-width: 0; + } + + .form-field-full { + grid-column: 1 / -1; + } + + .form-input { + width: 100%; + min-height: 48px; + } + + .form-submit-button { + min-height: 48px; + } + + .form-submit-button.disabled, + .form-submit-button:disabled { + cursor: not-allowed; + opacity: .4; + } @include breakpoint(md-phone) { - .checkout-summary-label { - font-size: 15px; - } + margin-top: 30px; - .checkout-summary-value-amount { - font-size: 16px; + .form-grid { + grid-template-columns: minmax(0, 1fr); } - .checkout-summary-row-total .checkout-summary-label, - .checkout-summary-value-total { - font-size: 20px; + .form-field, + .form-field-full { + grid-column: 1; } } +} - @include breakpoint(sm-phone) { - .checkout-summary-product { - font-size: 26px; +.checkout { + // Select2 normally positions the required native select off-screen, which + // prevents reportValidity() from focusing it. Keep it in the layout at zero height. + .select2-hidden-accessible[required] { + position: static !important; + display: block; + width: 100% !important; + height: 0 !important; + min-height: 0 !important; + margin: 0 !important; + opacity: 0; + } + + .select2-container { + width: 100% !important; + + .select2-selection--single { + height: 48px; + border: 1px solid rgba($black, .12); + border-radius: $border-radius-m; + box-shadow: inset 0 1px 1px rgba($black, .02); + + &:focus { + outline: none; + border-color: rgba($main-brand-color, .55); + box-shadow: 0 0 0 3px rgba($main-brand-color, .12); + } + + .select2-selection__rendered { + padding: 13px 40px 13px 15px; + color: $text-color; + font-size: 16px; + line-height: 20px; + } + + .select2-selection__arrow { + width: 40px; + height: 46px; + } } + } - .checkout-summary-row { - padding: 8px 0; + .field-error .select2-selection--single { + border-color: $form-error-color; + box-shadow: 0 0 0 3px rgba($form-error-color, .12); + } + + .iti { + display: block; + width: 100%; + + input.form-input { + width: 100%; + min-height: 48px; } - .checkout-summary-value-amount { - font-size: 15px; + .iti__selected-flag { + border-radius: $border-radius-m 0 0 $border-radius-m; } - .checkout-summary-row-total .checkout-summary-label, - .checkout-summary-value-total { - font-size: 18px; + .iti__country-list { + z-index: 20; + border: 0; + border-radius: $border-radius-m; + box-shadow: 0 8px 24px rgba($black, .16); } } } -.checkout .form-section { - margin-top: 8px; +.select2-dropdown { + border: 0; + border-radius: $border-radius-m; + box-shadow: 0 8px 24px rgba($black, .16); + z-index: 20; - .form-title { - padding-top: 40px; + .select2-results__option { + padding: 10px 15px; + } - @include breakpoint(lg-phone) { - padding-top: 8px; - } + .select2-results__option--highlighted.select2-results__option--selectable { + color: $text-color; + background: rgba($main-brand-color, .1); + } + + .select2-search__field { + padding: 8px 10px; + border: 1px solid rgba($black, .15) !important; + border-radius: $border-radius-m; + } +} + +.country-selector__option { + display: inline-flex; + align-items: center; + gap: 8px; + max-width: 100%; + min-width: 0; + + .iti__flag { + flex: 0 0 auto; + } +} + +.country-selector__text { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.checkout-business-fields { + min-width: 0; + margin: 0; + padding: 0; + border: 0; + + .checkout-business-fields-title { + width: 100%; + margin: 0 0 8px; + padding: 0; + color: $text-color; + font-size: 20px; + font-weight: 800; + } + + .checkout-business-fields-description { + margin: 0 0 22px; + color: $gray-500; + font-size: 15px; + line-height: 1.5; + } + + .checkout-business-fields-grid { + display: grid; + grid-template-columns: minmax(0, 1fr); + row-gap: 24px; } } +#checkout-missing-order, #checkout-not-found, -#checkout-summary-error { - margin-top: 32px; +#checkout-summary-error, +.checkout-completed > .checkout-frame { + flex: 1 0 auto; +} + +.checkout-result-loading .checkout-result-title { + margin: 0 0 14px; + color: $text-color; + font-size: 32px; + font-weight: 800; + line-height: 1.15; + text-align: center; - @include breakpoint(desktop) { - margin-top: 16px; + @include breakpoint(sm-phone) { + font-size: 28px; } } -.checkout-completed { +.checkout-completed, +.checkout-result-page .checkout { min-height: 420px; @include breakpoint(sm-phone) { diff --git a/site/assets/scss/pages/_getting-help.scss b/site/assets/scss/pages/_getting-help.scss index bce9bce6..01fa9200 100644 --- a/site/assets/scss/pages/_getting-help.scss +++ b/site/assets/scss/pages/_getting-help.scss @@ -1,11 +1,11 @@ /*! - * Copyright 2025, TeamDev. All rights reserved. + * Copyright 2026, TeamDev. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * - * http://www.apache.org/licenses/LICENSE-2.0 + * https://www.apache.org/licenses/LICENSE-2.0 * * Redistribution and use in source and/or binary forms, with or without * modification, must retain the above copyright notice and the following @@ -342,24 +342,10 @@ padding-left: 0; padding-right: 0; - $light-blue-btn-hover: #5fabdd; &.order-btn { - background: $main-brand-color; - border-color: $main-brand-color; - color: white; - &:after { opacity: 1; } - - &:hover { - background: $light-blue-btn-hover; - border-color: $light-blue-btn-hover; - - &:after { - color: white; - } - } } } diff --git a/site/config/development/hugo.toml b/site/config/development/hugo.toml index c5c23c67..18dce1c0 100644 --- a/site/config/development/hugo.toml +++ b/site/config/development/hugo.toml @@ -1,3 +1,4 @@ [params.payment] consentURL = 'http://localhost:5002/spine-site-server/us-central1/api/consent' paygateURL = 'https://stag.paygate.teamdev.com' + standardSupportProductId = '137c613b-305b-45ce-bd40-49f6a42eafde' diff --git a/site/content/checkout-completed/index.md b/site/content/checkout-completed/index.md index f524801a..4ee55acc 100644 --- a/site/content/checkout-completed/index.md +++ b/site/content/checkout-completed/index.md @@ -1,7 +1,8 @@ --- -title: Checkout Completed -description: Thank you page about completed checkout. -body_class: checkout-page +title: Payment Result +description: Current payment result for a checkout order. +body_class: checkout-page checkout-completed-page +customjs: js/pages/checkout/completed.js header_type: fixed-header sitemap: disable: true diff --git a/site/layouts/404.html b/site/layouts/404.html index d2868e3a..010011a9 100644 --- a/site/layouts/404.html +++ b/site/layouts/404.html @@ -16,10 +16,10 @@ "The page you are looking for could not be found." "Please check the address or return to the home page." ) - "action" (dict + "actions" (slice (dict "label" "Back to home" "url" site.Home.RelPermalink - ) + )) ) }} diff --git a/site/layouts/_partials/components/result-panel.html b/site/layouts/_partials/components/result-panel.html index 8cca5326..b852a08e 100644 --- a/site/layouts/_partials/components/result-panel.html +++ b/site/layouts/_partials/components/result-panel.html @@ -30,33 +30,43 @@ {{ $titleAsH1 := .title_as_h1 }} {{ $mark := .mark }} {{ $lines := .lines | default slice }} -{{ $action := .action }} +{{ $additionalLine := .additional_line | default "" }} +{{ $actions := .actions | default slice }}

{{ with $mark }} - {{ $markText := .text | default .content | default "" }} - {{ $markClass := .icon_class | default "" }} - + {{ $markText := .text | default .content | default "" }} + {{ $markClass := .icon_class | default "" }} + {{ end }} {{ if $titleAsH1 }} -

{{ $title }}

+

{{ $title }}

{{ else }} -

{{ $title }}

+

{{ $title }}

{{ end }} {{ if $lines }} -

- {{ range $lines }} - {{ . }} - {{ end }} -

+

+ {{ range $lines }} + {{ . | markdownify }} + {{ end }} +

{{ end }} - {{ with $action }} - {{ $actionLabel := .label | default .text | default "" }} - {{ $actionUrl := .url | default site.Home.RelPermalink }} - {{ if $actionLabel }} - - {{ $actionLabel }} - + {{ with $additionalLine }} +

{{ . | markdownify }}

{{ end }} + {{ if $actions }} +
+ {{ range $actions }} + {{ $actionLabel := .label | default .text | default "" }} + {{ $actionUrl := .url | default site.Home.RelPermalink }} + {{ if $actionLabel }} + + {{ $actionLabel }} + + {{ end }} + {{ end }} +
{{ end }}
diff --git a/site/layouts/_partials/getting-help/comparable-services-footer.html b/site/layouts/_partials/getting-help/comparable-services-footer.html index c82d36cf..05b982e9 100644 --- a/site/layouts/_partials/getting-help/comparable-services-footer.html +++ b/site/layouts/_partials/getting-help/comparable-services-footer.html @@ -69,7 +69,7 @@ data-bs-toggle="tooltip" data-bs-placement="bottom" data-bs-title="Read and agree to the terms to continue."> - diff --git a/site/layouts/_partials/getting-help/services.html b/site/layouts/_partials/getting-help/services.html index 35751883..96542c42 100644 --- a/site/layouts/_partials/getting-help/services.html +++ b/site/layouts/_partials/getting-help/services.html @@ -41,8 +41,14 @@

{{ $data.title | markdownify }}

{{ $comparableServices := $data.comparable_services }} {{ range $comparableServices.header_cols }} + {{ $productId := .paygate_product_id | default "" }} + {{ if $productId }} + {{ with site.Params.payment.standardsupportproductid }} + {{ $productId = . }} + {{ end }} + {{ end }}
+ {{ with $productId }}data-paygate-product-id="{{ . }}"{{ end }}>

{{ .title | markdownify }}

diff --git a/site/layouts/_partials/scripts/script.html b/site/layouts/_partials/scripts/script.html deleted file mode 100644 index 311797df..00000000 --- a/site/layouts/_partials/scripts/script.html +++ /dev/null @@ -1,44 +0,0 @@ - - - - -{{ $js := .js }} -{{ $attributes := .attributes }} - -{{ if hugo.IsProduction }} - {{ $js = $js | minify | fingerprint }} - -{{ else }} - -{{ end }} diff --git a/site/layouts/checkout-completed/single.html b/site/layouts/checkout-completed/single.html index 14e24484..55abf9aa 100644 --- a/site/layouts/checkout-completed/single.html +++ b/site/layouts/checkout-completed/single.html @@ -1,24 +1,128 @@ {{ define "main" }} {{ partial "components/navbar/navbar.html" . }} -
-
-
-
-
+ {{ $salesEmail := hugo.Data.emails.sales_email }} + {{ $salesEmailLink := (printf "{{< cloakemail address=\"%s\" >}}" $salesEmail) }} + + {{ $contactLine := printf "If you have any questions, email us at %s." $salesEmailLink }} + {{ $missingOrderContactLine := printf "Please check the address or contact %s." $salesEmailLink }} +
+
+
+
+ + + + + + + +
+ {{ partial "components/result-panel.html" (dict + "title" "Thank you!" + "title_as_h1" true + "lines" (slice + "We will send the order details to your email after the payment is confirmed." + ) + "additional_line" $contactLine + "actions" (slice (dict + "label" "Back to home" + "url" site.Home.RelPermalink + )) ) }}
diff --git a/site/layouts/checkout/single.html b/site/layouts/checkout/single.html index 6868be21..e0f1ef97 100644 --- a/site/layouts/checkout/single.html +++ b/site/layouts/checkout/single.html @@ -1,280 +1,251 @@ {{ define "main" }} {{ partial "components/navbar/navbar.html" . }} + {{ $salesEmail := hugo.Data.emails.sales_email }} + {{ $salesEmailLink := (printf "{{< cloakemail address=\"%s\" >}}" $salesEmail) }} + + {{ $missingOrderContactLine := printf "Please check the address or contact %s." $salesEmailLink }}
-
-
-
-
-
-
-
- -

Loading checkout details...

- {{ $email := hugo.Data.emails.sales_email }} - {{ $emailLink := printf - "%s" - $email - $email - | safeHTML - }} - -
- - -
- Subtotal - ... -
-
- VAT - ... -
-
- Total - ... -
+
+
+
+
+ +

Loading checkout details...

+
+
- {{ partial "components/go-top-button.html" . }} + + + + + {{ end }} diff --git a/site/package-lock.json b/site/package-lock.json index dc7fa4f9..ac97caa7 100644 --- a/site/package-lock.json +++ b/site/package-lock.json @@ -11,9 +11,11 @@ "devDependencies": { "@fullhuman/postcss-purgecss": "^7.0.2", "autoprefixer": "^10.5.0", + "intl-tel-input": "18.2.1", "postcss": "^8.5.14", "postcss-cli": "^11.0.1", - "postcss-discard-comments": "^7.0.5" + "postcss-discard-comments": "^7.0.5", + "select2": "4.1.0-rc.0" } }, "node_modules/@fullhuman/postcss-purgecss": { @@ -560,6 +562,13 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "dev": true }, + "node_modules/intl-tel-input": { + "version": "18.2.1", + "resolved": "https://registry.npmjs.org/intl-tel-input/-/intl-tel-input-18.2.1.tgz", + "integrity": "sha512-wOm0/61kTtpYjOOW1bhHzC4G8Om+atTxHmg31FS0KD0LQ8k8BpgO925npyi4jlT/EK4+joABABZzz0/XeSgupQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -1010,6 +1019,13 @@ "node": ">=0.10.0" } }, + "node_modules/select2": { + "version": "4.1.0-rc.0", + "resolved": "https://registry.npmjs.org/select2/-/select2-4.1.0-rc.0.tgz", + "integrity": "sha512-Hr9TdhyHCZUtwznEH2CBf7967mEM0idtJ5nMtjvk3Up5tPukOLXbHUNmh10oRfeNIhj+3GD3niu+g6sVK+gK0A==", + "dev": true, + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", diff --git a/site/package.json b/site/package.json index ea630367..36a885e5 100644 --- a/site/package.json +++ b/site/package.json @@ -4,15 +4,17 @@ "description": "", "main": "index.js", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "test": "node --test tests/*.test.mjs" }, "author": "", "license": "ISC", "devDependencies": { "@fullhuman/postcss-purgecss": "^7.0.2", "autoprefixer": "^10.5.0", + "intl-tel-input": "18.2.1", "postcss": "^8.5.14", "postcss-cli": "^11.0.1", - "postcss-discard-comments": "^7.0.5" + "postcss-discard-comments": "^7.0.5", + "select2": "4.1.0-rc.0" } } diff --git a/site/static/images/flags/flags.png b/site/static/images/flags/flags.png new file mode 100644 index 00000000..36f236e5 Binary files /dev/null and b/site/static/images/flags/flags.png differ diff --git a/site/static/images/flags/flags@2x.png b/site/static/images/flags/flags@2x.png new file mode 100644 index 00000000..cba08f2d Binary files /dev/null and b/site/static/images/flags/flags@2x.png differ diff --git a/site/static/libs/country-select/select2.min.js b/site/static/libs/country-select/select2.min.js new file mode 100644 index 00000000..cc9a83f1 --- /dev/null +++ b/site/static/libs/country-select/select2.min.js @@ -0,0 +1,2 @@ +/*! Select2 4.1.0-rc.0 | https://github.com/select2/select2/blob/master/LICENSE.md */ +!function(n){"function"==typeof define&&define.amd?define(["jquery"],n):"object"==typeof module&&module.exports?module.exports=function(e,t){return void 0===t&&(t="undefined"!=typeof window?require("jquery"):require("jquery")(e)),n(t),t}:n(jQuery)}(function(t){var e,n,s,p,r,o,h,f,g,m,y,v,i,a,_,s=((u=t&&t.fn&&t.fn.select2&&t.fn.select2.amd?t.fn.select2.amd:u)&&u.requirejs||(u?n=u:u={},g={},m={},y={},v={},i=Object.prototype.hasOwnProperty,a=[].slice,_=/\.js$/,h=function(e,t){var n,s,i=c(e),r=i[0],t=t[1];return e=i[1],r&&(n=x(r=l(r,t))),r?e=n&&n.normalize?n.normalize(e,(s=t,function(e){return l(e,s)})):l(e,t):(r=(i=c(e=l(e,t)))[0],e=i[1],r&&(n=x(r))),{f:r?r+"!"+e:e,n:e,pr:r,p:n}},f={require:function(e){return w(e)},exports:function(e){var t=g[e];return void 0!==t?t:g[e]={}},module:function(e){return{id:e,uri:"",exports:g[e],config:(t=e,function(){return y&&y.config&&y.config[t]||{}})};var t}},r=function(e,t,n,s){var i,r,o,a,l,c=[],u=typeof n,d=A(s=s||e);if("undefined"==u||"function"==u){for(t=!t.length&&n.length?["require","exports","module"]:t,a=0;a":">",'"':""","'":"'","/":"/"};return"string"!=typeof e?e:String(e).replace(/[&<>"'\/\\]/g,function(e){return t[e]})},s.__cache={};var n=0;return s.GetUniqueElementId=function(e){var t=e.getAttribute("data-select2-id");return null!=t||(t=e.id?"select2-data-"+e.id:"select2-data-"+(++n).toString()+"-"+s.generateChars(4),e.setAttribute("data-select2-id",t)),t},s.StoreData=function(e,t,n){e=s.GetUniqueElementId(e);s.__cache[e]||(s.__cache[e]={}),s.__cache[e][t]=n},s.GetData=function(e,t){var n=s.GetUniqueElementId(e);return t?s.__cache[n]&&null!=s.__cache[n][t]?s.__cache[n][t]:r(e).data(t):s.__cache[n]},s.RemoveData=function(e){var t=s.GetUniqueElementId(e);null!=s.__cache[t]&&delete s.__cache[t],e.removeAttribute("data-select2-id")},s.copyNonInternalCssClasses=function(e,t){var n=(n=e.getAttribute("class").trim().split(/\s+/)).filter(function(e){return 0===e.indexOf("select2-")}),t=(t=t.getAttribute("class").trim().split(/\s+/)).filter(function(e){return 0!==e.indexOf("select2-")}),t=n.concat(t);e.setAttribute("class",t.join(" "))},s}),u.define("select2/results",["jquery","./utils"],function(d,p){function s(e,t,n){this.$element=e,this.data=n,this.options=t,s.__super__.constructor.call(this)}return p.Extend(s,p.Observable),s.prototype.render=function(){var e=d('
    ');return this.options.get("multiple")&&e.attr("aria-multiselectable","true"),this.$results=e},s.prototype.clear=function(){this.$results.empty()},s.prototype.displayMessage=function(e){var t=this.options.get("escapeMarkup");this.clear(),this.hideLoading();var n=d(''),s=this.options.get("translations").get(e.message);n.append(t(s(e.args))),n[0].className+=" select2-results__message",this.$results.append(n)},s.prototype.hideMessages=function(){this.$results.find(".select2-results__message").remove()},s.prototype.append=function(e){this.hideLoading();var t=[];if(null!=e.results&&0!==e.results.length){e.results=this.sort(e.results);for(var n=0;n",{class:"select2-results__options select2-results__options--nested",role:"none"});i.append(l),o.append(a),o.append(i)}else this.template(e,t);return p.StoreData(t,"data",e),t},s.prototype.bind=function(t,e){var i=this,n=t.id+"-results";this.$results.attr("id",n),t.on("results:all",function(e){i.clear(),i.append(e.data),t.isOpen()&&(i.setClasses(),i.highlightFirstItem())}),t.on("results:append",function(e){i.append(e.data),t.isOpen()&&i.setClasses()}),t.on("query",function(e){i.hideMessages(),i.showLoading(e)}),t.on("select",function(){t.isOpen()&&(i.setClasses(),i.options.get("scrollAfterSelect")&&i.highlightFirstItem())}),t.on("unselect",function(){t.isOpen()&&(i.setClasses(),i.options.get("scrollAfterSelect")&&i.highlightFirstItem())}),t.on("open",function(){i.$results.attr("aria-expanded","true"),i.$results.attr("aria-hidden","false"),i.setClasses(),i.ensureHighlightVisible()}),t.on("close",function(){i.$results.attr("aria-expanded","false"),i.$results.attr("aria-hidden","true"),i.$results.removeAttr("aria-activedescendant")}),t.on("results:toggle",function(){var e=i.getHighlightedResults();0!==e.length&&e.trigger("mouseup")}),t.on("results:select",function(){var e,t=i.getHighlightedResults();0!==t.length&&(e=p.GetData(t[0],"data"),t.hasClass("select2-results__option--selected")?i.trigger("close",{}):i.trigger("select",{data:e}))}),t.on("results:previous",function(){var e,t=i.getHighlightedResults(),n=i.$results.find(".select2-results__option--selectable"),s=n.index(t);s<=0||(e=s-1,0===t.length&&(e=0),(s=n.eq(e)).trigger("mouseenter"),t=i.$results.offset().top,n=s.offset().top,s=i.$results.scrollTop()+(n-t),0===e?i.$results.scrollTop(0):n-t<0&&i.$results.scrollTop(s))}),t.on("results:next",function(){var e,t=i.getHighlightedResults(),n=i.$results.find(".select2-results__option--selectable"),s=n.index(t)+1;s>=n.length||((e=n.eq(s)).trigger("mouseenter"),t=i.$results.offset().top+i.$results.outerHeight(!1),n=e.offset().top+e.outerHeight(!1),e=i.$results.scrollTop()+n-t,0===s?i.$results.scrollTop(0):tthis.$results.outerHeight()||s<0)&&this.$results.scrollTop(n))},s.prototype.template=function(e,t){var n=this.options.get("templateResult"),s=this.options.get("escapeMarkup"),e=n(e,t);null==e?t.style.display="none":"string"==typeof e?t.innerHTML=s(e):d(t).append(e)},s}),u.define("select2/keys",[],function(){return{BACKSPACE:8,TAB:9,ENTER:13,SHIFT:16,CTRL:17,ALT:18,ESC:27,SPACE:32,PAGE_UP:33,PAGE_DOWN:34,END:35,HOME:36,LEFT:37,UP:38,RIGHT:39,DOWN:40,DELETE:46}}),u.define("select2/selection/base",["jquery","../utils","../keys"],function(n,s,i){function r(e,t){this.$element=e,this.options=t,r.__super__.constructor.call(this)}return s.Extend(r,s.Observable),r.prototype.render=function(){var e=n('');return this._tabindex=0,null!=s.GetData(this.$element[0],"old-tabindex")?this._tabindex=s.GetData(this.$element[0],"old-tabindex"):null!=this.$element.attr("tabindex")&&(this._tabindex=this.$element.attr("tabindex")),e.attr("title",this.$element.attr("title")),e.attr("tabindex",this._tabindex),e.attr("aria-disabled","false"),this.$selection=e},r.prototype.bind=function(e,t){var n=this,s=e.id+"-results";this.container=e,this.$selection.on("focus",function(e){n.trigger("focus",e)}),this.$selection.on("blur",function(e){n._handleBlur(e)}),this.$selection.on("keydown",function(e){n.trigger("keypress",e),e.which===i.SPACE&&e.preventDefault()}),e.on("results:focus",function(e){n.$selection.attr("aria-activedescendant",e.data._resultId)}),e.on("selection:update",function(e){n.update(e.data)}),e.on("open",function(){n.$selection.attr("aria-expanded","true"),n.$selection.attr("aria-owns",s),n._attachCloseHandler(e)}),e.on("close",function(){n.$selection.attr("aria-expanded","false"),n.$selection.removeAttr("aria-activedescendant"),n.$selection.removeAttr("aria-owns"),n.$selection.trigger("focus"),n._detachCloseHandler(e)}),e.on("enable",function(){n.$selection.attr("tabindex",n._tabindex),n.$selection.attr("aria-disabled","false")}),e.on("disable",function(){n.$selection.attr("tabindex","-1"),n.$selection.attr("aria-disabled","true")})},r.prototype._handleBlur=function(e){var t=this;window.setTimeout(function(){document.activeElement==t.$selection[0]||n.contains(t.$selection[0],document.activeElement)||t.trigger("blur",e)},1)},r.prototype._attachCloseHandler=function(e){n(document.body).on("mousedown.select2."+e.id,function(e){var t=n(e.target).closest(".select2");n(".select2.select2-container--open").each(function(){this!=t[0]&&s.GetData(this,"element").select2("close")})})},r.prototype._detachCloseHandler=function(e){n(document.body).off("mousedown.select2."+e.id)},r.prototype.position=function(e,t){t.find(".selection").append(e)},r.prototype.destroy=function(){this._detachCloseHandler(this.container)},r.prototype.update=function(e){throw new Error("The `update` method must be defined in child classes.")},r.prototype.isEnabled=function(){return!this.isDisabled()},r.prototype.isDisabled=function(){return this.options.get("disabled")},r}),u.define("select2/selection/single",["jquery","./base","../utils","../keys"],function(e,t,n,s){function i(){i.__super__.constructor.apply(this,arguments)}return n.Extend(i,t),i.prototype.render=function(){var e=i.__super__.render.call(this);return e[0].classList.add("select2-selection--single"),e.html(''),e},i.prototype.bind=function(t,e){var n=this;i.__super__.bind.apply(this,arguments);var s=t.id+"-container";this.$selection.find(".select2-selection__rendered").attr("id",s).attr("role","textbox").attr("aria-readonly","true"),this.$selection.attr("aria-labelledby",s),this.$selection.attr("aria-controls",s),this.$selection.on("mousedown",function(e){1===e.which&&n.trigger("toggle",{originalEvent:e})}),this.$selection.on("focus",function(e){}),this.$selection.on("blur",function(e){}),t.on("focus",function(e){t.isOpen()||n.$selection.trigger("focus")})},i.prototype.clear=function(){var e=this.$selection.find(".select2-selection__rendered");e.empty(),e.removeAttr("title")},i.prototype.display=function(e,t){var n=this.options.get("templateSelection");return this.options.get("escapeMarkup")(n(e,t))},i.prototype.selectionContainer=function(){return e("")},i.prototype.update=function(e){var t,n;0!==e.length?(n=e[0],t=this.$selection.find(".select2-selection__rendered"),e=this.display(n,t),t.empty().append(e),(n=n.title||n.text)?t.attr("title",n):t.removeAttr("title")):this.clear()},i}),u.define("select2/selection/multiple",["jquery","./base","../utils"],function(i,e,c){function r(e,t){r.__super__.constructor.apply(this,arguments)}return c.Extend(r,e),r.prototype.render=function(){var e=r.__super__.render.call(this);return e[0].classList.add("select2-selection--multiple"),e.html('
      '),e},r.prototype.bind=function(e,t){var n=this;r.__super__.bind.apply(this,arguments);var s=e.id+"-container";this.$selection.find(".select2-selection__rendered").attr("id",s),this.$selection.on("click",function(e){n.trigger("toggle",{originalEvent:e})}),this.$selection.on("click",".select2-selection__choice__remove",function(e){var t;n.isDisabled()||(t=i(this).parent(),t=c.GetData(t[0],"data"),n.trigger("unselect",{originalEvent:e,data:t}))}),this.$selection.on("keydown",".select2-selection__choice__remove",function(e){n.isDisabled()||e.stopPropagation()})},r.prototype.clear=function(){var e=this.$selection.find(".select2-selection__rendered");e.empty(),e.removeAttr("title")},r.prototype.display=function(e,t){var n=this.options.get("templateSelection");return this.options.get("escapeMarkup")(n(e,t))},r.prototype.selectionContainer=function(){return i('
    • ')},r.prototype.update=function(e){if(this.clear(),0!==e.length){for(var t=[],n=this.$selection.find(".select2-selection__rendered").attr("id")+"-choice-",s=0;s')).attr("title",s()),e.attr("aria-label",s()),e.attr("aria-describedby",n),a.StoreData(e[0],"data",t),this.$selection.prepend(e),this.$selection[0].classList.add("select2-selection--clearable"))},e}),u.define("select2/selection/search",["jquery","../utils","../keys"],function(s,a,l){function e(e,t,n){e.call(this,t,n)}return e.prototype.render=function(e){var t=this.options.get("translations").get("search"),n=s('');this.$searchContainer=n,this.$search=n.find("textarea"),this.$search.prop("autocomplete",this.options.get("autocomplete")),this.$search.attr("aria-label",t());e=e.call(this);return this._transferTabIndex(),e.append(this.$searchContainer),e},e.prototype.bind=function(e,t,n){var s=this,i=t.id+"-results",r=t.id+"-container";e.call(this,t,n),s.$search.attr("aria-describedby",r),t.on("open",function(){s.$search.attr("aria-controls",i),s.$search.trigger("focus")}),t.on("close",function(){s.$search.val(""),s.resizeSearch(),s.$search.removeAttr("aria-controls"),s.$search.removeAttr("aria-activedescendant"),s.$search.trigger("focus")}),t.on("enable",function(){s.$search.prop("disabled",!1),s._transferTabIndex()}),t.on("disable",function(){s.$search.prop("disabled",!0)}),t.on("focus",function(e){s.$search.trigger("focus")}),t.on("results:focus",function(e){e.data._resultId?s.$search.attr("aria-activedescendant",e.data._resultId):s.$search.removeAttr("aria-activedescendant")}),this.$selection.on("focusin",".select2-search--inline",function(e){s.trigger("focus",e)}),this.$selection.on("focusout",".select2-search--inline",function(e){s._handleBlur(e)}),this.$selection.on("keydown",".select2-search--inline",function(e){var t;e.stopPropagation(),s.trigger("keypress",e),s._keyUpPrevented=e.isDefaultPrevented(),e.which!==l.BACKSPACE||""!==s.$search.val()||0<(t=s.$selection.find(".select2-selection__choice").last()).length&&(t=a.GetData(t[0],"data"),s.searchRemoveChoice(t),e.preventDefault())}),this.$selection.on("click",".select2-search--inline",function(e){s.$search.val()&&e.stopPropagation()});var t=document.documentMode,o=t&&t<=11;this.$selection.on("input.searchcheck",".select2-search--inline",function(e){o?s.$selection.off("input.search input.searchcheck"):s.$selection.off("keyup.search")}),this.$selection.on("keyup.search input.search",".select2-search--inline",function(e){var t;o&&"input"===e.type?s.$selection.off("input.search input.searchcheck"):(t=e.which)!=l.SHIFT&&t!=l.CTRL&&t!=l.ALT&&t!=l.TAB&&s.handleSearch(e)})},e.prototype._transferTabIndex=function(e){this.$search.attr("tabindex",this.$selection.attr("tabindex")),this.$selection.attr("tabindex","-1")},e.prototype.createPlaceholder=function(e,t){this.$search.attr("placeholder",t.text)},e.prototype.update=function(e,t){var n=this.$search[0]==document.activeElement;this.$search.attr("placeholder",""),e.call(this,t),this.resizeSearch(),n&&this.$search.trigger("focus")},e.prototype.handleSearch=function(){var e;this.resizeSearch(),this._keyUpPrevented||(e=this.$search.val(),this.trigger("query",{term:e})),this._keyUpPrevented=!1},e.prototype.searchRemoveChoice=function(e,t){this.trigger("unselect",{data:t}),this.$search.val(t.text),this.handleSearch()},e.prototype.resizeSearch=function(){this.$search.css("width","25px");var e="100%";""===this.$search.attr("placeholder")&&(e=.75*(this.$search.val().length+1)+"em"),this.$search.css("width",e)},e}),u.define("select2/selection/selectionCss",["../utils"],function(n){function e(){}return e.prototype.render=function(e){var t=e.call(this),e=this.options.get("selectionCssClass")||"";return-1!==e.indexOf(":all:")&&(e=e.replace(":all:",""),n.copyNonInternalCssClasses(t[0],this.$element[0])),t.addClass(e),t},e}),u.define("select2/selection/eventRelay",["jquery"],function(o){function e(){}return e.prototype.bind=function(e,t,n){var s=this,i=["open","opening","close","closing","select","selecting","unselect","unselecting","clear","clearing"],r=["opening","closing","selecting","unselecting","clearing"];e.call(this,t,n),t.on("*",function(e,t){var n;-1!==i.indexOf(e)&&(t=t||{},n=o.Event("select2:"+e,{params:t}),s.$element.trigger(n),-1!==r.indexOf(e)&&(t.prevented=n.isDefaultPrevented()))})},e}),u.define("select2/translation",["jquery","require"],function(t,n){function s(e){this.dict=e||{}}return s.prototype.all=function(){return this.dict},s.prototype.get=function(e){return this.dict[e]},s.prototype.extend=function(e){this.dict=t.extend({},e.all(),this.dict)},s._cache={},s.loadPath=function(e){var t;return e in s._cache||(t=n(e),s._cache[e]=t),new s(s._cache[e])},s}),u.define("select2/diacritics",[],function(){return{"Ⓐ":"A","A":"A","À":"A","Á":"A","Â":"A","Ầ":"A","Ấ":"A","Ẫ":"A","Ẩ":"A","Ã":"A","Ā":"A","Ă":"A","Ằ":"A","Ắ":"A","Ẵ":"A","Ẳ":"A","Ȧ":"A","Ǡ":"A","Ä":"A","Ǟ":"A","Ả":"A","Å":"A","Ǻ":"A","Ǎ":"A","Ȁ":"A","Ȃ":"A","Ạ":"A","Ậ":"A","Ặ":"A","Ḁ":"A","Ą":"A","Ⱥ":"A","Ɐ":"A","Ꜳ":"AA","Æ":"AE","Ǽ":"AE","Ǣ":"AE","Ꜵ":"AO","Ꜷ":"AU","Ꜹ":"AV","Ꜻ":"AV","Ꜽ":"AY","Ⓑ":"B","B":"B","Ḃ":"B","Ḅ":"B","Ḇ":"B","Ƀ":"B","Ƃ":"B","Ɓ":"B","Ⓒ":"C","C":"C","Ć":"C","Ĉ":"C","Ċ":"C","Č":"C","Ç":"C","Ḉ":"C","Ƈ":"C","Ȼ":"C","Ꜿ":"C","Ⓓ":"D","D":"D","Ḋ":"D","Ď":"D","Ḍ":"D","Ḑ":"D","Ḓ":"D","Ḏ":"D","Đ":"D","Ƌ":"D","Ɗ":"D","Ɖ":"D","Ꝺ":"D","DZ":"DZ","DŽ":"DZ","Dz":"Dz","Dž":"Dz","Ⓔ":"E","E":"E","È":"E","É":"E","Ê":"E","Ề":"E","Ế":"E","Ễ":"E","Ể":"E","Ẽ":"E","Ē":"E","Ḕ":"E","Ḗ":"E","Ĕ":"E","Ė":"E","Ë":"E","Ẻ":"E","Ě":"E","Ȅ":"E","Ȇ":"E","Ẹ":"E","Ệ":"E","Ȩ":"E","Ḝ":"E","Ę":"E","Ḙ":"E","Ḛ":"E","Ɛ":"E","Ǝ":"E","Ⓕ":"F","F":"F","Ḟ":"F","Ƒ":"F","Ꝼ":"F","Ⓖ":"G","G":"G","Ǵ":"G","Ĝ":"G","Ḡ":"G","Ğ":"G","Ġ":"G","Ǧ":"G","Ģ":"G","Ǥ":"G","Ɠ":"G","Ꞡ":"G","Ᵹ":"G","Ꝿ":"G","Ⓗ":"H","H":"H","Ĥ":"H","Ḣ":"H","Ḧ":"H","Ȟ":"H","Ḥ":"H","Ḩ":"H","Ḫ":"H","Ħ":"H","Ⱨ":"H","Ⱶ":"H","Ɥ":"H","Ⓘ":"I","I":"I","Ì":"I","Í":"I","Î":"I","Ĩ":"I","Ī":"I","Ĭ":"I","İ":"I","Ï":"I","Ḯ":"I","Ỉ":"I","Ǐ":"I","Ȉ":"I","Ȋ":"I","Ị":"I","Į":"I","Ḭ":"I","Ɨ":"I","Ⓙ":"J","J":"J","Ĵ":"J","Ɉ":"J","Ⓚ":"K","K":"K","Ḱ":"K","Ǩ":"K","Ḳ":"K","Ķ":"K","Ḵ":"K","Ƙ":"K","Ⱪ":"K","Ꝁ":"K","Ꝃ":"K","Ꝅ":"K","Ꞣ":"K","Ⓛ":"L","L":"L","Ŀ":"L","Ĺ":"L","Ľ":"L","Ḷ":"L","Ḹ":"L","Ļ":"L","Ḽ":"L","Ḻ":"L","Ł":"L","Ƚ":"L","Ɫ":"L","Ⱡ":"L","Ꝉ":"L","Ꝇ":"L","Ꞁ":"L","LJ":"LJ","Lj":"Lj","Ⓜ":"M","M":"M","Ḿ":"M","Ṁ":"M","Ṃ":"M","Ɱ":"M","Ɯ":"M","Ⓝ":"N","N":"N","Ǹ":"N","Ń":"N","Ñ":"N","Ṅ":"N","Ň":"N","Ṇ":"N","Ņ":"N","Ṋ":"N","Ṉ":"N","Ƞ":"N","Ɲ":"N","Ꞑ":"N","Ꞥ":"N","NJ":"NJ","Nj":"Nj","Ⓞ":"O","O":"O","Ò":"O","Ó":"O","Ô":"O","Ồ":"O","Ố":"O","Ỗ":"O","Ổ":"O","Õ":"O","Ṍ":"O","Ȭ":"O","Ṏ":"O","Ō":"O","Ṑ":"O","Ṓ":"O","Ŏ":"O","Ȯ":"O","Ȱ":"O","Ö":"O","Ȫ":"O","Ỏ":"O","Ő":"O","Ǒ":"O","Ȍ":"O","Ȏ":"O","Ơ":"O","Ờ":"O","Ớ":"O","Ỡ":"O","Ở":"O","Ợ":"O","Ọ":"O","Ộ":"O","Ǫ":"O","Ǭ":"O","Ø":"O","Ǿ":"O","Ɔ":"O","Ɵ":"O","Ꝋ":"O","Ꝍ":"O","Œ":"OE","Ƣ":"OI","Ꝏ":"OO","Ȣ":"OU","Ⓟ":"P","P":"P","Ṕ":"P","Ṗ":"P","Ƥ":"P","Ᵽ":"P","Ꝑ":"P","Ꝓ":"P","Ꝕ":"P","Ⓠ":"Q","Q":"Q","Ꝗ":"Q","Ꝙ":"Q","Ɋ":"Q","Ⓡ":"R","R":"R","Ŕ":"R","Ṙ":"R","Ř":"R","Ȑ":"R","Ȓ":"R","Ṛ":"R","Ṝ":"R","Ŗ":"R","Ṟ":"R","Ɍ":"R","Ɽ":"R","Ꝛ":"R","Ꞧ":"R","Ꞃ":"R","Ⓢ":"S","S":"S","ẞ":"S","Ś":"S","Ṥ":"S","Ŝ":"S","Ṡ":"S","Š":"S","Ṧ":"S","Ṣ":"S","Ṩ":"S","Ș":"S","Ş":"S","Ȿ":"S","Ꞩ":"S","Ꞅ":"S","Ⓣ":"T","T":"T","Ṫ":"T","Ť":"T","Ṭ":"T","Ț":"T","Ţ":"T","Ṱ":"T","Ṯ":"T","Ŧ":"T","Ƭ":"T","Ʈ":"T","Ⱦ":"T","Ꞇ":"T","Ꜩ":"TZ","Ⓤ":"U","U":"U","Ù":"U","Ú":"U","Û":"U","Ũ":"U","Ṹ":"U","Ū":"U","Ṻ":"U","Ŭ":"U","Ü":"U","Ǜ":"U","Ǘ":"U","Ǖ":"U","Ǚ":"U","Ủ":"U","Ů":"U","Ű":"U","Ǔ":"U","Ȕ":"U","Ȗ":"U","Ư":"U","Ừ":"U","Ứ":"U","Ữ":"U","Ử":"U","Ự":"U","Ụ":"U","Ṳ":"U","Ų":"U","Ṷ":"U","Ṵ":"U","Ʉ":"U","Ⓥ":"V","V":"V","Ṽ":"V","Ṿ":"V","Ʋ":"V","Ꝟ":"V","Ʌ":"V","Ꝡ":"VY","Ⓦ":"W","W":"W","Ẁ":"W","Ẃ":"W","Ŵ":"W","Ẇ":"W","Ẅ":"W","Ẉ":"W","Ⱳ":"W","Ⓧ":"X","X":"X","Ẋ":"X","Ẍ":"X","Ⓨ":"Y","Y":"Y","Ỳ":"Y","Ý":"Y","Ŷ":"Y","Ỹ":"Y","Ȳ":"Y","Ẏ":"Y","Ÿ":"Y","Ỷ":"Y","Ỵ":"Y","Ƴ":"Y","Ɏ":"Y","Ỿ":"Y","Ⓩ":"Z","Z":"Z","Ź":"Z","Ẑ":"Z","Ż":"Z","Ž":"Z","Ẓ":"Z","Ẕ":"Z","Ƶ":"Z","Ȥ":"Z","Ɀ":"Z","Ⱬ":"Z","Ꝣ":"Z","ⓐ":"a","a":"a","ẚ":"a","à":"a","á":"a","â":"a","ầ":"a","ấ":"a","ẫ":"a","ẩ":"a","ã":"a","ā":"a","ă":"a","ằ":"a","ắ":"a","ẵ":"a","ẳ":"a","ȧ":"a","ǡ":"a","ä":"a","ǟ":"a","ả":"a","å":"a","ǻ":"a","ǎ":"a","ȁ":"a","ȃ":"a","ạ":"a","ậ":"a","ặ":"a","ḁ":"a","ą":"a","ⱥ":"a","ɐ":"a","ꜳ":"aa","æ":"ae","ǽ":"ae","ǣ":"ae","ꜵ":"ao","ꜷ":"au","ꜹ":"av","ꜻ":"av","ꜽ":"ay","ⓑ":"b","b":"b","ḃ":"b","ḅ":"b","ḇ":"b","ƀ":"b","ƃ":"b","ɓ":"b","ⓒ":"c","c":"c","ć":"c","ĉ":"c","ċ":"c","č":"c","ç":"c","ḉ":"c","ƈ":"c","ȼ":"c","ꜿ":"c","ↄ":"c","ⓓ":"d","d":"d","ḋ":"d","ď":"d","ḍ":"d","ḑ":"d","ḓ":"d","ḏ":"d","đ":"d","ƌ":"d","ɖ":"d","ɗ":"d","ꝺ":"d","dz":"dz","dž":"dz","ⓔ":"e","e":"e","è":"e","é":"e","ê":"e","ề":"e","ế":"e","ễ":"e","ể":"e","ẽ":"e","ē":"e","ḕ":"e","ḗ":"e","ĕ":"e","ė":"e","ë":"e","ẻ":"e","ě":"e","ȅ":"e","ȇ":"e","ẹ":"e","ệ":"e","ȩ":"e","ḝ":"e","ę":"e","ḙ":"e","ḛ":"e","ɇ":"e","ɛ":"e","ǝ":"e","ⓕ":"f","f":"f","ḟ":"f","ƒ":"f","ꝼ":"f","ⓖ":"g","g":"g","ǵ":"g","ĝ":"g","ḡ":"g","ğ":"g","ġ":"g","ǧ":"g","ģ":"g","ǥ":"g","ɠ":"g","ꞡ":"g","ᵹ":"g","ꝿ":"g","ⓗ":"h","h":"h","ĥ":"h","ḣ":"h","ḧ":"h","ȟ":"h","ḥ":"h","ḩ":"h","ḫ":"h","ẖ":"h","ħ":"h","ⱨ":"h","ⱶ":"h","ɥ":"h","ƕ":"hv","ⓘ":"i","i":"i","ì":"i","í":"i","î":"i","ĩ":"i","ī":"i","ĭ":"i","ï":"i","ḯ":"i","ỉ":"i","ǐ":"i","ȉ":"i","ȋ":"i","ị":"i","į":"i","ḭ":"i","ɨ":"i","ı":"i","ⓙ":"j","j":"j","ĵ":"j","ǰ":"j","ɉ":"j","ⓚ":"k","k":"k","ḱ":"k","ǩ":"k","ḳ":"k","ķ":"k","ḵ":"k","ƙ":"k","ⱪ":"k","ꝁ":"k","ꝃ":"k","ꝅ":"k","ꞣ":"k","ⓛ":"l","l":"l","ŀ":"l","ĺ":"l","ľ":"l","ḷ":"l","ḹ":"l","ļ":"l","ḽ":"l","ḻ":"l","ſ":"l","ł":"l","ƚ":"l","ɫ":"l","ⱡ":"l","ꝉ":"l","ꞁ":"l","ꝇ":"l","lj":"lj","ⓜ":"m","m":"m","ḿ":"m","ṁ":"m","ṃ":"m","ɱ":"m","ɯ":"m","ⓝ":"n","n":"n","ǹ":"n","ń":"n","ñ":"n","ṅ":"n","ň":"n","ṇ":"n","ņ":"n","ṋ":"n","ṉ":"n","ƞ":"n","ɲ":"n","ʼn":"n","ꞑ":"n","ꞥ":"n","nj":"nj","ⓞ":"o","o":"o","ò":"o","ó":"o","ô":"o","ồ":"o","ố":"o","ỗ":"o","ổ":"o","õ":"o","ṍ":"o","ȭ":"o","ṏ":"o","ō":"o","ṑ":"o","ṓ":"o","ŏ":"o","ȯ":"o","ȱ":"o","ö":"o","ȫ":"o","ỏ":"o","ő":"o","ǒ":"o","ȍ":"o","ȏ":"o","ơ":"o","ờ":"o","ớ":"o","ỡ":"o","ở":"o","ợ":"o","ọ":"o","ộ":"o","ǫ":"o","ǭ":"o","ø":"o","ǿ":"o","ɔ":"o","ꝋ":"o","ꝍ":"o","ɵ":"o","œ":"oe","ƣ":"oi","ȣ":"ou","ꝏ":"oo","ⓟ":"p","p":"p","ṕ":"p","ṗ":"p","ƥ":"p","ᵽ":"p","ꝑ":"p","ꝓ":"p","ꝕ":"p","ⓠ":"q","q":"q","ɋ":"q","ꝗ":"q","ꝙ":"q","ⓡ":"r","r":"r","ŕ":"r","ṙ":"r","ř":"r","ȑ":"r","ȓ":"r","ṛ":"r","ṝ":"r","ŗ":"r","ṟ":"r","ɍ":"r","ɽ":"r","ꝛ":"r","ꞧ":"r","ꞃ":"r","ⓢ":"s","s":"s","ß":"s","ś":"s","ṥ":"s","ŝ":"s","ṡ":"s","š":"s","ṧ":"s","ṣ":"s","ṩ":"s","ș":"s","ş":"s","ȿ":"s","ꞩ":"s","ꞅ":"s","ẛ":"s","ⓣ":"t","t":"t","ṫ":"t","ẗ":"t","ť":"t","ṭ":"t","ț":"t","ţ":"t","ṱ":"t","ṯ":"t","ŧ":"t","ƭ":"t","ʈ":"t","ⱦ":"t","ꞇ":"t","ꜩ":"tz","ⓤ":"u","u":"u","ù":"u","ú":"u","û":"u","ũ":"u","ṹ":"u","ū":"u","ṻ":"u","ŭ":"u","ü":"u","ǜ":"u","ǘ":"u","ǖ":"u","ǚ":"u","ủ":"u","ů":"u","ű":"u","ǔ":"u","ȕ":"u","ȗ":"u","ư":"u","ừ":"u","ứ":"u","ữ":"u","ử":"u","ự":"u","ụ":"u","ṳ":"u","ų":"u","ṷ":"u","ṵ":"u","ʉ":"u","ⓥ":"v","v":"v","ṽ":"v","ṿ":"v","ʋ":"v","ꝟ":"v","ʌ":"v","ꝡ":"vy","ⓦ":"w","w":"w","ẁ":"w","ẃ":"w","ŵ":"w","ẇ":"w","ẅ":"w","ẘ":"w","ẉ":"w","ⱳ":"w","ⓧ":"x","x":"x","ẋ":"x","ẍ":"x","ⓨ":"y","y":"y","ỳ":"y","ý":"y","ŷ":"y","ỹ":"y","ȳ":"y","ẏ":"y","ÿ":"y","ỷ":"y","ẙ":"y","ỵ":"y","ƴ":"y","ɏ":"y","ỿ":"y","ⓩ":"z","z":"z","ź":"z","ẑ":"z","ż":"z","ž":"z","ẓ":"z","ẕ":"z","ƶ":"z","ȥ":"z","ɀ":"z","ⱬ":"z","ꝣ":"z","Ά":"Α","Έ":"Ε","Ή":"Η","Ί":"Ι","Ϊ":"Ι","Ό":"Ο","Ύ":"Υ","Ϋ":"Υ","Ώ":"Ω","ά":"α","έ":"ε","ή":"η","ί":"ι","ϊ":"ι","ΐ":"ι","ό":"ο","ύ":"υ","ϋ":"υ","ΰ":"υ","ώ":"ω","ς":"σ","’":"'"}}),u.define("select2/data/base",["../utils"],function(n){function s(e,t){s.__super__.constructor.call(this)}return n.Extend(s,n.Observable),s.prototype.current=function(e){throw new Error("The `current` method must be defined in child classes.")},s.prototype.query=function(e,t){throw new Error("The `query` method must be defined in child classes.")},s.prototype.bind=function(e,t){},s.prototype.destroy=function(){},s.prototype.generateResultId=function(e,t){e=e.id+"-result-";return e+=n.generateChars(4),null!=t.id?e+="-"+t.id.toString():e+="-"+n.generateChars(4),e},s}),u.define("select2/data/select",["./base","../utils","jquery"],function(e,a,l){function n(e,t){this.$element=e,this.options=t,n.__super__.constructor.call(this)}return a.Extend(n,e),n.prototype.current=function(e){var t=this;e(Array.prototype.map.call(this.$element[0].querySelectorAll(":checked"),function(e){return t.item(l(e))}))},n.prototype.select=function(i){var e,r=this;if(i.selected=!0,null!=i.element&&"option"===i.element.tagName.toLowerCase())return i.element.selected=!0,void this.$element.trigger("input").trigger("change");this.$element.prop("multiple")?this.current(function(e){var t=[];(i=[i]).push.apply(i,e);for(var n=0;nthis.maximumInputLength?this.trigger("results:message",{message:"inputTooLong",args:{maximum:this.maximumInputLength,input:t.term,params:t}}):e.call(this,t,n)},e}),u.define("select2/data/maximumSelectionLength",[],function(){function e(e,t,n){this.maximumSelectionLength=n.get("maximumSelectionLength"),e.call(this,t,n)}return e.prototype.bind=function(e,t,n){var s=this;e.call(this,t,n),t.on("select",function(){s._checkIfMaximumSelected()})},e.prototype.query=function(e,t,n){var s=this;this._checkIfMaximumSelected(function(){e.call(s,t,n)})},e.prototype._checkIfMaximumSelected=function(e,t){var n=this;this.current(function(e){e=null!=e?e.length:0;0=n.maximumSelectionLength?n.trigger("results:message",{message:"maximumSelected",args:{maximum:n.maximumSelectionLength}}):t&&t()})},e}),u.define("select2/dropdown",["jquery","./utils"],function(t,e){function n(e,t){this.$element=e,this.options=t,n.__super__.constructor.call(this)}return e.Extend(n,e.Observable),n.prototype.render=function(){var e=t('');return e.attr("dir",this.options.get("dir")),this.$dropdown=e},n.prototype.bind=function(){},n.prototype.position=function(e,t){},n.prototype.destroy=function(){this.$dropdown.remove()},n}),u.define("select2/dropdown/search",["jquery"],function(r){function e(){}return e.prototype.render=function(e){var t=e.call(this),n=this.options.get("translations").get("search"),e=r('');return this.$searchContainer=e,this.$search=e.find("input"),this.$search.prop("autocomplete",this.options.get("autocomplete")),this.$search.attr("aria-label",n()),t.prepend(e),t},e.prototype.bind=function(e,t,n){var s=this,i=t.id+"-results";e.call(this,t,n),this.$search.on("keydown",function(e){s.trigger("keypress",e),s._keyUpPrevented=e.isDefaultPrevented()}),this.$search.on("input",function(e){r(this).off("keyup")}),this.$search.on("keyup input",function(e){s.handleSearch(e)}),t.on("open",function(){s.$search.attr("tabindex",0),s.$search.attr("aria-controls",i),s.$search.trigger("focus"),window.setTimeout(function(){s.$search.trigger("focus")},0)}),t.on("close",function(){s.$search.attr("tabindex",-1),s.$search.removeAttr("aria-controls"),s.$search.removeAttr("aria-activedescendant"),s.$search.val(""),s.$search.trigger("blur")}),t.on("focus",function(){t.isOpen()||s.$search.trigger("focus")}),t.on("results:all",function(e){null!=e.query.term&&""!==e.query.term||(s.showSearch(e)?s.$searchContainer[0].classList.remove("select2-search--hide"):s.$searchContainer[0].classList.add("select2-search--hide"))}),t.on("results:focus",function(e){e.data._resultId?s.$search.attr("aria-activedescendant",e.data._resultId):s.$search.removeAttr("aria-activedescendant")})},e.prototype.handleSearch=function(e){var t;this._keyUpPrevented||(t=this.$search.val(),this.trigger("query",{term:t})),this._keyUpPrevented=!1},e.prototype.showSearch=function(e,t){return!0},e}),u.define("select2/dropdown/hidePlaceholder",[],function(){function e(e,t,n,s){this.placeholder=this.normalizePlaceholder(n.get("placeholder")),e.call(this,t,n,s)}return e.prototype.append=function(e,t){t.results=this.removePlaceholder(t.results),e.call(this,t)},e.prototype.normalizePlaceholder=function(e,t){return t="string"==typeof t?{id:"",text:t}:t},e.prototype.removePlaceholder=function(e,t){for(var n=t.slice(0),s=t.length-1;0<=s;s--){var i=t[s];this.placeholder.id===i.id&&n.splice(s,1)}return n},e}),u.define("select2/dropdown/infiniteScroll",["jquery"],function(n){function e(e,t,n,s){this.lastParams={},e.call(this,t,n,s),this.$loadingMore=this.createLoadingMore(),this.loading=!1}return e.prototype.append=function(e,t){this.$loadingMore.remove(),this.loading=!1,e.call(this,t),this.showLoadingMore(t)&&(this.$results.append(this.$loadingMore),this.loadMoreIfNeeded())},e.prototype.bind=function(e,t,n){var s=this;e.call(this,t,n),t.on("query",function(e){s.lastParams=e,s.loading=!0}),t.on("query:append",function(e){s.lastParams=e,s.loading=!0}),this.$results.on("scroll",this.loadMoreIfNeeded.bind(this))},e.prototype.loadMoreIfNeeded=function(){var e=n.contains(document.documentElement,this.$loadingMore[0]);!this.loading&&e&&(e=this.$results.offset().top+this.$results.outerHeight(!1),this.$loadingMore.offset().top+this.$loadingMore.outerHeight(!1)<=e+50&&this.loadMore())},e.prototype.loadMore=function(){this.loading=!0;var e=n.extend({},{page:1},this.lastParams);e.page++,this.trigger("query:append",e)},e.prototype.showLoadingMore=function(e,t){return t.pagination&&t.pagination.more},e.prototype.createLoadingMore=function(){var e=n('
    • '),t=this.options.get("translations").get("loadingMore");return e.html(t(this.lastParams)),e},e}),u.define("select2/dropdown/attachBody",["jquery","../utils"],function(u,o){function e(e,t,n){this.$dropdownParent=u(n.get("dropdownParent")||document.body),e.call(this,t,n)}return e.prototype.bind=function(e,t,n){var s=this;e.call(this,t,n),t.on("open",function(){s._showDropdown(),s._attachPositioningHandler(t),s._bindContainerResultHandlers(t)}),t.on("close",function(){s._hideDropdown(),s._detachPositioningHandler(t)}),this.$dropdownContainer.on("mousedown",function(e){e.stopPropagation()})},e.prototype.destroy=function(e){e.call(this),this.$dropdownContainer.remove()},e.prototype.position=function(e,t,n){t.attr("class",n.attr("class")),t[0].classList.remove("select2"),t[0].classList.add("select2-container--open"),t.css({position:"absolute",top:-999999}),this.$container=n},e.prototype.render=function(e){var t=u(""),e=e.call(this);return t.append(e),this.$dropdownContainer=t},e.prototype._hideDropdown=function(e){this.$dropdownContainer.detach()},e.prototype._bindContainerResultHandlers=function(e,t){var n;this._containerResultsHandlersBound||(n=this,t.on("results:all",function(){n._positionDropdown(),n._resizeDropdown()}),t.on("results:append",function(){n._positionDropdown(),n._resizeDropdown()}),t.on("results:message",function(){n._positionDropdown(),n._resizeDropdown()}),t.on("select",function(){n._positionDropdown(),n._resizeDropdown()}),t.on("unselect",function(){n._positionDropdown(),n._resizeDropdown()}),this._containerResultsHandlersBound=!0)},e.prototype._attachPositioningHandler=function(e,t){var n=this,s="scroll.select2."+t.id,i="resize.select2."+t.id,r="orientationchange.select2."+t.id,t=this.$container.parents().filter(o.hasScroll);t.each(function(){o.StoreData(this,"select2-scroll-position",{x:u(this).scrollLeft(),y:u(this).scrollTop()})}),t.on(s,function(e){var t=o.GetData(this,"select2-scroll-position");u(this).scrollTop(t.y)}),u(window).on(s+" "+i+" "+r,function(e){n._positionDropdown(),n._resizeDropdown()})},e.prototype._detachPositioningHandler=function(e,t){var n="scroll.select2."+t.id,s="resize.select2."+t.id,t="orientationchange.select2."+t.id;this.$container.parents().filter(o.hasScroll).off(n),u(window).off(n+" "+s+" "+t)},e.prototype._positionDropdown=function(){var e=u(window),t=this.$dropdown[0].classList.contains("select2-dropdown--above"),n=this.$dropdown[0].classList.contains("select2-dropdown--below"),s=null,i=this.$container.offset();i.bottom=i.top+this.$container.outerHeight(!1);var r={height:this.$container.outerHeight(!1)};r.top=i.top,r.bottom=i.top+r.height;var o=this.$dropdown.outerHeight(!1),a=e.scrollTop(),l=e.scrollTop()+e.height(),c=ai.bottom+o,a={left:i.left,top:r.bottom},l=this.$dropdownParent;"static"===l.css("position")&&(l=l.offsetParent());i={top:0,left:0};(u.contains(document.body,l[0])||l[0].isConnected)&&(i=l.offset()),a.top-=i.top,a.left-=i.left,t||n||(s="below"),e||!c||t?!c&&e&&t&&(s="below"):s="above",("above"==s||t&&"below"!==s)&&(a.top=r.top-i.top-o),null!=s&&(this.$dropdown[0].classList.remove("select2-dropdown--below"),this.$dropdown[0].classList.remove("select2-dropdown--above"),this.$dropdown[0].classList.add("select2-dropdown--"+s),this.$container[0].classList.remove("select2-container--below"),this.$container[0].classList.remove("select2-container--above"),this.$container[0].classList.add("select2-container--"+s)),this.$dropdownContainer.css(a)},e.prototype._resizeDropdown=function(){var e={width:this.$container.outerWidth(!1)+"px"};this.options.get("dropdownAutoWidth")&&(e.minWidth=e.width,e.position="relative",e.width="auto"),this.$dropdown.css(e)},e.prototype._showDropdown=function(e){this.$dropdownContainer.appendTo(this.$dropdownParent),this._positionDropdown(),this._resizeDropdown()},e}),u.define("select2/dropdown/minimumResultsForSearch",[],function(){function e(e,t,n,s){this.minimumResultsForSearch=n.get("minimumResultsForSearch"),this.minimumResultsForSearch<0&&(this.minimumResultsForSearch=1/0),e.call(this,t,n,s)}return e.prototype.showSearch=function(e,t){return!(function e(t){for(var n=0,s=0;s');return e.attr("dir",this.options.get("dir")),this.$container=e,this.$container[0].classList.add("select2-container--"+this.options.get("theme")),r.StoreData(e[0],"element",this.$element),e},o}),u.define("jquery-mousewheel",["jquery"],function(e){return e}),u.define("jquery.select2",["jquery","jquery-mousewheel","./select2/core","./select2/defaults","./select2/utils"],function(i,e,r,t,o){var a;return null==i.fn.select2&&(a=["open","close","destroy"],i.fn.select2=function(t){if("object"==typeof(t=t||{}))return this.each(function(){var e=i.extend(!0,{},t);new r(i(this),e)}),this;if("string"!=typeof t)throw new Error("Invalid arguments for Select2: "+t);var n,s=Array.prototype.slice.call(arguments,1);return this.each(function(){var e=o.GetData(this,"select2");null==e&&window.console&&console.error&&console.error("The select2('"+t+"') method was called on an element that is not using Select2."),n=e[t].apply(e,s)}),-1this.countryCodeMaxLen&&(this.countryCodeMaxLen=c.length),this.q.hasOwnProperty(c)||(this.q[c]=[]);for(var e=0;e-1})}else if(this.d.excludeCountries.length){var b=this.d.excludeCountries.map(function(a){return a.toLowerCase()});this.p=i.filter(function(a){return-1===b.indexOf(a.iso2)})}else this.p=i}},{key:"_d0",value:function(){for(var a=0;ab.name?1:0}},{key:"_d2",value:function(){this.countryCodeMaxLen=0,this.dialCodes={},this.q={};for(var a=0;a"),this.d.showFlags&&(d+="
      ")),d+="".concat(f.name,""),d+="+".concat(f.dialCode,""),d+=""}this.m.insertAdjacentHTML("beforeend",d)}},{key:"_h",value:function(){var a=this.a.getAttribute("value"),b=this.a.value,c=a&&"+"===a.charAt(0)&&(!b||"+"!==b.charAt(0)),d=c?a:b,e=this._5(d),f=this._w(d),g=this.d,h=g.initialCountry,i=g.autoInsertDialCode;e&&!f?this._v(d):"auto"!==h&&(h?this._z(h.toLowerCase()):e&&f?this._z("us"):(this.j=this.preferredCountries.length?this.preferredCountries[0].iso2:this.p[0].iso2,d||this._z(this.j)),!d&&i&&(this.a.value="+".concat(this.s.dialCode))),d&&this._u(d)}},{key:"_i",value:function(){this._j(),this.d.autoInsertDialCode&&this._l(),this.d.allowDropdown&&this._i2(),this.hiddenInput&&this._i0()}},{key:"_i0",value:function(){var a=this;this._a14=function(){a.hiddenInput.value=a.getNumber()},this.a.form&&this.a.form.addEventListener("submit",this._a14)}},{key:"_i1",value:function(){for(var a=this.a;a&&"LABEL"!==a.tagName;)a=a.parentNode;return a}},{key:"_i2",value:function(){var a=this;this._a9=function(b){a.m.classList.contains("iti__hide")?a.a.focus():b.preventDefault()};var b=this._i1();b&&b.addEventListener("click",this._a9),this._a10=function(){!a.m.classList.contains("iti__hide")||a.a.disabled||a.a.readOnly||a._n()},this.selectedFlag.addEventListener("click",this._a10),this._a11=function(b){a.m.classList.contains("iti__hide")&&-1!==["ArrowUp","Up","ArrowDown","Down"," ","Enter"].indexOf(b.key)&&(b.preventDefault(),b.stopPropagation(),a._n()),"Tab"===b.key&&a._2()},this.k.addEventListener("keydown",this._a11)}},{key:"_i3",value:function(){var a=this;this.d.utilsScript&&!window.intlTelInputUtils?window.intlTelInputGlobals.documentReady()?window.intlTelInputGlobals.loadUtils(this.d.utilsScript):window.addEventListener("load",function(){window.intlTelInputGlobals.loadUtils(a.d.utilsScript)}):this.i0(),"auto"===this.d.initialCountry?this._i4():this.h()}},{key:"_i4",value:function(){window.intlTelInputGlobals.autoCountry?this.handleAutoCountry():window.intlTelInputGlobals.startedLoadingAutoCountry||(window.intlTelInputGlobals.startedLoadingAutoCountry=!0,"function"==typeof this.d.geoIpLookup&&this.d.geoIpLookup(function(a){window.intlTelInputGlobals.autoCountry=a.toLowerCase(),setTimeout(function(){return q("handleAutoCountry")})},function(){return q("rejectAutoCountryPromise")}))}},{key:"_j",value:function(){var a=this;this._a12=function(){a._v(a.a.value)&&a._m2CountryChange()},this.a.addEventListener("keyup",this._a12),this._a13=function(){setTimeout(a._a12)},this.a.addEventListener("cut",this._a13),this.a.addEventListener("paste",this._a13)}},{key:"_j2",value:function(a){var b=this.a.getAttribute("maxlength");return b&&a.length>b?a.substr(0,b):a}},{key:"_l",value:function(){var a=this;this._a8=function(){a._l2()},this.a.form&&this.a.form.addEventListener("submit",this._a8),this.a.addEventListener("blur",this._a8)}},{key:"_l2",value:function(){if("+"===this.a.value.charAt(0)){var a=this._m(this.a.value);a&&this.s.dialCode!==a||(this.a.value="")}}},{key:"_m",value:function(a){return a.replace(/\D/g,"")}},{key:"_m2",value:function(a){var b=document.createEvent("Event");b.initEvent(a,!0,!0),this.a.dispatchEvent(b)}},{key:"_n",value:function(){this.m.classList.remove("iti__hide"),this.selectedFlag.setAttribute("aria-expanded","true"),this._o(),this.b&&(this._x(this.b,!1),this._3(this.b,!0)),this._p(),this.u.classList.add("iti__arrow--up"),this._m2("open:countrydropdown")}},{key:"_n2",value:function(a,b,c){c&&!a.classList.contains(b)?a.classList.add(b):!c&&a.classList.contains(b)&&a.classList.remove(b)}},{key:"_o",value:function(){var a=this;if(this.d.dropdownContainer&&this.d.dropdownContainer.appendChild(this.dropdown),!this.g){var b=this.a.getBoundingClientRect(),c=window.pageYOffset||document.documentElement.scrollTop,d=b.top+c,e=this.m.offsetHeight,f=d+this.a.offsetHeight+ec;if(this._n2(this.m,"iti__country-list--dropup",!f&&g),this.d.dropdownContainer){var h=!f&&g?0:this.a.offsetHeight;this.dropdown.style.top="".concat(d+h,"px"),this.dropdown.style.left="".concat(b.left+document.body.scrollLeft,"px"),this._a4=function(){return a._2()},window.addEventListener("scroll",this._a4)}}}},{key:"_o2",value:function(a){for(var b=a;b&&b!==this.m&&!b.classList.contains("iti__country");)b=b.parentNode;return b===this.m?null:b}},{key:"_p",value:function(){var a=this;this._a0=function(b){var c=a._o2(b.target);c&&a._x(c,!1)},this.m.addEventListener("mouseover",this._a0),this._a1=function(b){var c=a._o2(b.target);c&&a._1(c)},this.m.addEventListener("click",this._a1);var b=!0;this._a2=function(){b||a._2(),b=!1},document.documentElement.addEventListener("click",this._a2);var c="",d=null;this._a3=function(b){b.preventDefault(),"ArrowUp"===b.key||"Up"===b.key||"ArrowDown"===b.key||"Down"===b.key?a._q(b.key):"Enter"===b.key?a._r():"Escape"===b.key?a._2():/^[a-zA-ZÀ-ÿа-яА-Я ]$/.test(b.key)&&(d&&clearTimeout(d),c+=b.key.toLowerCase(),a._s(c),d=setTimeout(function(){c=""},1e3))},document.addEventListener("keydown",this._a3)}},{key:"_q",value:function(a){var b="ArrowUp"===a||"Up"===a?this.c.previousElementSibling:this.c.nextElementSibling;b&&(b.classList.contains("iti__divider")&&(b="ArrowUp"===a||"Up"===a?b.previousElementSibling:b.nextElementSibling),this._x(b,!0))}},{key:"_r",value:function(){this.c&&this._1(this.c)}},{key:"_s",value:function(a){for(var b=0;bg){b&&(k+=l);var m=e-h;c.scrollTop=k-m}}},{key:"_4",value:function(a){var b,c=this.a.value,d="+".concat(a);if("+"===c.charAt(0)){var e=this._5(c);b=e?c.replace(e,d):d,this.a.value=b}else this.d.autoInsertDialCode&&(b=c?d+c:d,this.a.value=b)}},{key:"_5",value:function(a,b){var c="";if("+"===a.charAt(0))for(var d="",e=0;ea.length?!1:M(Ga,a)}function Ja(a){return M(Ba,a)?N(a,wa):N(a,va)}function Ka(a){var b=Ja(a.toString());a.i="";a.g(b)}function La(a){return null!=a&&(1!=w(a,9)||-1!=t(a,9)[0])}function N(a,b){for(var c=new C,d,f=a.length,e=0;eb?2:e[e.length-1]=b.i.length)throw Error("Phone number too short after IDD"); +a:{a=b.toString();if(0!=a.length&&"0"!=a.charAt(0))for(f=a.length,b=1;3>=b&&b<=f;++b)if(c=parseInt(a.substring(0,b),10),c in I){d.g(a.substring(b));d=c;break a}d=0}if(0!=d)return q(e,1,d),d;throw Error("Invalid country calling code");}if(null!=c&&(g=v(c,10),h=""+g,l=b.toString(),0==l.lastIndexOf(h,0)&&(h=new C(l.substring(h.length)),l=p(c,1),l=new RegExp(v(l,2)),Qa(h,c,null),h=h.toString(),!M(l,b.toString())&&M(l,h)||3==W(a,b.toString(),c,-1))))return d.g(h),f&&q(e,6,10),q(e,1,g),g;q(e,1,0);return 0} +function Qa(a,b,c){var d=a.toString(),f=d.length,e=p(b,15);if(0!=f&&null!=e&&0!=e.length){var g=new RegExp("^(?:"+e+")");if(f=g.exec(d)){e=new RegExp(v(p(b,1),2));var h=M(e,d),l=f.length-1;b=p(b,16);if(null==b||0==b.length||null==f[l]||0==f[l].length){if(!h||M(e,d.substring(f[0].length)))null!=c&&0=b.length)e="";else{var g=b.indexOf(";",e);e=-1!==g?b.substring(e,g):b.substring(e)}var h=e;null==h?g=!0:0===h.length?g=!1:(g=Ca.exec(h),h=Da.exec(h),g=null!==g||null!==h);if(!g)throw Error("The string supplied did not seem to be a phone number"); +null!=e?("+"===e.charAt(0)&&f.g(e),e=b.indexOf("tel:"),f.g(b.substring(0<=e?e+4:0,b.indexOf(";phone-context=")))):(e=f.g,g=b??"",h=g.search(ya),0<=h?(g=g.substring(h),g=g.replace(Aa,""),h=g.search(za),0<=h&&(g=g.substring(0,h))):g="",e.call(f,g));e=f.toString();g=e.indexOf(";isub=");0b.i.length)throw Error("The string supplied is too short to be a phone number");null!=g&&(c=new C,f=new C(b.toString()),Qa(f,g,c),a=W(a,f.toString(),g,-1),2!=a&&4!=a&&5!=a&&(b=f,d&&0a)throw Error("The string supplied is too short to be a phone number");if(17{try{const f=J.g(),e=Z(f,a,b);var d=X(f,e);return 0==d||4==d?Ma(f,e,"undefined"===typeof c?0:c):a}catch(f){return a}});k("intlTelInputUtils.getExampleNumber",(a,b,c)=>{try{const h=J.g();a:{var d=h;if(O(a)){var f=T(S(d,a),c);try{if(null!=f.g[6]){var e=p(f,6);var g=Ra(d,e,a,!1);break a}}catch(l){}}g=null}return Ma(h,g,b?2:1)}catch(h){return""}});k("intlTelInputUtils.getExtension",(a,b)=>{try{return p(Z(J.g(),a,b),3)}catch(c){return""}}); +k("intlTelInputUtils.getNumberType",(a,b)=>{try{const h=J.g();var c=Z(h,a,b);a=h;var d=Oa(a,c),f=Q(a,v(c,1),d);if(null==f)var e=-1;else{var g=P(c);e=U(g,f)}return e}catch(h){return-99}}); +k("intlTelInputUtils.getValidationError",(a,b)=>{try{const c=J.g(),d=Z(c,a,b);return X(c,d)}catch(c){return"Invalid country calling code"===c.message?1:"Phone number too short after IDD"===c.message||"The string supplied is too short to be a phone number"===c.message?2:"The string supplied is too long to be a phone number"===c.message?3:-99}}); +k("intlTelInputUtils.isValidNumber",(a,b)=>{try{const Y=J.g();var c=Z(Y,a,b);a=Y;var d=Oa(a,c);var f=v(c,1),e=Q(a,f,d),g;if(!(g=null==e)){var h;if(h="001"!=d){var l=S(a,d);if(null==l)throw Error("Invalid region code: "+d);var A=v(l,10);h=f!=A}g=h}if(g)var G=!1;else{var Sa=P(c);G=-1!=U(Sa,e)}return G}catch(Y){return!1}});k("intlTelInputUtils.isPossibleNumber",(a,b)=>{try{const c=J.g(),d=Z(c,a,b);return 0===X(c,d)}catch(c){return!1}}); +k("intlTelInputUtils.numberFormat",{E164:0,INTERNATIONAL:1,NATIONAL:2,RFC3966:3});k("intlTelInputUtils.numberType",{FIXED_LINE:0,MOBILE:1,FIXED_LINE_OR_MOBILE:2,TOLL_FREE:3,PREMIUM_RATE:4,SHARED_COST:5,VOIP:6,PERSONAL_NUMBER:7,PAGER:8,UAN:9,VOICEMAIL:10,UNKNOWN:-1});k("intlTelInputUtils.validationError",{IS_POSSIBLE:0,INVALID_COUNTRY_CODE:1,TOO_SHORT:2,TOO_LONG:3,IS_POSSIBLE_LOCAL_ONLY:4,INVALID_LENGTH:5});})(); diff --git a/site/tests/checkout-completed.test.mjs b/site/tests/checkout-completed.test.mjs new file mode 100644 index 00000000..c2848c4b --- /dev/null +++ b/site/tests/checkout-completed.test.mjs @@ -0,0 +1,509 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import test from 'node:test'; +import vm from 'node:vm'; + +// The page-state harness injects module dependencies and browser APIs. A Hugo +// consumer build separately verifies production import resolution and output. +const completedSource = fs.readFileSync( + new URL('../assets/js/pages/checkout/completed.js', import.meta.url), + 'utf8' +).replace(/^import .*;\n/gm, ''); +assert.doesNotMatch( + completedSource, + /^\s*import\b/m, + 'completed-page harness did not strip every import' +); +const completedPageUrlSource = fs.readFileSync( + new URL('../assets/js/pages/checkout/completed-page-url.js', import.meta.url), + 'utf8' +); +const {getCheckoutPageUrl, getCompletedPageUrl} = await import( + `data:text/javascript,${encodeURIComponent(completedPageUrlSource)}` +); +const orderIdSource = fs.readFileSync( + new URL('../assets/js/pages/checkout/order-id.js', import.meta.url), + 'utf8' +); +const {getOrderId} = await import( + `data:text/javascript,${encodeURIComponent(orderIdSource)}` +); +const viewIds = [ + 'payment-in-progress', + 'payment-completed', + 'payment-failed', + 'payment-refunded', + 'payment-charged-back', + 'payment-status-error', + 'payment-order-not-found', + 'payment-status-unknown' +]; +const maxPollingDurationMs = 15 * 60 * 1000; + +test('keep polling until a pending payment settles', async () => { + const page = await render([ + {order: {paymentStatus: 'SENT_FOR_PROCESSING', completed: false}}, + {order: {paymentStatus: 'SETTLED', completed: true}} + ]); + + assert.equal(page.isVisible('payment-in-progress'), true); + assert.equal(page.scheduledPolls(), 1); + assert.equal(page.nextPollDelay(), 3000); + + await page.runNextPoll(); + + assert.equal(page.isVisible('payment-completed'), true); + assert.equal(page.scheduledPolls(), 0); +}); + +test('back off pending polling to a 30-second interval', async () => { + const pending = {order: {paymentStatus: 'SENT_FOR_PROCESSING', completed: false}}; + const page = await render([pending, pending, pending, pending, pending]); + + assert.equal(page.nextPollDelay(), 3000); + await page.runNextPoll(); + assert.equal(page.nextPollDelay(), 5000); + await page.runNextPoll(); + assert.equal(page.nextPollDelay(), 10000); + await page.runNextPoll(); + assert.equal(page.nextPollDelay(), 30000); + await page.runNextPoll(); + assert.equal(page.nextPollDelay(), 30000); +}); + +for (const [status, view] of [ + ['SETTLED', 'payment-completed'], + ['ABANDONED', 'payment-failed'], + ['FAILED', 'payment-failed'], + ['VOIDED', 'payment-failed'], + ['REFUNDED', 'payment-refunded'], + ['CHARGED_BACK', 'payment-charged-back'] +]) { + test(`stop polling and show ${view} for ${status}`, async () => { + const page = await render([{order: {paymentStatus: status, completed: false}}]); + + assert.equal(page.isVisible(view), true); + assert.equal(page.scheduledPolls(), 0); + }); +} + +test('fall back to the legacy completed flag when payment status is absent', async () => { + const page = await render([{order: {completed: true}}]); + + assert.equal(page.isVisible('payment-completed'), true); + assert.equal(page.scheduledPolls(), 0); +}); + +test('show an error after repeated failures and recover to in progress', async () => { + const page = await render([ + {error: {status: 500, statusText: 'Unavailable'}}, + {error: {status: 500, statusText: 'Unavailable'}}, + {error: {status: 500, statusText: 'Unavailable'}}, + {order: {paymentStatus: 'AUTHORISED', completed: false}} + ]); + + assert.equal(page.nextPollDelay(), 3000); + await page.runNextPoll(); + assert.equal(page.nextPollDelay(), 5000); + await page.runNextPoll(); + assert.equal(page.isVisible('payment-status-error'), true); + assert.equal(page.nextPollDelay(), 10000); + + await page.runNextPoll(); + assert.equal(page.isVisible('payment-in-progress'), true); + assert.equal(page.scheduledPolls(), 1); + assert.equal(page.nextPollDelay(), 30000); +}); + +test('stop a retry chain when a terminal status arrives', async () => { + const page = await render([ + {error: {status: 503, statusText: 'Unavailable'}}, + {order: {paymentStatus: 'FAILED', completed: false}} + ]); + + assert.equal(page.nextPollDelay(), 3000); + await page.runNextPoll(); + + assert.equal(page.requests(), 2); + assert.equal(page.isVisible('payment-failed'), true); + assert.equal(page.scheduledPolls(), 0); + assert.equal(page.nextPollDelay(), undefined); +}); + +test('link a failed payment back to its checkout', async () => { + const page = await render([ + {order: {paymentStatus: 'FAILED', completed: false}} + ]); + + assert.equal(page.isVisible('payment-failed'), true); + assert.equal(page.backToCheckoutHidden(), false); + assert.equal( + page.backToCheckoutHref(), + 'https://example.com/checkout/?orderId=current-order' + ); +}); + +test('retry one not-found response before recovering', async () => { + const page = await render([ + {error: {status: 404, statusText: 'Not Found'}}, + {order: {paymentStatus: 'SETTLED', completed: true}} + ]); + + assert.equal(page.isVisible('payment-in-progress'), true); + assert.equal(page.scheduledPolls(), 1); + + await page.runNextPoll(); + + assert.equal(page.isVisible('payment-completed'), true); + assert.equal(page.scheduledPolls(), 0); +}); + +test('stop polling after two consecutive not-found responses', async () => { + const notFound = {error: {status: 404, statusText: 'Not Found'}}; + const page = await render([notFound, notFound]); + + assert.equal(page.isVisible('payment-in-progress'), true); + assert.equal(page.scheduledPolls(), 1); + + await page.runNextPoll(); + + assert.equal(page.isVisible('payment-order-not-found'), true); + assert.equal(page.scheduledPolls(), 0); +}); + +test('reset the not-found count after another response', async () => { + const notFound = {error: {status: 404, statusText: 'Not Found'}}; + const page = await render([ + notFound, + {error: {status: 503, statusText: 'Unavailable'}}, + notFound, + notFound + ]); + + await page.runNextPoll(); + await page.runNextPoll(); + assert.equal(page.isVisible('payment-in-progress'), true); + + await page.runNextPoll(); + assert.equal(page.isVisible('payment-order-not-found'), true); + assert.equal(page.requests(), 4); +}); + +test('stop polling after 15 minutes and show a neutral result', async () => { + const pending = Array.from({length: 40}, () => ({ + order: {paymentStatus: 'SENT_FOR_PROCESSING', completed: false} + })); + const page = await render(pending); + + while (!page.isVisible('payment-status-unknown')) { + await page.runNextTimer(); + } + + assert.equal(page.elapsedMs(), maxPollingDurationMs); + assert.ok(page.requests() < 40); + assert.equal(page.scheduledPolls(), 0); + assert.equal(page.scheduledTimers(), 0); +}); + +test('apply the 15-minute deadline to a hanging request', async () => { + const page = await render([{pending: true}]); + + assert.equal(page.isVisible('payment-in-progress'), true); + assert.equal(page.scheduledPolls(), 0); + assert.equal(page.nextTimerDelay(), maxPollingDurationMs); + + await page.runNextTimer(); + + assert.equal(page.isVisible('payment-status-unknown'), true); + assert.equal(page.scheduledTimers(), 0); +}); + +test('show a neutral result without polling when the order ID is absent', async () => { + const page = await render([], ''); + + assert.equal(page.isVisible('payment-status-unknown'), true); + for (const view of viewIds.filter(view => view !== 'payment-status-unknown')) { + assert.equal(page.isVisible(view), false); + } + assert.equal(page.requests(), 0); + assert.equal(page.scheduledPolls(), 0); + assert.equal(page.backToCheckoutHidden(), true); +}); + +test('keep the neutral result when payment configuration is absent', async () => { + const page = await render([], '?orderId=current-order', {params: {}}); + + assert.equal(page.isVisible('payment-status-unknown'), true); + assert.equal(page.clientCreations(), 0); + assert.equal(page.requests(), 0); + assert.equal(page.scheduledTimers(), 0); +}); + +test('keep the neutral result when the payment client cannot initialize', async () => { + const page = await render([], '?orderId=current-order', { + clientError: new Error('Client initialization failed') + }); + + assert.equal(page.isVisible('payment-status-unknown'), true); + assert.equal(page.clientCreations(), 1); + assert.equal(page.requests(), 0); + assert.equal(page.scheduledTimers(), 0); +}); + +test('keep the order ID in the visible URL', () => { + const initialUrl = 'https://example.com/checkout-completed/' + + '?campaign=sale&orderId=current-order&orderId=ignored#result'; + const browser = createBrowserState(initialUrl); + + const orderId = getOrderId(browser.location); + + assert.equal(orderId, 'current-order'); + assert.equal(browser.location.href, initialUrl); +}); + +test('carry only the order ID to a directory completion URL', () => { + const actual = getCompletedPageUrl( + 'https://example.com/checkout/?orderId=current-order&campaign=sale#form', + 'current-order' + ); + + assert.equal( + actual, + 'https://example.com/checkout-completed/?orderId=current-order' + ); +}); + +test('carry only the order ID back to a directory checkout URL', () => { + const actual = getCheckoutPageUrl( + 'https://example.com/checkout-completed/?campaign=sale#result', + 'current-order' + ); + + assert.equal( + actual, + 'https://example.com/checkout/?orderId=current-order' + ); +}); + +test('reject a non-completion URL as a checkout-link source', () => { + assert.equal( + getCheckoutPageUrl( + 'https://example.com/payment-result/?orderId=current-order', + 'current-order' + ), + '' + ); +}); + +/** + * Runs the completion page against predefined order API results. + * + * @param {Array} responses order responses or request errors + * @param {string} [search] completion-page query string + * @param {Object} [options] harness overrides + * @return {Promise} page-state test harness + */ +async function render( + responses, + search = '?orderId=current-order', + options = {} +) { + const views = Object.fromEntries( + viewIds.map(id => [id, {hidden: id !== 'payment-status-unknown'}]) + ); + const backToCheckoutLink = {hidden: true, href: ''}; + const scheduled = new Map(); + const queuedResponses = [...responses]; + let currentTimeMs = 0; + let nextTimerId = 1; + let deadlineTimerId; + let clientCreations = 0; + let requests = 0; + const browser = createBrowserState( + `https://example.com/checkout-completed/${search}`, + options.historyState || null + ); + const context = { + params: Object.hasOwn(options, 'params') ? options.params : { + payment: {paygateurl: 'https://paygate.example'} + }, + createPurchaseClient: () => { + clientCreations += 1; + if (options.clientError) { + throw options.clientError; + } + return { + async getOrder() { + requests += 1; + const response = queuedResponses.shift(); + assert.ok(response, 'unexpected payment-status request'); + if (response.pending) { + return new Promise(() => {}); + } + if (response.error) { + throw response.error; + } + return response.order; + } + }; + }, + getCheckoutPageUrl, + getOrderId, + document: { + querySelector: selector => { + if (selector === '[data-payment-status-page]') { + return {}; + } + if (selector === '#payment-failed [data-back-to-checkout]') { + return backToCheckoutLink; + } + return null; + }, + getElementById: id => views[id] + }, + window: { + location: browser.location, + history: browser.history, + setTimeout(callback, delay) { + const timerId = nextTimerId; + nextTimerId += 1; + if (delay === maxPollingDurationMs) { + assert.equal( + deadlineTimerId, + undefined, + 'expected only one polling deadline timer' + ); + deadlineTimerId = timerId; + } + scheduled.set(timerId, { + callback, + dueAt: currentTimeMs + delay, + timerId + }); + return timerId; + }, + clearTimeout: timerId => scheduled.delete(timerId) + }, + Date: {now: () => currentTimeMs}, + console: {error: () => {}}, + }; + + vm.runInNewContext(completedSource, context); + await settleAsyncWork(); + + return { + isVisible: id => views[id].hidden === false, + backToCheckoutHidden: () => backToCheckoutLink.hidden, + backToCheckoutHref: () => backToCheckoutLink.href, + clientCreations: () => clientCreations, + requests: () => requests, + elapsedMs: () => currentTimeMs, + scheduledPolls: () => + getScheduledPolls(scheduled, deadlineTimerId).length, + scheduledTimers: () => scheduled.size, + nextPollDelay: () => { + const nextPoll = getScheduledPolls( + scheduled, + deadlineTimerId + )[0]; + return nextPoll ? nextPoll.dueAt - currentTimeMs : undefined; + }, + nextTimerDelay: () => { + const nextTimer = getScheduledTimers(scheduled)[0]; + return nextTimer ? nextTimer.dueAt - currentTimeMs : undefined; + }, + async runNextPoll() { + const poll = getScheduledPolls(scheduled, deadlineTimerId)[0]; + assert.ok(poll, 'expected another polling attempt'); + await runTimer(poll); + }, + async runNextTimer() { + const timer = getScheduledTimers(scheduled)[0]; + assert.ok(timer, 'expected another scheduled timer'); + await runTimer(timer); + } + }; + + /** + * Runs one browser timer and lets its promise continuations settle. + * + * @param {Object} timer scheduled timer + * @return {Promise} resolves after asynchronous page work settles + */ + async function runTimer(timer) { + scheduled.delete(timer.timerId); + currentTimeMs = timer.dueAt; + timer.callback(); + await settleAsyncWork(); + } +} + +/** + * Returns scheduled timers in browser execution order. + * + * @param {Map} scheduled timers by ID + * @return {Array} ordered timers + */ +function getScheduledTimers(scheduled) { + return [...scheduled.values()].sort((left, right) => + left.dueAt - right.dueAt || left.timerId - right.timerId + ); +} + +/** + * Returns only timers that trigger another status request. + * + * @param {Map} scheduled timers by ID + * @param {number|undefined} deadlineTimerId polling deadline timer ID + * @return {Array} ordered polling timers + */ +function getScheduledPolls(scheduled, deadlineTimerId) { + return getScheduledTimers(scheduled).filter( + timer => timer.timerId !== deadlineTimerId + ); +} + +/** + * Creates location and history fakes whose URLs stay in sync. + * + * @param {string} href initial browser URL + * @param {Object|null} state initial history state + * @return {{location: Object, history: Object}} browser state + */ +function createBrowserState(href, state = null) { + const location = {href}; + const history = {state}; + return {location, history}; +} + +/** + * Waits for promise continuations started by the page script. + */ +function settleAsyncWork() { + return new Promise(resolve => setImmediate(resolve)); +} diff --git a/site/tests/checkout-configuration.test.mjs b/site/tests/checkout-configuration.test.mjs new file mode 100644 index 00000000..2a9bf271 --- /dev/null +++ b/site/tests/checkout-configuration.test.mjs @@ -0,0 +1,696 @@ +/* + * Copyright 2026, TeamDev. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Redistribution and use in source and/or binary forms, with or without + * modification, must retain the above copyright notice and the following + * disclaimer. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import test from 'node:test'; + +const {buildChargeRequest} = await importSource( + '../assets/js/pages/checkout/charge-request.js' +); +const { + focusOpenCountrySearchField, + populateCountrySelect +} = await importSource( + '../assets/js/pages/checkout/countries.js' +); +const { + checkoutNavigationMode, + getCheckoutNavigationMode, + getRestoredPhoneCountryManualState +} = await importSource( + '../assets/js/pages/checkout/navigation.js' +); +const {isEuCountry} = await importSource( + '../assets/js/pages/checkout/vat-countries.js' +); +const {normalizeIntlPhoneNumber} = await importSource( + '../assets/js/modules/forms/phone-number.js' +); +const { + createCheckoutFormController, + defaultPhoneCountryCode +} = await importFormController(); +const {createCheckoutView} = await importSource( + '../assets/js/pages/checkout/view-controller.js' +); +const {createChargeController} = await importChargeController(); + +test('keep checkout libraries aligned with pinned npm distributions', () => { + assert.deepEqual( + readFile('../static/libs/country-select/select2.min.js'), + readFile('../node_modules/select2/dist/js/select2.min.js') + ); + assert.deepEqual( + readFile('../assets/scss/libs/country-select/_select2.scss'), + readFile('../node_modules/select2/dist/css/select2.min.css') + ); + assert.deepEqual( + readFile('../static/libs/intl-tel-input/intlTelInput.min.js'), + readFile('../node_modules/intl-tel-input/build/js/intlTelInput.min.js') + ); + assert.deepEqual( + readFile('../static/libs/intl-tel-input/utils.js'), + readFile('../node_modules/intl-tel-input/build/js/utils.js') + ); + + const expectedPhoneCss = readText( + '../node_modules/intl-tel-input/build/css/intlTelInput.min.css' + ).replace( + 'url(../img/flags.png?1)', + 'url("../../images/flags/flags.png?1")' + ).replace( + 'url(../img/flags@2x.png?1)', + 'url("../../images/flags/flags@2x.png?1")' + ); + assert.equal( + readText('../assets/scss/libs/intl-tel-input/_intl-tel-input.scss'), + expectedPhoneCss + ); + assert.deepEqual( + readFile('../static/images/flags/flags.png'), + readFile('../node_modules/intl-tel-input/build/img/flags.png') + ); + assert.deepEqual( + readFile('../static/images/flags/flags@2x.png'), + readFile('../node_modules/intl-tel-input/build/img/flags@2x.png') + ); +}); + +test('calculate charges without requiring a VAT ID', () => { + assert.deepEqual( + buildChargeRequest('order-1', 'US', ''), + {orderId: 'order-1', buyerCountryCode: 'US'} + ); + assert.deepEqual( + buildChargeRequest('order-1', 'EE', 'EE123456789'), + { + orderId: 'order-1', + buyerCountryCode: 'EE', + vatId: 'EE123456789' + } + ); + assert.equal(buildChargeRequest('', 'US', ''), null); + assert.equal(buildChargeRequest('order-1', '', ''), null); +}); + +test('offer the complete ISO billing-country list', () => { + const OriginalDocument = globalThis.document; + const OriginalOption = globalThis.Option; + let appendCount = 0; + globalThis.document = { + createDocumentFragment() { + return { + children: [], + append(option) { + this.children.push(option); + } + }; + } + }; + globalThis.Option = class { + constructor(text, value) { + this.text = text; + this.value = value; + } + }; + + try { + const select = { + options: [{text: 'Select country', value: ''}], + append(fragment) { + appendCount += 1; + this.options.push(...fragment.children); + } + }; + + populateCountrySelect(select); + + assert.ok(select.options.length > 240); + assert.equal(appendCount, 1); + for (const countryCode of ['AU', 'BR', 'CA', 'CN', 'EE', 'GB', 'JP', 'US', 'ZA']) { + assert.ok(select.options.some(option => option.value === countryCode)); + } + } finally { + globalThis.document = OriginalDocument; + globalThis.Option = OriginalOption; + } +}); + +test('focus the country search input when its dropdown opens', () => { + let focused = false; + let requestedSelector = ''; + const root = { + querySelector(selector) { + requestedSelector = selector; + return {focus: () => focused = true}; + } + }; + + focusOpenCountrySearchField(root); + + assert.equal( + requestedSelector, + '.select2-container--open .select2-search__field' + ); + assert.equal(focused, true); +}); + +test('restore checkout fields only for browser history navigation', () => { + assert.equal( + getCheckoutNavigationMode('back_forward'), + checkoutNavigationMode.restore + ); + assert.equal( + getCheckoutNavigationMode('navigate', true), + checkoutNavigationMode.restore + ); + assert.equal( + getCheckoutNavigationMode('reload'), + checkoutNavigationMode.reset + ); + assert.equal( + getCheckoutNavigationMode('reload', true), + checkoutNavigationMode.restore + ); + assert.equal( + getCheckoutNavigationMode('navigate'), + checkoutNavigationMode.none + ); +}); + +test('preserve only genuine manual phone-country selection after history restore', () => { + assert.equal( + getRestoredPhoneCountryManualState(false, { + billingCountryCode: '', + phoneCountryCode: defaultPhoneCountryCode + }), + false + ); + assert.equal( + getRestoredPhoneCountryManualState(false, { + billingCountryCode: 'DE', + phoneCountryCode: 'US' + }), + true + ); + assert.equal( + getRestoredPhoneCountryManualState(true, { + billingCountryCode: 'DE', + phoneCountryCode: 'DE' + }), + true + ); +}); + +test('show VAT ID only for EU billing countries', () => { + assert.equal(isEuCountry('EE'), true); + assert.equal(isEuCountry('DE'), true); + assert.equal(isEuCountry('GB'), false); + assert.equal(isEuCountry('US'), false); +}); + +test('build Paygate phone data from the shared international input', () => { + assert.deepEqual( + normalizeIntlPhoneNumber('555 0100', '372', '+372 555 0100'), + {countryCode: 372, number: '5550100'} + ); + assert.deepEqual( + normalizeIntlPhoneNumber('(512) 555-0199', '1', ''), + {countryCode: 1, number: '5125550199'} + ); + assert.equal(normalizeIntlPhoneNumber('', '372', ''), null); + assert.equal(normalizeIntlPhoneNumber('5550100', '', ''), null); +}); + +test('load phone validation utilities next to the static library', () => { + const originalDocument = globalThis.document; + const originalWindow = globalThis.window; + const phoneField = {}; + let libraryOptions; + globalThis.document = { + querySelector: () => ({ + src: 'https://spine.io/libs/intl-tel-input/intlTelInput.min.js' + }) + }; + globalThis.window = { + intlTelInput(field, options) { + assert.equal(field, phoneField); + libraryOptions = options; + }, + intlTelInputGlobals: { + getInstance: () => ({ + getSelectedCountryData: () => ({iso2: 'us'}) + }) + } + }; + + try { + const controller = createCheckoutFormController({ + dom: { + $phoneNumber: {get: () => phoneField}, + $phoneCountry: {val: () => 'US'} + } + }); + + controller.initPhoneNumberField(); + + assert.equal( + libraryOptions.utilsScript, + 'https://spine.io/libs/intl-tel-input/utils.js' + ); + assert.equal(libraryOptions.initialCountry, 'us'); + } finally { + globalThis.document = originalDocument; + globalThis.window = originalWindow; + } +}); + +test('allow an optional phone number while validation utilities are loading', () => { + const originalWindow = globalThis.window; + let validityMessage = ''; + const errorElement = {textContent: ''}; + const fieldContainer = { + classList: {toggle() {}}, + querySelector: () => errorElement + }; + const phoneField = { + closest: () => fieldContainer, + setCustomValidity: message => validityMessage = message + }; + const phoneInput = {isValidNumber: () => false}; + globalThis.window = { + intlTelInputGlobals: {getInstance: () => phoneInput} + }; + + try { + const controller = createCheckoutFormController({ + dom: { + $phoneNumber: { + get: () => phoneField, + val: () => '151 23456789' + } + } + }); + + assert.equal(controller.validatePhoneNumber(), true); + assert.equal(validityMessage, ''); + + globalThis.window.intlTelInputUtils = {}; + assert.equal(controller.validatePhoneNumber(), false); + assert.equal(validityMessage, 'Enter a valid phone number.'); + } finally { + globalThis.window = originalWindow; + } +}); + +test('apply billing country to a typed phone unless its country was chosen manually', () => { + const originalWindow = globalThis.window; + const selectedCountries = []; + const phoneField = {}; + globalThis.window = { + intlTelInputGlobals: { + getInstance: () => ({ + setCountry: countryCode => selectedCountries.push(countryCode) + }) + }, + setTimeout: callback => callback() + }; + + try { + const controller = createCheckoutFormController({ + dom: { + $country: {val: () => 'DE'}, + $phoneNumber: {get: () => phoneField, val: () => '151 23456789'} + } + }); + + controller.applyPhoneCountryFromBillingCountry(false); + controller.applyPhoneCountryFromBillingCountry(true); + + assert.deepEqual(selectedCountries, ['de']); + assert.equal(controller.applyBillingCountryFromPhoneCountry, undefined); + } finally { + globalThis.window = originalWindow; + } +}); + +test('clear an earlier VAT validation error after the input changes', () => { + const classes = new Set(); + const errorElement = {textContent: ''}; + const fieldContainer = { + classList: { + toggle(className, enabled) { + if (enabled) { + classes.add(className); + } else { + classes.delete(className); + } + } + }, + querySelector() { + return errorElement; + } + }; + const vatField = { + closest() { + return fieldContainer; + } + }; + const controller = createCheckoutFormController({ + dom: { + $country: {val: () => 'EE'}, + $vatId: {get: () => vatField} + } + }); + + controller.showVatIdError('NOT_ACTIVE'); + assert.equal(classes.has('field-error'), true); + assert.equal(errorElement.textContent, 'This VAT ID is not active.'); + + controller.clearVatIdError(); + assert.equal(classes.has('field-error'), false); + assert.equal(errorElement.textContent, ''); +}); + +test('defer a VAT validation error until the field loses focus', () => { + const classes = new Set(); + const errorElement = {textContent: ''}; + const fieldContainer = { + classList: { + toggle(className, enabled) { + if (enabled) { + classes.add(className); + } else { + classes.delete(className); + } + } + }, + querySelector: () => errorElement + }; + const ownerDocument = {activeElement: null}; + const vatField = { + ownerDocument, + closest: () => fieldContainer + }; + const controller = createCheckoutFormController({ + dom: { + $country: {val: () => 'EE'}, + $vatId: {get: () => vatField} + } + }); + + ownerDocument.activeElement = vatField; + controller.showVatIdError('NOT_ACTIVE'); + assert.equal(classes.has('field-error'), false); + assert.equal(errorElement.textContent, ''); + + ownerDocument.activeElement = null; + controller.showPendingVatIdError(); + assert.equal(classes.has('field-error'), true); + assert.equal(errorElement.textContent, 'This VAT ID is not active.'); +}); + +test('clear a required-field error while the user edits the field', () => { + const classes = new Set(); + const errorElement = {textContent: ''}; + const fieldContainer = { + classList: { + toggle(className, enabled) { + if (enabled) { + classes.add(className); + } else { + classes.delete(className); + } + } + }, + querySelector: () => errorElement + }; + const field = { + disabled: false, + required: true, + type: 'text', + value: '', + closest: selector => selector === '[hidden]' ? null : fieldContainer + }; + const controller = createCheckoutFormController({dom: {}}); + + assert.equal(controller.validateField(field), false); + assert.equal(classes.has('field-error'), true); + + controller.clearFieldError(field); + assert.equal(classes.has('field-error'), false); + assert.equal(errorElement.textContent, ''); +}); + +test('reject an empty required phone field', () => { + const errorElement = {textContent: ''}; + const fieldContainer = { + classList: {toggle() {}}, + querySelector: () => errorElement + }; + const field = { + disabled: false, + required: true, + type: 'tel', + value: '', + closest: selector => selector === '[hidden]' ? null : fieldContainer + }; + const controller = createCheckoutFormController({dom: {}}); + + assert.equal(controller.validateField(field), false); + assert.equal(errorElement.textContent, 'This field is required.'); +}); + +test('ignore an obsolete charge failure after newer charges succeed', async () => { + const requests = []; + const updatedCharges = []; + const loggedErrors = []; + let countryCode = 'EE'; + let modalOpenCount = 0; + const controller = createChargeController({ + purchaseClient: { + calculateCharges(payload) { + return new Promise((resolve, reject) => { + requests.push({payload, resolve, reject}); + }); + } + }, + view: { + isFormHidden: () => false, + setSubmitDisabled() {}, + showErrorModal: () => modalOpenCount += 1, + updateCharges: charges => updatedCharges.push(charges) + }, + ensureOrderId: () => Promise.resolve('order-1'), + getBuyerCountryCode: () => countryCode, + getVatId: () => '', + onFieldValidationStateChange() {}, + onVatIdError() {}, + logApiError: error => loggedErrors.push(error) + }); + + const firstRequest = controller.flush(); + await waitUntil(() => requests.length === 1); + + countryCode = 'DE'; + controller.invalidate(); + const secondRequest = controller.flush(); + await waitUntil(() => requests.length === 2); + + const currentCharges = {vatRate: 0.19}; + requests[1].resolve(currentCharges); + await secondRequest; + const obsoleteError = {status: 500, statusText: 'Unavailable'}; + requests[0].reject(obsoleteError); + await firstRequest; + + assert.deepEqual(updatedCharges, [currentCharges]); + assert.equal(modalOpenCount, 0); + assert.deepEqual(loggedErrors, [obsoleteError]); +}); + +test('render a safe rounded VAT label and tolerate missing order money', () => { + const dom = createSummaryDom(); + const view = createCheckoutView(dom); + + assert.doesNotThrow(() => view.fillOrderSummary({productTitle: 'Support'})); + assert.equal(dom.$vatLabel.value, 'VAT'); + assert.equal(dom.$subtotalValue.value, ''); + assert.equal(dom.$vatValue.value, '0.00'); + assert.equal(dom.$totalValue.value, ''); + + const money = {value: 10, currency: {symbol: '€'}}; + view.updateCharges({ + vatRate: 0.07, + netAmount: money, + vatAmount: money, + totalAmount: money + }); + assert.equal(dom.$vatLabel.value, 'VAT (7%)'); + + view.updateCharges({ + netAmount: money, + vatAmount: money, + totalAmount: money + }); + assert.equal(dom.$vatLabel.value, 'VAT'); +}); + +test('show one checkout page state at a time', () => { + const originalDocument = globalThis.document; + let isResultPage = false; + globalThis.document = { + body: { + classList: { + toggle(className, enabled) { + assert.equal(className, 'checkout-result-page'); + isResultPage = enabled; + } + } + } + }; + + try { + const dom = createSummaryDom(); + const view = createCheckoutView(dom); + + view.showCheckoutView(); + assertVisiblePageElements(dom, ['$summary', '$form']); + assert.equal(isResultPage, false); + + view.showNotFoundView(); + assertVisiblePageElements(dom, ['$notFound']); + assert.equal(isResultPage, true); + } finally { + globalThis.document = originalDocument; + } +}); + +async function importSource(relativePath) { + const source = fs.readFileSync(new URL(relativePath, import.meta.url), 'utf8'); + return import(`data:text/javascript,${encodeURIComponent(source)}`); +} + +function readFile(relativePath) { + return fs.readFileSync(new URL(relativePath, import.meta.url)); +} + +function readText(relativePath) { + return fs.readFileSync(new URL(relativePath, import.meta.url), 'utf8'); +} + +async function importChargeController() { + const delayedRequestSource = readText( + '../assets/js/pages/checkout/delayed-request-controller.js' + ).replace('export function createDelayedRequestController', + 'function createDelayedRequestController'); + const chargeControllerSource = readText( + '../assets/js/pages/checkout/charge-controller.js' + ).replace(/^import .*;\n/gm, ''); + const dependencies = ` + const fieldValidationState = {idle: 'idle', loading: 'loading', success: 'success'}; + const buildChargeRequest = ${buildChargeRequest.toString()}; + `; + + const source = [delayedRequestSource, dependencies, chargeControllerSource].join('\n'); + return import(`data:text/javascript,${encodeURIComponent(source)}`); +} + +function createSummaryDom() { + const element = () => ({ + hidden: false, + value: '', + prop(name, value) { + if (value === undefined) { + return this[name]; + } + this[name] = value; + return this; + }, + text(value) { + this.value = value; + return this; + } + }); + + return { + $errorModal: element(), + $form: element(), + $loading: element(), + $missingOrder: element(), + $notFound: element(), + $productTitle: element(), + $productDescription: element(), + $summary: element(), + $summaryError: element(), + $subtotalValue: element(), + $submitButton: element(), + $vatLabel: element(), + $vatValue: element(), + $totalValue: element() + }; +} + +function assertVisiblePageElements(dom, visibleNames) { + const pageElementNames = [ + '$loading', + '$summary', + '$form', + '$missingOrder', + '$notFound', + '$summaryError' + ]; + + for (const name of pageElementNames) { + assert.equal(dom[name].hidden, !visibleNames.includes(name), name); + } +} + +async function waitUntil(predicate) { + for (let attempt = 0; attempt < 20; attempt += 1) { + if (predicate()) { + return; + } + await Promise.resolve(); + } + assert.fail('Timed out waiting for asynchronous checkout state.'); +} + +async function importFormController() { + const source = fs.readFileSync( + new URL('../assets/js/pages/checkout/form-controller.js', import.meta.url), + 'utf8' + ).replace( + "import {isEuCountry} from 'js/pages/checkout/vat-countries';", + "const isEuCountry = countryCode => countryCode === 'EE';" + ).replace( + "import {normalizeIntlPhoneNumber} from 'js/modules/forms/phone-number';", + 'const normalizeIntlPhoneNumber = () => null;' + ); + + return import(`data:text/javascript,${encodeURIComponent(source)}`); +}