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 }}