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")) }} +

+
+
+
+
+ {{ partial "components/result-panel.html" (dict + "title" "Page not found" + "title_as_h1" true + "mark" (dict + "text" "404" + "icon_class" "result-panel-mark-code" + ) + "lines" (slice + "The page you are looking for could not be found." + "Please check the address or return to the home page." + ) + "action" (dict + "label" "Back to home" + "url" site.Home.RelPermalink + ) + ) }} +
+
+
+
+
+{{ 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 "" }} + + {{ 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" . }} +
+
+
+
+
+ {{ partial "components/result-panel.html" (dict + "title" "Thank you for your purchase" + "title_as_h1" true + "mark" (dict + "icon_class" "result-panel-mark-success" + ) + "lines" (slice + "Your payment was completed successfully." + "We will send the order details to your email." + ) + "action" (dict + "label" "Back to home" + "url" site.Home.RelPermalink + ) + ) }} +
+
+
+
+
+{{ 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" . }} +
+
+
+
+
+
+
+
+ +

Loading checkout details...

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

Billing details

+
+
+ + +
+
+ +
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + + + + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+
+ + + + {{ .Content }} +
+
+
+
+
+ {{ partial "components/go-top-button.html" . }} +{{ end }}