diff --git a/site/assets/js/modules/forms/phone-number.js b/site/assets/js/modules/forms/phone-number.js
new file mode 100644
index 00000000..b6a29325
--- /dev/null
+++ b/site/assets/js/modules/forms/phone-number.js
@@ -0,0 +1,66 @@
+/*
+ * Copyright 2026, TeamDev. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+'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.
+ *
+ * @param {string} rawCountryCode phone country code
+ * @param {string} rawNumber local phone number
+ * @return {{countryCode: number, number: string}|null}
+ * normalized phone-number payload, or null when incomplete
+ */
+export function normalizePhoneNumber(rawCountryCode, rawNumber) {
+ const countryCode = String(rawCountryCode || '').replace(/\D/g, '');
+ const number = String(rawNumber || '').replace(/\D/g, '');
+
+ if (!countryCode || !number) {
+ return null;
+ }
+
+ const numericCountryCode = Number(countryCode);
+ if (!Number.isInteger(numericCountryCode) || numericCountryCode <= 0) {
+ return null;
+ }
+
+ return {
+ countryCode: numericCountryCode,
+ number
+ };
+}
diff --git a/site/assets/js/modules/paygate/purchases.js b/site/assets/js/modules/paygate/purchases.js
new file mode 100644
index 00000000..2a91f14b
--- /dev/null
+++ b/site/assets/js/modules/paygate/purchases.js
@@ -0,0 +1,266 @@
+/*
+ * Copyright 2026, TeamDev. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+'use strict';
+
+/**
+ * Paygate currency payload returned inside money values.
+ *
+ * @typedef {Object} PaygateCurrency
+ * @property {string} code ISO currency code
+ * @property {string} symbol currency symbol
+ */
+
+/**
+ * Paygate money payload.
+ *
+ * @typedef {Object} PaygateMoney
+ * @property {number|string} value decimal amount value
+ * @property {PaygateCurrency} currency currency metadata
+ */
+
+/**
+ * Paygate product data returned by the product endpoint.
+ *
+ * @typedef {Object} PaygateProduct
+ * @property {string} id product identifier
+ * @property {string} name product display name
+ * @property {string} description product description shown on checkout
+ * @property {PaygateMoney} netAmount product price before VAT
+ */
+
+/**
+ * Paygate response for the `place-order` endpoint.
+ *
+ * @typedef {Object} PlaceOrderResponse
+ * @property {string} orderId created paygate order ID
+ */
+
+/**
+ * Paygate request payload for the `calculate-charges` endpoint.
+ *
+ * @typedef {Object} CalculateChargesRequest
+ * @property {string} orderId paygate order ID
+ * @property {string} buyerCountryCode iso billing country code
+ * @property {string} vatId buyer VAT ID
+ */
+
+/**
+ * Paygate response for the `calculate-charges` endpoint.
+ *
+ * @typedef {Object} CalculateChargesResponse
+ * @property {PaygateMoney} netAmount price before VAT
+ * @property {number|string} vatRate vat rate as a decimal fraction
+ * @property {PaygateMoney} vatAmount VAT amount for the order
+ * @property {PaygateMoney} totalAmount total price including VAT
+ */
+
+/**
+ * Billing address submitted to Paygate.
+ *
+ * @typedef {Object} BillingAddress
+ * @property {string} countryCode iso billing country code
+ * @property {string} city billing city
+ * @property {string} street combined street address
+ * @property {string} postalCode billing postal code
+ */
+
+/**
+ * Company billing details submitted to Paygate.
+ *
+ * @typedef {Object} BillingCompany
+ * @property {string} name company legal or display name
+ * @property {string} vatId company VAT ID
+ */
+
+/**
+ * Phone number submitted to Paygate.
+ *
+ * @typedef {Object} PhoneNumber
+ * @property {number} countryCode dialing country code without a leading plus sign
+ * @property {string} number local phone number digits
+ */
+
+/**
+ * Billing information submitted to Paygate before redirecting to payment.
+ *
+ * @typedef {Object} BillingInfo
+ * @property {string} name full buyer name or company fallback name
+ * @property {string} email buyer email address
+ * @property {BillingAddress} address billing address details
+ * @property {BillingCompany} company company billing details
+ * @property {PhoneNumber} [phoneNumber] optional normalized phone number
+ */
+
+/**
+ * Paygate request payload for the `submit-billing-info` endpoint.
+ *
+ * @typedef {Object} SubmitBillingInfoRequest
+ * @property {string} orderId paygate order ID
+ * @property {BillingInfo} billingInfo billing details for the order
+ */
+
+/**
+ * Paygate response for the billing-info submission step.
+ *
+ * @typedef {Object} SubmitBillingInfoResponse
+ * @property {string} paymentLink hosted payment redirect URL
+ */
+
+/**
+ * Generic Paygate error response used for 400, 404, and 500 responses.
+ *
+ * @typedef {Object} PaygateErrorResponse
+ * @property {string} message human-readable error message
+ */
+
+/**
+ * Charge-calculation validation error response returned with status 422.
+ *
+ * @typedef {Object} CalculateChargesFailureResponse
+ * @property {string|null} vatIdInvalid VAT validation failure reason
+ */
+
+/**
+ * Error object thrown when a Paygate request fails.
+ *
+ * @typedef {Object} PurchaseApiError
+ * @property {number} status http response status code
+ * @property {string} statusText http response status text
+ * @property {PaygateErrorResponse|CalculateChargesFailureResponse|string|null} body
+ * parsed response body, if any
+ */
+
+/**
+ * Paygate purchase endpoint methods used by checkout.
+ *
+ * @typedef {Object} PaygatePurchaseClient
+ * @property {function(string): Promise} getProduct
+ * loads checkout product data by product ID
+ * @property {function(string): Promise} placeOrder
+ * creates a checkout order for the given product ID
+ * @property {function(CalculateChargesRequest): Promise} calculateCharges
+ * calculates VAT and totals for the current order
+ * @property {function(SubmitBillingInfoRequest): Promise} submitBillingInfo
+ * sends billing details and returns payment redirect data
+ */
+
+/**
+ * Creates a client for Paygate purchase endpoints.
+ *
+ * @param {string} serverUrl base URL of the Paygate API server
+ * @return {PaygatePurchaseClient} paygate purchase endpoint methods
+ */
+export function createPurchaseClient(serverUrl) {
+ return {
+ getProduct(productId) {
+ return getJson(`${serverUrl}/products/${encodeURIComponent(productId)}`);
+ },
+ placeOrder(productId) {
+ return postJson(`${serverUrl}/purchases/place-order`, {productId});
+ },
+ calculateCharges(payload) {
+ return postJson(`${serverUrl}/purchases/calculate-charges`, payload);
+ },
+ submitBillingInfo(payload) {
+ return postJson(`${serverUrl}/purchases/provide-billing-details`, payload);
+ }
+ };
+}
+
+/**
+ * Sends a JSON GET request and returns the parsed response body.
+ *
+ * Body will be parsed as JSON or plain text if possible, otherwise `null` is returned.
+ *
+ * @param {string} url endpoint URL
+ * @return {Promise<*>} parsed response body when the request succeeds
+ *
+ * @throws {PurchaseApiError} if response status is not OK
+ */
+async function getJson(url) {
+ const response = await fetch(url);
+ const body = await readResponseBody(response);
+
+ if (!response.ok) {
+ throw ({
+ status: response.status,
+ statusText: response.statusText,
+ body
+ });
+ }
+
+ return body;
+}
+
+/**
+ * Sends a JSON POST request and returns the parsed response body.
+ *
+ * Body will be parsed as JSON or plain text if possible, otherwise `null` is returned.
+ *
+ * @param {string} url endpoint URL
+ * @param {Object} payload json request body
+ * @return {Promise<*>} parsed response body when the request succeeds
+ *
+ * @throws {PurchaseApiError} if response status is not OK
+ */
+async function postJson(url, payload) {
+ const response = await fetch(url, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify(payload)
+ });
+ const body = await readResponseBody(response);
+
+ if (!response.ok) {
+ throw ({
+ status: response.status,
+ statusText: response.statusText,
+ body
+ });
+ }
+
+ return body;
+}
+
+/**
+ * Parses a fetch response body as JSON when possible, otherwise as text.
+ *
+ * @param {Response} response fetch response to parse
+ * @return {Promise<*>} parsed JSON, text, or null when there is no readable body
+ */
+async function readResponseBody(response) {
+ const contentType = response.headers.get('content-type') || '';
+ try {
+ return contentType.includes('application/json')
+ ? await response.json()
+ : await response.text();
+ } catch (ignored) {
+ return null;
+ }
+}
diff --git a/site/assets/js/pages/checkout/charge-controller.js b/site/assets/js/pages/checkout/charge-controller.js
new file mode 100644
index 00000000..cc06fa81
--- /dev/null
+++ b/site/assets/js/pages/checkout/charge-controller.js
@@ -0,0 +1,237 @@
+/*
+ * Copyright 2026, TeamDev. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+'use strict';
+
+/**
+ * @typedef {import('js/modules/paygate/purchases').PaygatePurchaseClient} PaygatePurchaseClient
+ * @typedef {import('js/pages/checkout/view-controller').CheckoutViewController}
+ * CheckoutViewController
+ */
+
+import {createDelayedRequestController} from 'js/pages/checkout/delayed-request-controller';
+import {fieldValidationState} from 'js/pages/checkout/form-controller';
+
+/**
+ * Delay in milliseconds before sending the 'calculate-charges' request when VAT ID was changed.
+ *
+ * @type {number}
+ */
+const vatIdInputDelay = 1000;
+
+/**
+ * API exposed by the checkout charge controller.
+ *
+ * @typedef {Object} CheckoutChargeController
+ * @property {function(): Promise} flush
+ * cancels delayed VAT recalculation and requests charges immediately
+ * @property {function(): boolean} hasCurrentCharges
+ * checks whether current country and VAT inputs have fresh charge data
+ * @property {function(): boolean} hasScheduledRequest
+ * checks whether a delayed VAT recalculation is pending
+ * @property {function(): void} invalidate
+ * clears charge state and ignores older in-flight responses
+ * @property {function(): Promise} requestIfReady
+ * recalculates charges when all required inputs are present
+ * @property {function(): void} schedule
+ * debounces charge recalculation while typing VAT ID
+ * @property {function(): void} updateSubmitState
+ * enables submit only when current charge data is ready
+ */
+
+/**
+ * Creates the checkout charge-calculation controller.
+ *
+ * @param {Object} options charge controller options
+ * @param {PaygatePurchaseClient} options.purchaseClient paygate purchase client
+ * @param {CheckoutViewController} options.view checkout view controller
+ * @param {function(): Promise} options.ensureOrderId creates or reuses the Paygate order
+ * @param {function(): string} options.getBuyerCountryCode returns the selected billing country
+ * @param {function(): string} options.getVatId returns the current VAT ID value
+ * @param {function(string): void} options.onFieldValidationStateChange updates field UI state
+ * @param {function(string): void} options.onVatIdError renders the VAT ID API validation error
+ * @param {function(Object|Error): void} options.logApiError logs request failures
+ * @return {CheckoutChargeController} charge lifecycle helpers for the checkout page
+ */
+export function createChargeController(
+ {
+ purchaseClient,
+ view,
+ ensureOrderId,
+ getBuyerCountryCode,
+ getVatId,
+ onFieldValidationStateChange,
+ onVatIdError,
+ logApiError
+ }
+) {
+ const delayedRequest = createDelayedRequestController({
+ delay: vatIdInputDelay,
+ getRequestKey,
+ request: requestCharges,
+ onSuccess: response => {
+ view.updateCharges(response);
+ },
+ onError: handleRequestError,
+ onStateChange: handleRequestStateChange
+ });
+
+ /**
+ * Reacts to delayed-request state changes.
+ *
+ * @param {Object} state delayed-request state snapshot
+ * @param {boolean} state.hasCurrentResult whether charges are ready for current inputs
+ * @param {boolean} state.isRequesting whether a charge request is currently in flight
+ */
+ function handleRequestStateChange(state) {
+ updateSubmitState(state.hasCurrentResult);
+ updateVatIdState(state);
+ }
+
+ /**
+ * Enables checkout submission only when the current charge calculation is ready.
+ *
+ * @param {boolean} [hasCurrentResult] whether current inputs already have fresh charges
+ */
+ function updateSubmitState(hasCurrentResult = delayedRequest.hasCurrentResult()) {
+ view.setSubmitDisabled(view.isFormHidden() || !hasCurrentResult);
+ }
+
+ /**
+ * Updates the VAT field visual validation state.
+ *
+ * @param {Object} state delayed-request state snapshot
+ * @param {boolean} state.hasCurrentResult whether charges are ready for current inputs
+ * @param {boolean} state.isRequesting whether a charge request is currently in flight
+ */
+ function updateVatIdState({hasCurrentResult, isRequesting}) {
+ const hasRequestKey = Boolean(getRequestKey());
+
+ if (!hasRequestKey) {
+ onFieldValidationStateChange(fieldValidationState.idle);
+ return;
+ }
+
+ if (isRequesting) {
+ onFieldValidationStateChange(fieldValidationState.loading);
+ return;
+ }
+
+ onFieldValidationStateChange(
+ hasCurrentResult ? fieldValidationState.success : fieldValidationState.idle
+ );
+ }
+
+ /**
+ * Sends the current Paygate `calculate-charges` request.
+ *
+ * @return {Promise} charge response, or `null` when inputs are incomplete
+ */
+ async function requestCharges() {
+ const orderId = await ensureOrderId();
+ const buyerCountryCode = getBuyerCountryCode();
+ const vatId = getVatId();
+
+ if (!orderId || !buyerCountryCode || !vatId) {
+ return null;
+ }
+
+ return purchaseClient.calculateCharges({
+ orderId,
+ buyerCountryCode,
+ vatId
+ });
+ }
+
+ /**
+ * Builds the cache key for the current charge calculation inputs.
+ *
+ * @return {string} joined country:VAT key, or empty string when request cannot run yet
+ */
+ function getRequestKey() {
+ const buyerCountryCode = getBuyerCountryCode();
+ const vatId = getVatId();
+
+ return buyerCountryCode && vatId
+ ? [buyerCountryCode, vatId].join(':')
+ : '';
+ }
+
+ /**
+ * Handles a failed charge calculation response from the delayed request controller.
+ *
+ * @param {Object} error error response
+ * @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) {
+ onVatIdError(getVatErrorReason(error));
+ }
+
+ logApiError(error);
+ }
+
+ /**
+ * Checks whether the Paygate error is a VAT validation failure.
+ *
+ * @param {Object} error request error
+ * @return {boolean} true when the error is a Paygate VAT validation response
+ */
+ function isVatErrorResponse(error) {
+ return error.status === 422 && Boolean(error.body.vatIdInvalid);
+ }
+
+ /**
+ * Returns the Paygate VAT validation reason from the error response.
+ *
+ * @param {Object} error request error
+ * @return {string} VAT validation reason, or empty string
+ */
+ function getVatErrorReason(error) {
+ return String(error.body && error.body.vatIdInvalid || '');
+ }
+
+ return {
+ flush: delayedRequest.flush,
+ hasCurrentCharges: delayedRequest.hasCurrentResult,
+ hasScheduledRequest: delayedRequest.hasScheduledRequest,
+ invalidate: delayedRequest.invalidate,
+ requestIfReady: delayedRequest.requestIfReady,
+ schedule: delayedRequest.schedule,
+ updateSubmitState
+ };
+}
diff --git a/site/assets/js/pages/checkout/delayed-request-controller.js b/site/assets/js/pages/checkout/delayed-request-controller.js
new file mode 100644
index 00000000..7ce7ad8b
--- /dev/null
+++ b/site/assets/js/pages/checkout/delayed-request-controller.js
@@ -0,0 +1,269 @@
+/*
+ * Copyright 2026, TeamDev. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+'use strict';
+
+/**
+ * Generic delayed-request controller API.
+ *
+ * @typedef {Object} DelayedRequestController
+ * @property {function(): Promise} flush
+ * cancels the delayed request and runs it immediately
+ * @property {function(): boolean} hasCurrentResult
+ * checks whether current inputs already have a fresh response
+ * @property {function(): boolean} hasScheduledRequest
+ * checks whether a delayed request is pending
+ * @property {function(): void} invalidate
+ * clears current request state and ignores older responses
+ * @property {function(): Promise} requestIfReady
+ * runs the request when a request key is available
+ * @property {function(): void} schedule
+ * delays the request for the configured amount of time
+ */
+
+/**
+ * Creates a reusable controller for `input -> delayed request -> latest response` flows.
+ *
+ * @typedef {Object} DelayedRequestState
+ * @property {boolean} hasCurrentResult whether current inputs already have a fresh response
+ * @property {boolean} isRequesting whether a request is currently in flight
+ *
+ * @param {Object} options delayed request options
+ * @param {number} options.delay debounce delay in milliseconds
+ * @param {function(): string} options.getRequestKey returns the current request key
+ * @param {function(): Promise<*|null>} options.request sends the current request
+ * @param {function(*): void} options.onSuccess handles the latest successful response
+ * @param {function(Object, boolean): void} options.onError handles request failures
+ * @param {function(DelayedRequestState): void} options.onStateChange reacts to state changes
+ * @return {DelayedRequestController} delayed request helpers
+ */
+export function createDelayedRequestController({
+ delay,
+ getRequestKey,
+ request,
+ onSuccess,
+ onError,
+ onStateChange
+}) {
+ let requestId = 0;
+ let requestTimer = null;
+ let pendingRequestKey = '';
+ let pendingRequestPromise = null;
+ let readyRequestKey = '';
+ let isRequesting = false;
+
+ /**
+ * Runs the request immediately when the current key is present.
+ *
+ * @return {Promise} resolves when the request flow completes
+ */
+ async function requestIfReady() {
+ const requestKey = getRequestKey();
+ const reusableRequest = getReusableRequest(requestKey);
+
+ if (!requestKey) {
+ invalidate();
+ return;
+ }
+
+ if (reusableRequest) {
+ return reusableRequest;
+ }
+
+ return startRequest(requestKey);
+ }
+
+ /**
+ * Cancels the delayed request and runs it immediately.
+ *
+ * @return {Promise} resolves when the request flow completes
+ */
+ function flush() {
+ clearScheduledRequest();
+ return requestIfReady();
+ }
+
+ /**
+ * Schedules the request after the configured delay.
+ */
+ function schedule() {
+ clearScheduledRequest();
+ requestTimer = window.setTimeout(() => {
+ requestTimer = null;
+ requestIfReady();
+ }, delay);
+ }
+
+ /**
+ * Clears state and ignores any older in-flight response.
+ */
+ function invalidate() {
+ clearScheduledRequest();
+ requestId += 1;
+ isRequesting = false;
+ pendingRequestKey = '';
+ pendingRequestPromise = null;
+ readyRequestKey = '';
+ emitStateChange();
+ }
+
+ /**
+ * Checks whether a delayed request is currently scheduled.
+ *
+ * @return {boolean} true when a delayed request is pending
+ */
+ function hasScheduledRequest() {
+ return Boolean(requestTimer);
+ }
+
+ /**
+ * Checks whether the current inputs already have a fresh response.
+ *
+ * @return {boolean} true when the current request key is ready
+ */
+ function hasCurrentResult() {
+ const requestKey = getRequestKey();
+ return Boolean(requestKey) && requestKey === readyRequestKey;
+ }
+
+ /**
+ * Reuses the latest request promise when inputs have not changed.
+ *
+ * @param {string} requestKey current request key
+ * @return {Promise|null} reusable request promise, if available
+ */
+ function getReusableRequest(requestKey) {
+ if (requestKey === readyRequestKey) {
+ return Promise.resolve();
+ }
+
+ return requestKey === pendingRequestKey && pendingRequestPromise
+ ? pendingRequestPromise
+ : null;
+ }
+
+ /**
+ * Starts a new request for the given key.
+ *
+ * @param {string} requestKey current request key
+ * @return {Promise} resolves when the request flow completes
+ */
+ function startRequest(requestKey) {
+ const currentRequestId = ++requestId;
+
+ isRequesting = true;
+ pendingRequestKey = requestKey;
+ readyRequestKey = '';
+ emitStateChange();
+ pendingRequestPromise = Promise.resolve()
+ .then(request)
+ .then(response => handleSuccess(currentRequestId, requestKey, response))
+ .catch(error => handleError(currentRequestId, requestKey, error))
+ .finally(() => finishRequest(currentRequestId));
+
+ return pendingRequestPromise;
+ }
+
+ /**
+ * Applies a successful response when it still matches the latest request.
+ *
+ * @param {number} currentRequestId current request sequence number
+ * @param {string} requestKey request key for the response
+ * @param {*|null} response latest response
+ */
+ function handleSuccess(currentRequestId, requestKey, response) {
+ if (currentRequestId !== requestId || !response) {
+ return;
+ }
+
+ readyRequestKey = requestKey;
+ onSuccess(response);
+ }
+
+ /**
+ * Handles request failures and marks current inputs as not ready.
+ *
+ * @param {number} currentRequestId current request sequence number
+ * @param {string} requestKey request key for the failed request
+ * @param {Object} error request error
+ */
+ function handleError(currentRequestId, requestKey, error) {
+ const isCurrentRequest = currentRequestId === requestId;
+
+ if (isCurrentRequest) {
+ readyRequestKey = '';
+ }
+
+ onError(error, isCurrentRequest);
+ }
+
+ /**
+ * Clears the tracked in-flight request after it finishes.
+ *
+ * @param {number} currentRequestId current request sequence number
+ */
+ function finishRequest(currentRequestId) {
+ if (currentRequestId !== requestId) {
+ return;
+ }
+
+ isRequesting = false;
+ pendingRequestKey = '';
+ pendingRequestPromise = null;
+ emitStateChange();
+ }
+
+ /**
+ * Clears the delayed request timer when one exists.
+ */
+ function clearScheduledRequest() {
+ if (!requestTimer) {
+ return;
+ }
+
+ window.clearTimeout(requestTimer);
+ requestTimer = null;
+ }
+
+ /**
+ * Emits the current delayed-request state to the consumer.
+ */
+ function emitStateChange() {
+ onStateChange({
+ hasCurrentResult: hasCurrentResult(),
+ isRequesting
+ });
+ }
+
+ return {
+ flush,
+ hasCurrentResult,
+ hasScheduledRequest,
+ invalidate,
+ requestIfReady,
+ schedule
+ };
+}
diff --git a/site/assets/js/pages/checkout/dom.js b/site/assets/js/pages/checkout/dom.js
new file mode 100644
index 00000000..2fff1c10
--- /dev/null
+++ b/site/assets/js/pages/checkout/dom.js
@@ -0,0 +1,101 @@
+/*
+ * Copyright 2026, TeamDev. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+'use strict';
+
+/**
+ * DOM references used across the checkout page controllers.
+ *
+ * @typedef {Object} CheckoutDom
+ * @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} $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} $productName product name element
+ * @property {JQuery} $productDescription product description
+ * element
+ * @property {JQuery} $subtotalValue subtotal value element
+ * @property {JQuery} $vatLabel vat label element
+ * @property {JQuery} $vatValue vat amount element
+ * @property {JQuery} $totalValue total amount element
+ * @property {JQuery} $submitButton checkout submit button
+ * @property {JQuery} $errorModal generic checkout error modal
+ * @property {JQuery} $notFound product-not-found result panel
+ * @property {JQuery} $summaryError generic checkout summary-error panel
+ * @property {HTMLFormElement} form native checkout form element
+ */
+
+/**
+ * Collects the checkout page DOM references used by the page controllers.
+ *
+ * @return {CheckoutDom|null} checkout DOM references, or null when the page is not present
+ */
+export function getCheckoutDom() {
+ const dom = {
+ $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'),
+ $vatId: $('#checkout-vat-id'),
+ $loading: $('#checkout-summary-loading'),
+ $loadingSpinner: $('#checkout-summary-loading-spinner'),
+ $loadingText: $('#checkout-summary-loading-text'),
+ $loadingSupport: $('#checkout-summary-support'),
+ $productName: $('#checkout-product-name'),
+ $productDescription: $('#checkout-product-description'),
+ $subtotalValue: $('#checkout-subtotal-value'),
+ $vatLabel: $('#checkout-vat-label'),
+ $vatValue: $('#checkout-vat-value'),
+ $totalValue: $('#checkout-total-value'),
+ $submitButton: $('#checkout-submit'),
+ $errorModal: $('#checkout-error-modal'),
+ $notFound: $('#checkout-not-found'),
+ $summaryError: $('#checkout-summary-error')
+ };
+
+ if (!dom.$form.length || !dom.$summary.length) {
+ return null;
+ }
+
+ return {
+ ...dom,
+ form: dom.$form.get(0)
+ };
+}
diff --git a/site/assets/js/pages/checkout/form-controller.js b/site/assets/js/pages/checkout/form-controller.js
new file mode 100644
index 00000000..59d9b74c
--- /dev/null
+++ b/site/assets/js/pages/checkout/form-controller.js
@@ -0,0 +1,507 @@
+/*
+ * Copyright 2026, TeamDev. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+'use strict';
+
+import {euCountryPhoneCodes} from 'js/pages/checkout/phone-codes';
+import {
+ normalizePhoneNumber,
+ sanitizePhoneNumberInput
+} from 'js/modules/forms/phone-number';
+
+/**
+ * Generic async field-validation states.
+ */
+export const fieldValidationState = Object.freeze({
+ idle: 'idle',
+ loading: 'loading',
+ success: 'success'
+});
+
+/**
+ * @typedef {import('js/pages/checkout/dom').CheckoutDom} CheckoutDom
+ * @typedef {import('js/modules/paygate/purchases').SubmitBillingInfoRequest}
+ * SubmitBillingInfoRequest
+ */
+
+/**
+ * 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} focusPhoneNumber
+ * focuses the phone number input when a country is selected
+ * @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} updateVatIdFieldState
+ * refreshes VAT field state after country changes
+ * @property {function(HTMLInputElement|HTMLSelectElement|HTMLTextAreaElement):boolean} validateField
+ * validates one form field
+ * @property {function(string): boolean} validateRequiredFields
+ * validates all required checkout fields
+ */
+
+/**
+ * Creates the checkout form controller.
+ *
+ * @param {Object} options form controller options
+ * @param {CheckoutDom} options.dom checkout DOM references
+ * @return {CheckoutFormController} checkout form helpers and event handlers
+ */
+export function createCheckoutFormController({dom}) {
+ /**
+ * Attaches event handlers for the custom phone field.
+ */
+ function bindPhoneEvents() {
+ dom.$phone.on('click', focusPhoneCountrySelectorIfMissing);
+ dom.$phoneNumber.on('focus', focusPhoneCountrySelectorIfMissing);
+ dom.$phoneNumber.on('beforeinput', preventUnsupportedPhoneInput);
+ dom.$phoneNumber.on('input', sanitizePhoneNumberValue);
+ }
+
+ /**
+ * Validates all required checkout fields before billing info submission.
+ *
+ * @param {string} requiredSelector selector used to find required form fields
+ * @return {boolean} true when all required fields are valid
+ */
+ function validateRequiredFields(requiredSelector) {
+ return Array.from(dom.form.querySelectorAll(requiredSelector))
+ .map(validateField)
+ .every(Boolean);
+ }
+
+ /**
+ * Validates a single form field and renders its inline error state.
+ *
+ * @param {HTMLInputElement|HTMLSelectElement|HTMLTextAreaElement} field field element to
+ * validate
+ * @return {boolean} true when the field has no validation error
+ */
+ function validateField(field) {
+ if (!field) {
+ return true;
+ }
+
+ const value = field.value ? field.value.trim() : '';
+ let message = '';
+
+ if (field.required && !value) {
+ message = 'This field is required.';
+ } else if (field.type === 'email' && value && !field.validity.valid) {
+ message = 'Enter a valid email address.';
+ }
+
+ setFieldError(field, message);
+ return !message;
+ }
+
+ /**
+ * 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));
+ }
+
+ /**
+ * Updates generic async field validation styling.
+ *
+ * @param {HTMLElement} field field whose validation styling should be updated
+ * @param {string} state async validation state
+ */
+ function setFieldValidationState(field, state) {
+ applyFieldValidationState(field, state);
+ }
+
+ /**
+ * Refreshes VAT ID state after country changes without marking an empty field as invalid.
+ */
+ function updateVatIdFieldState() {
+ const field = dom.$vatId.get(0);
+ const vatId = (dom.$vatId.val() || '').trim();
+
+ vatId ? validateField(field) : setFieldError(field, '');
+ }
+
+ /**
+ * Builds the Paygate submit-billing-info request from the checkout form.
+ *
+ * @param {string} orderId paygate order ID
+ * @return {SubmitBillingInfoRequest} submit-billing-info request payload
+ */
+ function buildSubmitBillingInfoRequest(orderId) {
+ 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 fullName = [field('first_name'), field('last_name')]
+ .filter(Boolean)
+ .join(' ') || companyName;
+ const phoneNumber = normalizePhoneNumber(
+ formData.phone_country_code || '',
+ formData.phone_number || ''
+ );
+ const billingInfo = {
+ name: fullName,
+ email: field('email'),
+ address: {
+ countryCode: field('country'),
+ city: field('city'),
+ street: joinAddressLines(formData.address_line_1, formData.address_line_2),
+ postalCode: field('postal_code')
+ },
+ company: companyName ? {
+ name: companyName,
+ vatId
+ } : null
+ };
+
+ if (phoneNumber) {
+ billingInfo.phoneNumber = phoneNumber;
+ }
+
+ return {
+ orderId,
+ billingInfo
+ };
+ }
+
+ /**
+ * 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.
+ *
+ * @param {boolean} phoneCountryManuallySelected whether phone country was chosen by the user
+ */
+ function applyPhoneCountryFromBillingCountry(phoneCountryManuallySelected) {
+ if (phoneCountryManuallySelected || hasPhoneNumber()) {
+ updatePhoneCountryDisplay();
+ return;
+ }
+
+ setPhoneCountryCode(euCountryPhoneCodes[dom.$country.val()] || '');
+ updatePhoneCountryDisplay();
+ }
+
+ /**
+ * 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 phone country from billing country when browser already restored the number.
+ */
+ function restorePhoneCountryFromBillingCountry() {
+ if (getPhoneCountryCode() || !hasPhoneNumber()) {
+ return;
+ }
+
+ setPhoneCountryCode(euCountryPhoneCodes[dom.$country.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');
+ }
+ }
+
+ /**
+ * 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;
+
+ if (input && sanitizePhoneNumberInput(input) !== input) {
+ event.preventDefault();
+ }
+ }
+
+ /**
+ * Sanitizes the phone number input after user edits.
+ */
+ function sanitizePhoneNumberValue() {
+ const sanitized = sanitizePhoneNumberInput(dom.$phoneNumber.val());
+
+ if (dom.$phoneNumber.val() !== sanitized) {
+ dom.$phoneNumber.val(sanitized);
+ }
+ }
+
+ /**
+ * Returns the current phone-country code.
+ *
+ * @return {string} selected phone-country code, or empty string
+ */
+ function getPhoneCountryCode() {
+ return String(dom.$phoneCountryCode.val() || '');
+ }
+
+ /**
+ * Updates the selected phone-country code.
+ *
+ * @param {string} phoneCode phone-country code without a leading plus sign
+ */
+ function setPhoneCountryCode(phoneCode) {
+ dom.$phoneCountryCode.val(phoneCode);
+ }
+
+ /**
+ * 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');
+
+ return {
+ flag: String(selected.data('flag') || ''),
+ code: String(selected.data('code') || ''),
+ isSelected: Boolean(getPhoneCountryCode())
+ };
+ }
+
+ /**
+ * Applies or clears the visual error state for a form field.
+ *
+ * @param {HTMLElement} field field whose nearest form-field container should be updated
+ * @param {string} message error message to show, or empty string to clear the error
+ */
+ function setFieldError(field, message) {
+ if (!field) {
+ return;
+ }
+
+ const fieldContainer = field.closest('.form-field');
+
+ if (!fieldContainer) {
+ return;
+ }
+
+ const errorElement = getOrCreateError(fieldContainer);
+
+ if (message) {
+ applyFieldValidationState(field, fieldValidationState.idle);
+ }
+
+ fieldContainer.classList.toggle('field-error', Boolean(message));
+ errorElement.textContent = message || '';
+ }
+
+ /**
+ * Applies the field state classes used by inline validation styles.
+ *
+ * @param {HTMLElement} field field whose state should be updated
+ * @param {string} state field validation state
+ */
+ function applyFieldValidationState(field, state) {
+ const fieldContainer = field && field.closest('.form-field');
+
+ if (!fieldContainer) {
+ return;
+ }
+
+ fieldContainer.classList.toggle(
+ 'field-loading',
+ state === fieldValidationState.loading
+ );
+ fieldContainer.classList.toggle(
+ 'field-success',
+ state === fieldValidationState.success
+ );
+ }
+
+ /**
+ * Returns the field error element, creating it when the template has none.
+ *
+ * @param {HTMLElement} fieldContainer form field container that owns the error element
+ * @return {HTMLDivElement} existing or newly created error element
+ */
+ function getOrCreateError(fieldContainer) {
+ let errorElement = fieldContainer.querySelector('.error-message');
+
+ if (!errorElement) {
+ errorElement = document.createElement('div');
+ errorElement.className = 'error-message';
+ fieldContainer.appendChild(errorElement);
+ }
+
+ return errorElement;
+ }
+
+ /**
+ * Maps Paygate VAT ID error reasons to user-facing field messages.
+ *
+ * @param {string} reason paygate VAT ID error reason
+ * @return {string} user-facing VAT ID field error message
+ */
+ function vatIdErrorMessage(reason) {
+ switch (reason) {
+ case 'INVALID_FORMAT':
+ return 'Invalid VAT ID format. Example: EE1234567890.';
+ case 'COUNTRY_MISMATCH':
+ return 'The VAT ID country must match the selected billing country.';
+ case 'NON_EU_COUNTRY':
+ return 'Only European Union VAT ID is acceptable.';
+ case 'NOT_ACTIVE':
+ return 'This VAT ID is not active.';
+ case 'UNSPECIFIED':
+ case 'INVALID':
+ case 'VAT_ID_INVALID':
+ default:
+ return 'Invalid VAT ID.';
+ }
+ }
+
+ /**
+ * 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.
+ *
+ * @param {string} countryCode country ISO code to look for in the billing country select
+ * @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;
+ }
+
+ /**
+ * 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
+ ) || '';
+ }
+
+ /**
+ * Joins non-empty address lines into the single street value expected by Paygate.
+ *
+ * @param {string} line1 first street address line
+ * @param {string} line2 second street address line
+ * @return {string} comma-separated street value
+ */
+ function joinAddressLines(line1, line2) {
+ return [line1, line2].map(value => (value || '').trim()).filter(Boolean).join(', ');
+ }
+
+ return {
+ applyBillingCountryFromPhoneCountry,
+ applyPhoneCountryFromBillingCountry,
+ bindPhoneEvents,
+ buildSubmitBillingInfoRequest,
+ focusPhoneNumber,
+ setFieldValidationState,
+ showVatIdError,
+ updatePhoneCountryDisplay,
+ updateVatIdFieldState,
+ validateField,
+ validateRequiredFields
+ };
+}
diff --git a/site/assets/js/pages/checkout/index.js b/site/assets/js/pages/checkout/index.js
new file mode 100644
index 00000000..053c96ed
--- /dev/null
+++ b/site/assets/js/pages/checkout/index.js
@@ -0,0 +1,268 @@
+/*
+ * Copyright 2026, TeamDev. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+'use strict';
+
+import * as params from '@params';
+import {createPurchaseClient} from 'js/modules/paygate/purchases';
+import {createChargeController} from 'js/pages/checkout/charge-controller';
+import {getCheckoutDom} from 'js/pages/checkout/dom';
+import {createCheckoutFormController} from 'js/pages/checkout/form-controller';
+import {createCheckoutView} from 'js/pages/checkout/view-controller';
+
+const requiredSelector = 'input[required], select[required], textarea[required]';
+
+$(
+ function () {
+ const dom = getCheckoutDom();
+
+ if (!dom) {
+ return;
+ }
+
+ const purchaseClient = createPurchaseClient(params.paygate.serverurl);
+ const productId = getProductId();
+ const view = createCheckoutView(dom);
+ const formController = createCheckoutFormController({dom});
+ let orderId = null;
+ let orderPromise = null;
+ let countryManuallySelected = false;
+ let phoneCountryManuallySelected = false;
+ const chargeController = createChargeController({
+ purchaseClient,
+ view,
+ ensureOrderId,
+ getBuyerCountryCode: () => dom.$country.val(),
+ getVatId: () => (dom.$vatId.val() || '').trim(),
+ onFieldValidationStateChange: state => {
+ formController.setFieldValidationState(dom.$vatId.get(0), state);
+ },
+ onVatIdError: formController.showVatIdError,
+ logApiError
+ });
+
+ if (!productId) {
+ chargeController.invalidate();
+ view.showNotFoundView();
+ return;
+ }
+
+ dom.$form.prop('hidden', true);
+ formController.updatePhoneCountryDisplay();
+ formController.bindPhoneEvents();
+ chargeController.updateSubmitState();
+ loadProduct();
+ bindEvents();
+
+ /**
+ * Registers checkout page event handlers.
+ */
+ function bindEvents() {
+ dom.$form.on('input', requiredSelector, event => {
+ formController.validateField(event.target);
+ });
+
+ dom.$form.on('change', 'select[required]', event => {
+ formController.validateField(event.target);
+ });
+
+ $('[data-checkout-modal-close]').on('click', view.closeErrorModal);
+
+ $(document).on('keydown', event => {
+ if (event.key === 'Escape') {
+ view.closeErrorModal();
+ }
+ });
+
+ $(window).on('pageshow', () => {
+ scheduleRestoredVatResume();
+ });
+
+ 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', () => {
+ chargeController.invalidate();
+ chargeController.schedule();
+ });
+
+ dom.$vatId.on('blur', () => {
+ if (chargeController.hasScheduledRequest()) {
+ chargeController.flush();
+ }
+ });
+
+ dom.$form.on('submit', handleSubmit);
+ }
+
+ /**
+ * Loads product details for the checkout page from the current checkout URL.
+ *
+ * @return {Promise} resolves when the initial product load flow finishes
+ */
+ async function loadProduct() {
+ view.setSummaryLoading(true);
+
+ try {
+ const product = await purchaseClient.getProduct(productId);
+ view.fillProductSummary(product);
+ view.setSummaryLoading(false);
+ dom.$form.prop('hidden', false);
+ chargeController.updateSubmitState();
+ scheduleRestoredVatResume();
+ } catch (error) {
+ if (error.status === 404) {
+ chargeController.invalidate();
+ view.showNotFoundView();
+ chargeController.updateSubmitState();
+ return;
+ }
+
+ view.setSummaryLoading(false);
+ view.showSummaryError();
+ chargeController.updateSubmitState();
+ logApiError(error);
+ }
+ }
+
+ /**
+ * Submits checkout billing data after the current charge state is valid.
+ *
+ * @param {JQuery.SubmitEvent} event checkout form submit event
+ * @return {Promise} resolves when submit handling finishes
+ */
+ async function handleSubmit(event) {
+ event.preventDefault();
+
+ if (!formController.validateRequiredFields(requiredSelector)) {
+ dom.form.reportValidity();
+ return;
+ }
+
+ await chargeController.requestIfReady();
+
+ if (!chargeController.hasCurrentCharges() || !orderId) {
+ return;
+ }
+
+ try {
+ const response = await purchaseClient.submitBillingInfo(
+ formController.buildSubmitBillingInfoRequest(orderId)
+ );
+ window.location = response.paymentLink;
+ } catch (error) {
+ view.showErrorModal();
+ logApiError(error);
+ }
+ }
+
+ /**
+ * Reads the product ID from the `product` query parameter.
+ *
+ * @return {string} checkout product ID, or empty string when unavailable
+ */
+ function getProductId() {
+ return (new URLSearchParams(window.location.search).get('product') || '').trim();
+ }
+
+ /**
+ * Logs API failures in a compact and consistent format.
+ *
+ * @param {Object|Error} error request error to log
+ */
+ function logApiError(error) {
+ console.error(
+ `${error.status || 'Network error'}: ` +
+ `${error.statusText || 'Request failed'}`
+ );
+ }
+
+ /**
+ * Schedules one pass that resumes charge calculation from browser-restored VAT data.
+ */
+ function scheduleRestoredVatResume() {
+ window.setTimeout(resumeChargesFromRestoredVatId, 0);
+ }
+
+ /**
+ * Restarts charge calculation when the browser already restored VAT ID into the field.
+ */
+ function resumeChargesFromRestoredVatId() {
+ if (view.isFormHidden()) {
+ return;
+ }
+
+ if (!(dom.$vatId.val() || '').trim()) {
+ return;
+ }
+
+ chargeController.requestIfReady();
+ }
+
+ /**
+ * Places the order once and reuses it for all later charge calculations.
+ *
+ * @return {Promise} paygate order ID
+ */
+ async function ensureOrderId() {
+ if (orderId) {
+ return orderId;
+ }
+
+ if (!orderPromise) {
+ orderPromise = purchaseClient
+ .placeOrder(productId)
+ .then(response => {
+ orderId = response.orderId;
+ return orderId;
+ })
+ .catch(error => {
+ orderPromise = null;
+ throw error;
+ });
+ }
+
+ return orderPromise;
+ }
+ }
+);
diff --git a/site/assets/js/pages/checkout/phone-codes.js b/site/assets/js/pages/checkout/phone-codes.js
new file mode 100644
index 00000000..5dd658e3
--- /dev/null
+++ b/site/assets/js/pages/checkout/phone-codes.js
@@ -0,0 +1,60 @@
+/*
+ * Copyright 2026, TeamDev. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+'use strict';
+
+/**
+ * Phone codes of the EU countries.
+ */
+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'
+};
diff --git a/site/assets/js/pages/checkout/view-controller.js b/site/assets/js/pages/checkout/view-controller.js
new file mode 100644
index 00000000..f6dfec6e
--- /dev/null
+++ b/site/assets/js/pages/checkout/view-controller.js
@@ -0,0 +1,218 @@
+/*
+ * Copyright 2026, TeamDev. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+'use strict';
+
+/**
+ * @typedef {import('js/pages/checkout/dom').CheckoutDom} CheckoutDom
+ */
+
+/**
+ * API exposed by the checkout view controller.
+ *
+ * @typedef {Object} CheckoutViewController
+ * @property {function(): void} closeErrorModal
+ * closes the generic checkout error modal
+ * @property {function(Object): void} fillProductSummary
+ * fills summary fields with product data
+ * @property {function(): boolean} isFormHidden
+ * 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} showErrorModal
+ * opens the generic checkout error modal
+ * @property {function(): void} showNotFoundView
+ * shows the checkout product-not-found panel
+ * @property {function(): void} showSummaryError
+ * shows the generic checkout summary-error panel
+ * @property {function(Object): void} updateCharges
+ * refreshes summary totals from charge-calculation response
+ */
+
+/**
+ * Creates the checkout view controller.
+ *
+ * @param {CheckoutDom} dom checkout DOM references
+ * @return {CheckoutViewController} view update helpers for the checkout page
+ */
+export function createCheckoutView(dom) {
+
+ /**
+ * Enables or disables the checkout submit button.
+ *
+ * @param {boolean} isDisabled whether submit should be disabled
+ */
+ function setSubmitDisabled(isDisabled) {
+ dom.$submitButton.prop('disabled', isDisabled);
+ }
+
+ /**
+ * Checks whether the checkout form is currently hidden.
+ *
+ * @return {boolean} true when the form is hidden
+ */
+ function isFormHidden() {
+ return dom.$form.prop('hidden');
+ }
+
+ /**
+ * Fills the order summary with product details returned by Paygate.
+ *
+ * @param {Object} product paygate product data for the current order
+ */
+ function fillProductSummary(product) {
+ if (!product) {
+ return;
+ }
+
+ dom.$productName.text(product.name || 'Unnamed product').prop('hidden', false);
+
+ if (product.description) {
+ dom.$productDescription.text(product.description).prop('hidden', false);
+ } else {
+ dom.$productDescription.text('').prop('hidden', true);
+ }
+
+ dom.$subtotalValue.text(formatMoney(product.netAmount));
+ dom.$vatLabel.text('VAT');
+ dom.$vatValue.text(formatMoney(zeroMoney(product.netAmount.currency)));
+ dom.$totalValue.text(formatMoney(product.netAmount));
+ }
+
+ /**
+ * Updates the order summary from the Paygate charge calculation response.
+ *
+ * @param {Object} response paygate charge calculation response
+ */
+ function updateCharges(response) {
+ const vatRatePercent = Number(response.vatRate) * 100;
+
+ dom.$vatLabel.text(`VAT (${String(vatRatePercent)}%)`);
+ 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
+ */
+ 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...');
+ }
+ }
+
+ /**
+ * 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);
+ }
+
+ /**
+ * Opens the generic checkout error modal.
+ */
+ function showErrorModal() {
+ dom.$errorModal.prop('hidden', false);
+ }
+
+ /**
+ * Closes the generic checkout error modal.
+ */
+ function closeErrorModal() {
+ dom.$errorModal.prop('hidden', true);
+ }
+
+ /**
+ * Shows the not-found result panel inside the checkout page.
+ */
+ function showNotFoundView() {
+ closeErrorModal();
+ dom.$summary.prop('hidden', true);
+ dom.$form.prop('hidden', true);
+ dom.$summaryError.prop('hidden', true);
+ dom.$notFound.prop('hidden', false);
+ }
+
+ /**
+ * Formats a money value with its currency symbol.
+ *
+ * @param {Object} amount money value returned by Paygate
+ * @return {string} formatted money value
+ */
+ function formatMoney(amount) {
+ const numericAmount = Number(amount.value);
+ const formattedAmount = Number.isNaN(numericAmount)
+ ? String(amount.value || '')
+ : numericAmount.toFixed(2);
+ const currency = amount.currency;
+
+ return `${currency.symbol}${formattedAmount}`;
+ }
+
+ /**
+ * Creates a zero-value money payload for the given currency.
+ *
+ * @param {Object} currency money currency
+ * @return {{value: number, currency: Object}} zero money value
+ */
+ function zeroMoney(currency) {
+ return {
+ value: 0,
+ currency
+ };
+ }
+
+ return {
+ closeErrorModal,
+ fillProductSummary,
+ isFormHidden,
+ setSubmitDisabled,
+ setSummaryLoading,
+ showErrorModal,
+ showNotFoundView,
+ showSummaryError,
+ updateCharges
+ };
+}
diff --git a/site/assets/scss/main.scss b/site/assets/scss/main.scss
index 3f9c416f..786df24f 100644
--- a/site/assets/scss/main.scss
+++ b/site/assets/scss/main.scss
@@ -42,6 +42,9 @@
@import "modules/checkbox";
@import "modules/redirect-screen";
@import "modules/loader";
+@import "modules/forms";
+@import "modules/result-panel";
+@import "modules/message-modal";
@import "pages/landing";
@import "pages/release-notes/release-notes";
@@ -51,4 +54,5 @@
@import "pages/about";
@import "pages/licenses";
@import "pages/privacy";
+@import "pages/checkout";
@import "pages/blog";
diff --git a/site/assets/scss/modules/_forms.scss b/site/assets/scss/modules/_forms.scss
new file mode 100644
index 00000000..f140799a
--- /dev/null
+++ b/site/assets/scss/modules/_forms.scss
@@ -0,0 +1,331 @@
+/*!
+ * Copyright 2026, TeamDev. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+$form-error-color: #d93025;
+$form-success-color: #2e7d32;
+
+.form-title {
+ padding-top: 0;
+ margin-bottom: 24px;
+ color: $black;
+ font-size: 24px !important;
+ font-weight: 700;
+ line-height: 1.2;
+}
+
+.form-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 24px 16px;
+}
+
+.form-field-full {
+ grid-column: 1 / -1;
+}
+
+.form-label {
+ display: block;
+ margin-bottom: 6px;
+ color: $black;
+ 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);
+ box-shadow: 0 0 0 3px rgba($form-error-color, .12);
+ }
+
+ .error-message {
+ display: block;
+ }
+}
+
+.required-label::after {
+ content: ' *';
+ color: rgba($form-error-color, .92);
+ font-weight: 700;
+}
+
+.form-input {
+ display: block;
+ width: 100%;
+ min-width: 0;
+ padding: 13px 15px;
+ color: $black;
+ font-size: 16px;
+ font-weight: 400;
+ line-height: 1.3;
+ background-color: white;
+ border: 1px solid rgba(black, .12);
+ border-radius: $border-radius-m;
+ box-shadow: inset 0 1px 1px rgba(black, .02);
+ transition: border-color .2s ease-in-out, box-shadow .2s ease-in-out;
+
+ &:focus {
+ outline: none;
+ border-color: rgba($main-brand-color, .55);
+ box-shadow: 0 0 0 3px rgba($main-brand-color, .12);
+ }
+}
+
+.form-input-status {
+ position: relative;
+}
+
+.form-field-vat .form-input {
+ padding-right: 44px;
+}
+
+.form-field-vat .form-input-status::after {
+ position: absolute;
+ top: 50%;
+ right: 15px;
+ pointer-events: none;
+ transform: translateY(-50%);
+}
+
+.form-field-vat.field-loading .form-input {
+ border-color: rgba($main-brand-color, .5);
+ box-shadow: 0 0 0 3px rgba($main-brand-color, .1);
+}
+
+.form-field-vat.field-loading .form-input-status::after {
+ content: '';
+ width: 16px;
+ height: 16px;
+ border: 2px solid rgba($main-brand-color, .16);
+ border-top-color: rgba($main-brand-color, .95);
+ border-radius: 50%;
+ animation: vat-field-spin .75s linear infinite;
+}
+
+.form-field-vat.field-success .form-input {
+ border-color: rgba($form-success-color, .55);
+ box-shadow: 0 0 0 3px rgba($form-success-color, .12);
+}
+
+.form-field-vat.field-success .form-input-status::after {
+ content: '';
+ width: 10px;
+ height: 6px;
+ border-left: 2px solid rgba($form-success-color, .95);
+ border-bottom: 2px solid rgba($form-success-color, .95);
+ transform: translateY(-58%) rotate(-45deg);
+}
+
+.form-select {
+ appearance: none;
+ background-image:
+ linear-gradient(45deg, transparent 50%, $gray-500 50%),
+ linear-gradient(135deg, $gray-500 50%, transparent 50%);
+ background-position:
+ calc(100% - 18px) 50%,
+ calc(100% - 12px) 50%;
+ background-repeat: no-repeat;
+ background-size: 6px 6px, 6px 6px;
+ 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);
+ font-size: 13px;
+ line-height: 1.35;
+}
+
+@keyframes vat-field-spin {
+ to {
+ transform: translateY(-50%) rotate(360deg);
+ }
+}
+
+.form-actions {
+ margin: 40px 0 8px;
+}
+
+.form-submit-button {
+ min-width: 220px;
+ text-decoration: none;
+
+ &:hover,
+ &:focus {
+ text-decoration: none;
+ }
+}
+
+@include breakpoint(lg-phone) {
+ .form-grid {
+ grid-template-columns: 1fr;
+ gap: 24px;
+ }
+
+ .form-field-full {
+ grid-column: auto;
+ }
+}
+
+@include breakpoint(xmd-phone) {
+ .form-submit-button {
+ width: 100%;
+ min-width: 0;
+ }
+}
+
+@include breakpoint(sm-phone) {
+ .form-input {
+ padding: 12px 14px;
+ font-size: 15px;
+ }
+
+ .phone-flag {
+ left: 15px;
+ font-size: 18px;
+ }
+
+ .phone-chevron {
+ left: 40px;
+ }
+}
diff --git a/site/assets/scss/modules/_message-modal.scss b/site/assets/scss/modules/_message-modal.scss
new file mode 100644
index 00000000..f0818b17
--- /dev/null
+++ b/site/assets/scss/modules/_message-modal.scss
@@ -0,0 +1,106 @@
+/*!
+ * Copyright 2026, TeamDev. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+.message-modal {
+ position: fixed;
+ inset: 0;
+ z-index: map_get($z-index, 'redirect-screen');
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 24px;
+
+ &[hidden] {
+ display: none;
+ }
+
+ .message-modal-backdrop {
+ position: absolute;
+ inset: 0;
+ background: rgba(black, .45);
+ }
+
+ .message-modal-dialog {
+ position: relative;
+ z-index: 1;
+ width: 100%;
+ max-width: 520px;
+ padding: 30px 34px;
+ background: white;
+ border-radius: $border-radius-m;
+ box-shadow: 0 22px 60px rgba(black, .18);
+ overflow: hidden;
+ }
+
+ .message-modal-close {
+ position: absolute;
+ top: 10px;
+ right: 12px;
+ padding: 4px 8px;
+ color: $gray-500;
+ font-size: 28px;
+ line-height: 1;
+ background: transparent;
+ border: 0;
+ cursor: pointer;
+ }
+
+ .message-modal-title {
+ margin: 0 28px 12px 0;
+ padding: 0;
+ color: $black;
+ font-size: 24px;
+ line-height: 1.2;
+ }
+
+ .message-modal-text {
+ margin: 0;
+ color: $gray-500;
+ font-size: 16px;
+ line-height: 1.55;
+
+ a {
+ color: $main-brand-color;
+ font-weight: 700;
+ text-decoration: none;
+
+ &:hover,
+ &:focus {
+ text-decoration: underline;
+ }
+ }
+ }
+}
+
+@include breakpoint(sm-phone) {
+ .message-modal {
+ padding: 16px;
+ }
+
+ .message-modal .message-modal-dialog {
+ padding: 24px;
+ }
+}
diff --git a/site/assets/scss/modules/_result-panel.scss b/site/assets/scss/modules/_result-panel.scss
new file mode 100644
index 00000000..f243c6b0
--- /dev/null
+++ b/site/assets/scss/modules/_result-panel.scss
@@ -0,0 +1,183 @@
+/*!
+ * Copyright 2026, TeamDev. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+.result-page {
+ flex: 1 0 auto;
+ min-height: 420px;
+ 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);
+ overflow-x: hidden;
+
+ .content-holder,
+ .row {
+ flex: 1 0 auto;
+ }
+
+ .content-with-fixed-header {
+ margin-top: $header-height;
+
+ @include breakpoint(desktop) {
+ margin-top: $header-height;
+ }
+ }
+
+ .content-holder,
+ .article-container {
+ display: flex;
+ flex-direction: column;
+ }
+
+ .article-container {
+ flex: 1 0 auto;
+ height: 100%;
+ }
+
+ .row {
+ margin-right: 0;
+ margin-left: 0;
+ }
+
+ .row > [class*='col-'] {
+ padding-right: 0;
+ padding-left: 0;
+ }
+
+ @include breakpoint(lg-phone) {
+ background: white;
+ }
+
+ @include breakpoint(sm-phone) {
+ min-height: auto;
+ }
+}
+
+.result-panel {
+ max-width: 620px;
+ margin: 0 auto;
+ padding: 20px 0 30px;
+ text-align: center;
+}
+
+.result-panel-mark {
+ position: relative;
+ width: 64px;
+ height: 64px;
+ margin: 0 auto 9px;
+ background: rgba($main-brand-color, .1);
+ border: 1px solid rgba($main-brand-color, .18);
+ border-radius: 50%;
+}
+
+.result-panel-mark-success::after {
+ content: '';
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ width: 18px;
+ height: 30px;
+ border-right: 4px solid $main-brand-color;
+ border-bottom: 4px solid $main-brand-color;
+ transform: translate(-50%, -58%) rotate(42deg);
+ transform-origin: center;
+}
+
+.result-panel-mark-code {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: $main-brand-color;
+ font-size: 17px;
+ font-weight: 800;
+ letter-spacing: 0;
+}
+
+.result-panel h1.result-panel-title,
+.result-panel h2.result-panel-title {
+ margin: 0 0 14px;
+ color: $black;
+ font-size: 36px;
+ font-weight: 800;
+ line-height: 1.15;
+ letter-spacing: 0;
+ text-align: center;
+}
+
+.result-panel p.result-panel-text {
+ max-width: 520px;
+ margin: 0 auto 30px;
+ color: $gray-500;
+ font-size: 17px;
+ font-weight: 400;
+ line-height: 1.55;
+ text-align: center;
+ letter-spacing: 0;
+
+ span {
+ display: block;
+ text-align: center;
+ }
+}
+
+.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;
+ text-decoration: none;
+ text-transform: uppercase;
+
+ &:hover,
+ &:focus {
+ text-decoration: none;
+ }
+}
+
+@include breakpoint(sm-phone) {
+ .result-panel {
+ padding: 14px 0 16px;
+ }
+
+ .result-panel-mark {
+ width: 58px;
+ height: 58px;
+ margin-bottom: 8px;
+ }
+
+ .result-panel h1.result-panel-title,
+ .result-panel h2.result-panel-title {
+ font-size: 28px;
+ }
+
+ .result-panel p.result-panel-text {
+ font-size: 16px;
+ }
+}
diff --git a/site/assets/scss/pages/_checkout.scss b/site/assets/scss/pages/_checkout.scss
new file mode 100644
index 00000000..526ebff2
--- /dev/null
+++ b/site/assets/scss/pages/_checkout.scss
@@ -0,0 +1,367 @@
+/*!
+ * Copyright 2026, TeamDev. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+.checkout-page {
+ .main {
+ display: flex;
+ flex-direction: column;
+ }
+}
+
+.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);
+
+ @include breakpoint(lg-phone) {
+ background: white;
+ }
+
+ .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;
+ }
+
+ .row > [class*='col-'] {
+ padding-right: 0;
+ padding-left: 0;
+ }
+
+ .content-holder,
+ .article-container {
+ display: flex;
+ flex: 1 0 auto;
+ flex-direction: column;
+ }
+
+ .article-container {
+ height: 100%;
+
+ @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;
+ }
+
+ .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;
+ }
+ }
+
+ .checkout-summary-details {
+ border-top: none;
+ padding-top: 8px;
+ }
+
+ .checkout-summary-loading {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+ max-width: 420px;
+ margin: 0 auto;
+ 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;
+ 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;
+ }
+ }
+ }
+
+ .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-summary-row-divider {
+ border-bottom: 1px solid rgba($black, .12);
+ }
+
+ .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-product {
+ max-width: 100%;
+ padding-bottom: 8px;
+ color: rgba($black, .4);
+ font-size: 42px;
+ font-weight: 300;
+ letter-spacing: 0;
+ line-height: 1.12;
+ overflow-wrap: anywhere;
+
+ @include breakpoint(md-phone) {
+ font-size: 32px !important;
+ }
+ }
+
+ .checkout-summary-product-description {
+ margin-bottom: 28px;
+ color: rgba($black, .54);
+ font-size: 16px;
+ font-weight: 400;
+ line-height: 1.5;
+
+ @include breakpoint(lg-phone) {
+ margin-bottom: 24px;
+ }
+
+ @include breakpoint(md-phone) {
+ font-size: 14px;
+ margin-bottom: 16px;
+ }
+ }
+
+ .checkout-summary-label {
+ flex: 0 0 auto;
+ min-width: 0;
+ color: $black;
+ 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-value-total {
+ color: $black;
+ font-size: 20px;
+ font-weight: 700;
+ letter-spacing: 0;
+ line-height: 1.1;
+ }
+
+ @include breakpoint(lg-phone) {
+ margin-bottom: 16px;
+
+ .checkout-summary-product {
+ font-size: 28px;
+ padding-bottom: 6px;
+ }
+
+ .checkout-summary-row {
+ column-gap: 12px;
+ padding: 10px 0 12px;
+ }
+ }
+
+ @include breakpoint(md-phone) {
+ .checkout-summary-label {
+ font-size: 15px;
+ }
+
+ .checkout-summary-value-amount {
+ font-size: 16px;
+ }
+
+ .checkout-summary-row-total .checkout-summary-label,
+ .checkout-summary-value-total {
+ font-size: 20px;
+ }
+ }
+
+ @include breakpoint(sm-phone) {
+ .checkout-summary-product {
+ font-size: 26px;
+ }
+
+ .checkout-summary-row {
+ padding: 8px 0;
+ }
+
+ .checkout-summary-value-amount {
+ font-size: 15px;
+ }
+
+ .checkout-summary-row-total .checkout-summary-label,
+ .checkout-summary-value-total {
+ font-size: 18px;
+ }
+ }
+}
+
+.checkout .form-section {
+ margin-top: 8px;
+
+ .form-title {
+ padding-top: 40px;
+
+ @include breakpoint(lg-phone) {
+ padding-top: 8px;
+ }
+ }
+}
+
+#checkout-not-found,
+#checkout-summary-error {
+ margin-top: 32px;
+
+ @include breakpoint(desktop) {
+ margin-top: 16px;
+ }
+}
+
+.checkout-completed {
+ min-height: 420px;
+
+ @include breakpoint(sm-phone) {
+ min-height: auto;
+ }
+}
diff --git a/site/config/_default/hugo.toml b/site/config/_default/hugo.toml
index 0641b07a..fbfaeb14 100644
--- a/site/config/_default/hugo.toml
+++ b/site/config/_default/hugo.toml
@@ -19,6 +19,9 @@ disableKinds = ['taxonomy', 'term']
orderURL = 'https://secure.2checkout.com/checkout/buy?merchant=999999999589¤cy=EUR&tpl=default&prod=4HJME1JJDF&qty=1'
reCaptchaKey = '6Lef7b0ZAAAAAHPIbf6XQIzzeyCoSzS56GTej1c0'
+[params.paygate]
+ serverURL = 'https://stag.paygate.teamdev.com'
+
[params.libs.docsearch]
appId = 'DUYV0WFHKV'
apiKey = '50ce4ead490484f1436ae042c0a1a4dd'
diff --git a/site/config/development/hugo.toml b/site/config/development/hugo.toml
index 1c036cfc..ab1f3487 100644
--- a/site/config/development/hugo.toml
+++ b/site/config/development/hugo.toml
@@ -1,2 +1,5 @@
[params.payment]
apiURL = 'http://localhost:5002/spine-site-server/us-central1/paymentTransaction'
+
+[params.paygate]
+ serverURL = 'https://stag.paygate.teamdev.com'
diff --git a/site/content/checkout-completed/index.md b/site/content/checkout-completed/index.md
new file mode 100644
index 00000000..f524801a
--- /dev/null
+++ b/site/content/checkout-completed/index.md
@@ -0,0 +1,8 @@
+---
+title: Checkout Completed
+description: Thank you page about completed checkout.
+body_class: checkout-page
+header_type: fixed-header
+sitemap:
+ disable: true
+---
diff --git a/site/content/checkout/index.md b/site/content/checkout/index.md
new file mode 100644
index 00000000..9ac099b7
--- /dev/null
+++ b/site/content/checkout/index.md
@@ -0,0 +1,9 @@
+---
+title: Checkout
+description: Checkout form to buy a product.
+body_class: checkout-page
+customjs: js/pages/checkout/index.js
+header_type: fixed-header
+sitemap:
+ disable: true
+---
diff --git a/site/layouts/404.html b/site/layouts/404.html
new file mode 100644
index 00000000..d2868e3a
--- /dev/null
+++ b/site/layouts/404.html
@@ -0,0 +1,29 @@
+{{ define "main" }}
+ {{ partial "components/navbar/navbar.html" (dict "Params" (dict "header_type" "fixed-header")) }}
+
+{{ end }}
diff --git a/site/layouts/_partials/components/result-panel.html b/site/layouts/_partials/components/result-panel.html
new file mode 100644
index 00000000..8cca5326
--- /dev/null
+++ b/site/layouts/_partials/components/result-panel.html
@@ -0,0 +1,62 @@
+
+
+
+
+{{ $title := .title }}
+{{ $titleAsH1 := .title_as_h1 }}
+{{ $mark := .mark }}
+{{ $lines := .lines | default slice }}
+{{ $action := .action }}
+
+
+ {{ with $mark }}
+ {{ $markText := .text | default .content | default "" }}
+ {{ $markClass := .icon_class | default "" }}
+
{{ $markText }}
+ {{ end }}
+ {{ if $titleAsH1 }}
+
{{ $title }}
+ {{ else }}
+
{{ $title }}
+ {{ end }}
+ {{ if $lines }}
+
+ {{ range $lines }}
+ {{ . }}
+ {{ end }}
+
+ {{ end }}
+ {{ with $action }}
+ {{ $actionLabel := .label | default .text | default "" }}
+ {{ $actionUrl := .url | default site.Home.RelPermalink }}
+ {{ if $actionLabel }}
+
+ {{ $actionLabel }}
+
+ {{ end }}
+ {{ end }}
+
diff --git a/site/layouts/_partials/scripts/body-scripts.html b/site/layouts/_partials/scripts/body-scripts.html
index dd2c48e4..85783cf8 100644
--- a/site/layouts/_partials/scripts/body-scripts.html
+++ b/site/layouts/_partials/scripts/body-scripts.html
@@ -26,10 +26,12 @@
{{ $environment := hugo.Environment }}
{{ $payment := site.Params.payment }}
+{{ $paygate := site.Params.paygate }}
{{ $params := (dict
"environment" $environment
"payment" $payment
+ "paygate" $paygate
)}}
{{ partial "theme/scripts/body/baseurl.html" . }}
diff --git a/site/layouts/checkout-completed/single.html b/site/layouts/checkout-completed/single.html
new file mode 100644
index 00000000..14e24484
--- /dev/null
+++ b/site/layouts/checkout-completed/single.html
@@ -0,0 +1,28 @@
+{{ define "main" }}
+ {{ partial "components/navbar/navbar.html" . }}
+
+{{ end }}
diff --git a/site/layouts/checkout/single.html b/site/layouts/checkout/single.html
new file mode 100644
index 00000000..e8c53796
--- /dev/null
+++ b/site/layouts/checkout/single.html
@@ -0,0 +1,280 @@
+{{ define "main" }}
+ {{ partial "components/navbar/navbar.html" . }}
+
+ {{ partial "components/go-top-button.html" . }}
+{{ end }}